f3f1f51d9e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
259 lines
11 KiB
Rust
259 lines
11 KiB
Rust
//! Hostile filenames, symlinks, output limits and subprocess failure boundaries.
|
|
|
|
use notescraft_git::{ChangeKind, Error, Git, git_available};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
struct Temp(PathBuf);
|
|
|
|
impl Temp {
|
|
fn new() -> Self {
|
|
let path =
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/tmp").join(format!("safety-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir_all(&path).expect("temp");
|
|
Self(path)
|
|
}
|
|
fn run(&self, args: &[&str]) -> String {
|
|
let output = Command::new("git").args(args).current_dir(&self.0).output().expect("git");
|
|
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
|
String::from_utf8(output.stdout).expect("utf8")
|
|
}
|
|
fn init(&self) -> Git {
|
|
let git = Git::open(&self.0);
|
|
git.init().expect("init");
|
|
self.run(&["config", "user.name", "NotesCraft test"]);
|
|
self.run(&["config", "user.email", "test@example.invalid"]);
|
|
git
|
|
}
|
|
}
|
|
|
|
impl Drop for Temp {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn available() -> bool {
|
|
if git_available() {
|
|
return true;
|
|
}
|
|
eprintln!("git missing: skipping real git test");
|
|
false
|
|
}
|
|
|
|
#[test]
|
|
fn literal_globs_and_reserved_descendants() {
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
let git = temp.init();
|
|
std::fs::write(temp.0.join("*.md"), "literal\n").expect("literal");
|
|
std::fs::write(temp.0.join("ordinary.md"), "ordinary\n").expect("ordinary");
|
|
git.stage(&["*.md".into()]).expect("literal stage");
|
|
assert_eq!(temp.run(&["diff", "--cached", "--name-only"]), "*.md\n");
|
|
assert_eq!(git.status().expect("status").changes.iter().find(|change| change.path == "*.md").expect("added").index, ChangeKind::Added);
|
|
git.unstage(&["*.md".into()]).expect("unstage before first commit");
|
|
assert_eq!(temp.run(&["ls-files"]), "");
|
|
std::fs::create_dir_all(temp.0.join("folder/.obsidian")).expect("reserved directory");
|
|
std::fs::write(temp.0.join("folder/.obsidian/config"), "keep").expect("reserved");
|
|
assert!(git.stage(&["folder".into()]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parent_repository_does_not_count_as_vault_repository() {
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
temp.init();
|
|
std::fs::create_dir(temp.0.join("nested")).expect("nested");
|
|
let nested = Git::open(&temp.0.join("nested"));
|
|
assert!(!nested.is_repo());
|
|
assert!(matches!(nested.status(), Err(Error::NotARepository)));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn symlinks_and_non_utf8_paths_are_guarded() {
|
|
use std::io::Write;
|
|
use std::os::unix::fs::symlink;
|
|
use std::process::Stdio;
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
let outside = Temp::new();
|
|
let git = temp.init();
|
|
symlink(&outside.0, temp.0.join("escape")).expect("escape");
|
|
symlink(temp.0.join(".git"), temp.0.join("git-alias")).expect("git alias");
|
|
std::fs::create_dir(temp.0.join(".obsidian")).expect("obsidian");
|
|
symlink(temp.0.join(".obsidian"), temp.0.join("obsidian-alias")).expect("obsidian alias");
|
|
for path in ["escape/x", "git-alias/config", "obsidian-alias/config"] {
|
|
assert!(git.stage(&[path.into()]).is_err());
|
|
assert!(git.restore_version("HEAD", path).is_err());
|
|
}
|
|
assert!(git.diff("escape/x", false).is_err());
|
|
// APFS rejects invalid UTF-8 filenames, but git's index can still contain them.
|
|
let blob = temp.run(&["hash-object", "-w", "--stdin"]);
|
|
let mut record = format!("100644 {}\t", blob.trim()).into_bytes();
|
|
record.extend_from_slice(b"n\xff.md\0");
|
|
let mut child = Command::new("git").args(["update-index", "-z", "--index-info"]).current_dir(&temp.0).stdin(Stdio::piped()).spawn().expect("index writer");
|
|
child.stdin.take().expect("stdin").write_all(&record).expect("index record");
|
|
assert!(child.wait().expect("index writer").success());
|
|
let status = git.status().expect("status");
|
|
assert_eq!(status.skipped_non_utf8, 1);
|
|
std::fs::create_dir(temp.0.join(".notescraft")).expect("state");
|
|
std::fs::write(temp.0.join(".notescraft/private"), "private").expect("private");
|
|
temp.run(&["add", "-f", "--", ".notescraft/private"]);
|
|
assert!(!git.status().expect("status").changes.iter().any(|change| change.path.starts_with(".notescraft/")));
|
|
}
|
|
|
|
#[test]
|
|
fn status_change_cap_and_newline_names() {
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
let git = temp.init();
|
|
std::fs::write(temp.0.join("line\nbreak.md"), "content").expect("newline path");
|
|
assert!(git.status().expect("status").changes.iter().any(|change| change.path == "line\nbreak.md"));
|
|
for index in 0..20_001 {
|
|
std::fs::write(temp.0.join(format!("file-{index}")), "").expect("file");
|
|
}
|
|
let status = git.status().expect("status");
|
|
assert_eq!(status.changes.len(), 20_000);
|
|
assert!(status.truncated);
|
|
}
|
|
|
|
#[test]
|
|
fn show_size_limit_and_log_limit() {
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
let git = temp.init();
|
|
std::fs::write(temp.0.join("large.md"), "x".repeat(8 * 1024 * 1024 + 1)).expect("large file");
|
|
git.stage(&["large.md".into()]).expect("stage");
|
|
git.commit("large").expect("commit");
|
|
assert!(matches!(git.show("HEAD", "large.md"), Err(Error::OutputLimit)));
|
|
assert_eq!(git.log(0, None).expect("zero log"), []);
|
|
assert_eq!(git.log(usize::MAX, None).expect("capped log").len(), 1);
|
|
assert!(matches!(git.show(&"a".repeat(1_000_000), "large.md"), Err(Error::InvalidRevision(message)) if message.len() < 100));
|
|
assert!(matches!(git.diff(&"a".repeat(1_000_000), false), Err(Error::InvalidPath(message)) if message.len() < 100));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn commit_runs_repository_hooks_and_reports_failure() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
let git = temp.init();
|
|
std::fs::write(temp.0.join("note.md"), "note\n").expect("note");
|
|
git.stage(&["note.md".into()]).expect("stage");
|
|
let hook = temp.0.join(".git/hooks/pre-commit");
|
|
std::fs::write(&hook, "#!/bin/sh\nprintf 'hook refused commit' >&2\nexit 1\n").expect("hook");
|
|
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("permissions");
|
|
assert!(matches!(git.commit("blocked"), Err(Error::Failed(message)) if message.contains("hook refused commit")));
|
|
assert_eq!(git.log(10, None).expect("no history"), []);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn stderr_capture_is_bounded() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let temp = Temp::new();
|
|
let script = temp.0.join("git");
|
|
std::fs::write(&script, "#!/bin/sh\n/usr/bin/awk 'BEGIN {for(i=0;i<100000;i++)print \"error\";exit 2}' >&2\n").expect("fake git");
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("executable");
|
|
let result = Command::new(std::env::current_exe().expect("test executable"))
|
|
.args(["--exact", "subprocess_worker", "--nocapture"])
|
|
.env("PATH", &temp.0)
|
|
.env("NOTESCRAFT_GIT_TEST_ROOT", &temp.0)
|
|
.env("NOTESCRAFT_GIT_TEST_MODE", "stderr")
|
|
.output()
|
|
.expect("worker");
|
|
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stdout));
|
|
}
|
|
|
|
#[test]
|
|
fn ahead_behind_detached_head_and_historical_directory() {
|
|
if !available() {
|
|
return;
|
|
}
|
|
let temp = Temp::new();
|
|
let git = temp.init();
|
|
std::fs::create_dir(temp.0.join("folder")).expect("folder");
|
|
std::fs::write(temp.0.join("folder/note.md"), "initial\n").expect("note");
|
|
git.stage(&["folder/note.md".into()]).expect("stage");
|
|
git.commit("initial").expect("commit");
|
|
let original = temp.run(&["branch", "--show-current"]);
|
|
temp.run(&["branch", "upstream"]);
|
|
temp.run(&["branch", "--set-upstream-to=upstream"]);
|
|
std::fs::write(temp.0.join("folder/note.md"), "ours\n").expect("ours");
|
|
git.stage(&["folder/note.md".into()]).expect("stage");
|
|
git.commit("ours").expect("commit");
|
|
assert_eq!(git.status().expect("status").ahead, 1);
|
|
temp.run(&["checkout", "upstream"]);
|
|
std::fs::write(temp.0.join("folder/note.md"), "theirs\n").expect("theirs");
|
|
git.stage(&["folder/note.md".into()]).expect("stage");
|
|
git.commit("theirs").expect("commit");
|
|
temp.run(&["checkout", original.trim()]);
|
|
let status = git.status().expect("status");
|
|
assert_eq!((status.ahead, status.behind), (1, 1));
|
|
assert_eq!(status.upstream.as_deref(), Some("upstream"));
|
|
assert!(git.show("HEAD", "folder").is_err(), "show returns file contents, not tree listings");
|
|
temp.run(&["checkout", "--detach", "HEAD"]);
|
|
assert_eq!(git.status().expect("status").branch, None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn fake_git_timeout_and_missing_executable() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let temp = Temp::new();
|
|
let script = temp.0.join("git");
|
|
std::fs::write(&script, "#!/bin/sh\nexec /bin/sleep 60\n").expect("fake git");
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("executable");
|
|
let start = std::time::Instant::now();
|
|
let result = Command::new(std::env::current_exe().expect("test executable"))
|
|
.args(["--exact", "subprocess_worker", "--nocapture"])
|
|
.env("PATH", &temp.0)
|
|
.env("NOTESCRAFT_GIT_TEST_ROOT", &temp.0)
|
|
.env("NOTESCRAFT_GIT_TEST_MODE", "timeout")
|
|
.output()
|
|
.expect("worker");
|
|
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stdout));
|
|
assert!(start.elapsed() < std::time::Duration::from_secs(40));
|
|
std::fs::remove_file(script).expect("remove fake git");
|
|
let result = Command::new(std::env::current_exe().expect("test executable"))
|
|
.args(["--exact", "subprocess_worker", "--nocapture"])
|
|
.env("PATH", &temp.0)
|
|
.env("NOTESCRAFT_GIT_TEST_ROOT", &temp.0)
|
|
.env("NOTESCRAFT_GIT_TEST_MODE", "missing")
|
|
.output()
|
|
.expect("worker");
|
|
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stdout));
|
|
}
|
|
|
|
#[test]
|
|
fn subprocess_worker() {
|
|
let Some(root) = std::env::var_os("NOTESCRAFT_GIT_TEST_ROOT") else { return };
|
|
let git = Git::open(Path::new(&root));
|
|
match std::env::var("NOTESCRAFT_GIT_TEST_MODE").expect("mode").as_str() {
|
|
"timeout" => assert!(matches!(git.status(), Err(Error::Timeout))),
|
|
"missing" => {
|
|
assert!(matches!(git.status(), Err(Error::GitMissing)));
|
|
assert!(!git_available());
|
|
}
|
|
"stderr" => assert!(matches!(git.init(), Err(Error::Failed(message)) if message.len() == 64 * 1024)),
|
|
_ => panic!("unknown worker mode"),
|
|
}
|
|
}
|