//! 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"), } }