Files
Marcos Paulo 6b863d2d2d feat(vault): wave 1 implementation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:06 -03:00

106 lines
3.7 KiB
Rust

//! The path-traversal guard (SPEC §4.2).
mod common;
use std::fs;
use notescraft_vault::Error;
/// Every escaping or malformed path is rejected with [`Error::OutsideVault`].
#[test]
fn traversal_attempts_are_rejected() {
let (_dir, vault) = common::vault("traversal");
let attacks = [
"../x",
"..",
"a/../../x",
"a/b/../../../x",
"/etc/passwd",
"/",
"C:\\x",
"C:x",
"c:/windows/system32",
"\\\\srv\\share",
"\\x",
"a\0b",
"",
".",
"./",
"..\\..\\x",
];
for attack in attacks {
let err = vault.resolve(attack).expect_err(&format!("{attack:?} must be rejected"));
assert!(matches!(err, Error::OutsideVault(_)), "{attack:?}: {err:?}");
}
}
/// `./` and duplicate separators normalise away; the result stays inside the root.
#[test]
fn harmless_paths_are_normalised() {
let (_dir, vault) = common::vault("normalise");
let root = vault.root().to_path_buf();
let cases = [("a/b.md", "a/b.md"), ("a//b.md", "a/b.md"), ("./a/b.md", "a/b.md"), ("a/./b.md", "a/b.md"), ("a/b.md/", "a/b.md")];
for (input, expected) in cases {
assert_eq!(vault.resolve(input).expect(input), root.join(expected), "{input}");
}
}
/// On a case-insensitive file system the on-disk case is what `resolve` returns.
#[test]
fn resolve_returns_the_on_disk_case_when_the_file_exists() {
let (_dir, vault) = common::vault("case");
fs::write(vault.root().join("Note.md"), b"# hi").unwrap();
let resolved = vault.resolve("note.md").unwrap();
if resolved.exists() {
assert_eq!(resolved.file_name().unwrap(), "Note.md");
assert_eq!(vault.read("note.md").unwrap(), b"# hi");
}
}
/// Reads and writes of escaping paths fail the same way as `resolve`.
#[test]
fn every_entry_point_uses_the_guard() {
let (_dir, vault) = common::vault("entry");
for bad in ["../x", "/etc/passwd", "a\0b"] {
assert!(matches!(vault.read(bad), Err(Error::OutsideVault(_))), "{bad}");
assert!(matches!(vault.write(bad, b"x"), Err(Error::OutsideVault(_))), "{bad}");
assert!(matches!(vault.rename(bad, "ok.md"), Err(Error::OutsideVault(_))), "{bad}");
assert!(matches!(vault.delete_to_trash(bad), Err(Error::OutsideVault(_))), "{bad}");
assert!(matches!(vault.create_dir(bad), Err(Error::OutsideVault(_))), "{bad}");
}
}
#[cfg(unix)]
#[test]
fn a_symlink_that_leaves_the_vault_is_rejected() {
use std::os::unix::fs::symlink;
let (dir, vault) = common::vault("escape");
let outside = common::TempDir::new("outside");
fs::write(outside.path().join("secret.md"), b"secret").unwrap();
symlink(outside.path(), dir.path().join("link")).unwrap();
for bad in ["link", "link/secret.md"] {
let err = vault.resolve(bad).expect_err(bad);
assert!(matches!(err, Error::OutsideVault(_)), "{bad}: {err:?}");
assert!(matches!(vault.read(bad), Err(Error::OutsideVault(_))), "{bad}");
assert!(matches!(vault.write(bad, b"x"), Err(Error::OutsideVault(_))), "{bad}");
}
assert_eq!(fs::read(dir.path().join("link/secret.md")).unwrap(), b"secret");
}
#[cfg(unix)]
#[test]
fn a_symlink_that_stays_inside_the_vault_is_allowed() {
use std::os::unix::fs::symlink;
let (dir, vault) = common::vault("inside");
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub/ok.md"), b"inside").unwrap();
symlink(dir.path().join("sub"), dir.path().join("inner")).unwrap();
let resolved = vault.resolve("inner/ok.md").unwrap();
assert!(resolved.starts_with(vault.root()));
assert_eq!(vault.read("inner/ok.md").unwrap(), b"inside");
}