Files
Marcos Paulo f3f1f51d9e feat(git): git versioning crate and engine commands
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:05:06 -03:00

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