Skip to content

Commit 9441530

Browse files
committed
test(vfs): cover path canonicalization and lock aliasing on the memory backend
Adds tests asserting canonical_native_path/resolve_native_path reject escaping and drive-letter components, and that the in-memory VFS treats equivalent path spellings as one lock domain and one file, matching the native backends' contract. Also switches two String clones in MemVfs away from unnecessary to_string() calls.
1 parent aa05ca1 commit 9441530

3 files changed

Lines changed: 114 additions & 4 deletions

File tree

src/vfs/memory.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ impl Vfs for MemVfs {
125125
}
126126
(OpenMode::CreateNew | OpenMode::CreateOrOpen, false) => {
127127
let inode = Arc::new(Mutex::new(MemInode { data: Vec::new() }));
128-
files.insert(path.to_string(), inode.clone());
128+
files.insert(path.clone(), inode.clone());
129129
(inode, true)
130130
}
131131
};
@@ -155,7 +155,7 @@ impl Vfs for MemVfs {
155155
async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
156156
let path = canonical_native_path(path)?;
157157
let prefix = if path.ends_with('/') {
158-
path.to_string()
158+
path.clone()
159159
} else {
160160
format!("{path}/")
161161
};

src/vfs/traits.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,50 @@ mod tests {
636636
}
637637
}
638638

639+
#[test]
640+
fn a_logical_path_normalizes_its_separators_and_leading_slash() {
641+
for spelling in ["/seg/abc", "seg/abc", "seg\\abc", "/seg/abc/"] {
642+
assert_eq!(canonical_native_path(spelling).unwrap(), "/seg/abc");
643+
}
644+
assert_eq!(canonical_native_path("").unwrap(), "/");
645+
assert_eq!(canonical_native_path("/").unwrap(), "/");
646+
}
647+
648+
#[test]
649+
fn a_parent_or_current_directory_component_never_resolves() {
650+
for escape in ["../escaped", "a/../../escaped", "./a", "a//b", "a/./b"] {
651+
assert!(
652+
canonical_native_path(escape).is_err(),
653+
"{escape:?} must not canonicalize"
654+
);
655+
}
656+
}
657+
658+
#[test]
659+
fn a_drive_letter_component_is_rejected_wherever_it_appears() {
660+
// Rejected on every target, not just the one that gives it meaning.
661+
// A platform prefix is only recognised in leading position, so a drive
662+
// letter deeper in the path parses as an ordinary name — and pushing it
663+
// would replace the root outright rather than extend it.
664+
for drive in ["C:", "a/C:/b", "C:/escaped", "a/C:"] {
665+
assert!(
666+
canonical_native_path(drive).is_err(),
667+
"{drive:?} must not canonicalize"
668+
);
669+
}
670+
}
671+
672+
#[test]
673+
fn a_resolved_path_always_stays_under_its_root() {
674+
let root = std::path::Path::new("/srv/pagedb");
675+
assert_eq!(
676+
resolve_native_path(root, "seg/abc").unwrap(),
677+
root.join("seg").join("abc")
678+
);
679+
assert!(resolve_native_path(root, "../escaped").is_err());
680+
assert!(resolve_native_path(root, "a/C:/b").is_err());
681+
}
682+
639683
#[test]
640684
fn readfile_len_accepts_u32_max() {
641685
assert_eq!(checked_readfile_len(u32::MAX as usize).unwrap(), u32::MAX);
@@ -656,7 +700,16 @@ mod tests {
656700
#[test]
657701
fn read_count_rejects_overreported_completion() {
658702
let err = checked_read_count(11, 10).unwrap_err();
659-
assert!(matches!(err, crate::errors::PagedbError::Io(_)));
703+
assert!(
704+
matches!(
705+
err,
706+
crate::errors::PagedbError::VfsContractViolated {
707+
operation: "read_at",
708+
..
709+
}
710+
),
711+
"expected VfsContractViolated, got {err:?}"
712+
);
660713
}
661714

662715
#[test]
@@ -796,7 +849,16 @@ mod tests {
796849
fn write_progress_rejects_overreported_count() {
797850
let mut offset = 7;
798851
let err = checked_write_progress(&mut offset, 11, 10).unwrap_err();
799-
assert!(matches!(err, crate::errors::PagedbError::Io(_)));
852+
assert!(
853+
matches!(
854+
err,
855+
crate::errors::PagedbError::VfsContractViolated {
856+
operation: "write_at",
857+
..
858+
}
859+
),
860+
"expected VfsContractViolated, got {err:?}"
861+
);
800862
assert_eq!(offset, 7, "failed progress must not advance offset");
801863
}
802864

tests/vfs_memory.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,51 @@ async fn read_mode_handle_cannot_write() {
208208
let err = g.write_at(0, b"nope").await.err().unwrap();
209209
assert!(matches!(err, PagedbError::ReadOnly));
210210
}
211+
212+
/// The in-memory backend is the one nearly every test runs on, so it has to
213+
/// honour the same logical-path contract as the native backends. Spellings that
214+
/// name one file must occupy one lock domain here too, or the suite stops being
215+
/// able to catch path aliasing at all.
216+
#[tokio::test(flavor = "current_thread")]
217+
async fn equivalent_spellings_share_one_lock_domain() {
218+
let vfs = MemVfs::new();
219+
let held = vfs.lock_exclusive("/db.lock").await.unwrap();
220+
for alias in ["db.lock", "/db.lock", "db.lock/"] {
221+
assert!(
222+
matches!(
223+
vfs.lock_exclusive(alias).await,
224+
Err(pagedb::PagedbError::AlreadyLocked)
225+
),
226+
"{alias:?} must contend with the held lock"
227+
);
228+
}
229+
drop(held);
230+
vfs.lock_exclusive("db.lock").await.unwrap();
231+
}
232+
233+
/// Equivalent spellings must also name one file.
234+
#[tokio::test(flavor = "current_thread")]
235+
async fn equivalent_spellings_name_one_file() {
236+
let vfs = MemVfs::new();
237+
let mut f = vfs.open("seg/one", OpenMode::CreateNew).await.unwrap();
238+
f.write_at(0, b"payload").await.unwrap();
239+
drop(f);
240+
241+
let mut buf = [0u8; 7];
242+
let g = vfs.open("/seg/one", OpenMode::Read).await.unwrap();
243+
assert_eq!(g.read_at(0, &mut buf).await.unwrap(), 7);
244+
assert_eq!(&buf, b"payload");
245+
assert_eq!(vfs.list_dir("/seg").await.unwrap(), vec!["one".to_string()]);
246+
}
247+
248+
/// A path that escapes the root is rejected by the in-memory backend as well.
249+
#[tokio::test(flavor = "current_thread")]
250+
async fn a_path_that_escapes_the_root_is_rejected() {
251+
let vfs = MemVfs::new();
252+
for escape in ["../escaped", "a/../../escaped", "a/C:/b"] {
253+
assert!(
254+
vfs.open(escape, OpenMode::CreateNew).await.is_err(),
255+
"{escape:?} must not open"
256+
);
257+
}
258+
}

0 commit comments

Comments
 (0)