6b863d2d2d
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
106 lines
3.7 KiB
Rust
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");
|
|
}
|