This commit is contained in:
pj committed 2026-08-28 09:22:58 +05:30
commit 852cba1840
252 files changed
+55050

No files matched your search

+597
View File
@@ -0,0 +1,597 @@
// The filesystem layer is the only part of this app that can lose somebody's work, so the tests
// here are about the promises rather than the plumbing: a failed write leaves the old file whole,
// a conflict writes nothing at all, a name is never taken from a file that already has it, a path
// from the frontend cannot reach outside the folders the user opened, and a delete is always
// something Finder can undo.
use std::collections::BTreeSet;
use std::fs;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{SystemTime, UNIX_EPOCH};
use margin_docs_lib::dto::FileNode;
use margin_docs_lib::fs::{
atomic_write, create_file, create_folder, duplicate_entry, free_path, move_entry, read_document,
rename_entry, resolve_in_roots, root_id_for, scan_tree, trash_entry, write_asset,
write_document,
};
use tempfile::TempDir;
fn root() -> TempDir {
TempDir::new().expect("a temp dir")
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, text).unwrap();
}
fn names(node: &FileNode) -> Vec<String> {
node.children.iter().map(|c| c.name.clone()).collect()
}
fn find<'a>(node: &'a FileNode, name: &str) -> Option<&'a FileNode> {
node.children.iter().find(|c| c.name == name)
}
fn flat(node: &FileNode, into: &mut Vec<String>) {
into.push(node.name.clone());
for child in &node.children {
flat(child, into);
}
}
#[test]
fn a_failed_write_leaves_the_original_whole() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "the original");
// A folder that cannot be written to makes the write fail at its first step, which is the
// moment the original is most at risk. The temp name cannot be blocked from outside any more,
// since it is unique per call, so the folder is what gets taken away instead.
let open = fs::metadata(dir.path()).unwrap().permissions();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
let result = atomic_write(&doc, b"the replacement");
fs::set_permissions(dir.path(), open).unwrap();
assert!(result.is_err());
assert_eq!(fs::read_to_string(&doc).unwrap(), "the original");
}
/// A `.bak` or a `.tmp` named after a document is a file somebody may have written on purpose, and
/// a save of the document it is named after is not permission to unlink it.
#[test]
fn a_write_leaves_the_users_own_bak_and_tmp_siblings_alone() {
let dir = root();
let doc = dir.path().join("doc.md");
let bak = dir.path().join("doc.md.bak");
let tmp = dir.path().join("doc.md.tmp");
write(&doc, "one");
write(&bak, "a revision the author kept on purpose");
write(&tmp, "a scratch file the author kept on purpose");
atomic_write(&doc, b"two").unwrap();
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
assert_eq!(
fs::read_to_string(&bak).unwrap(),
"a revision the author kept on purpose",
"the save deleted a backup the user owned"
);
assert_eq!(
fs::read_to_string(&tmp).unwrap(),
"a scratch file the author kept on purpose",
"the save deleted a scratch file the user owned"
);
}
/// Nothing named after the document may appear beside it, not even for the length of one save.
/// Anything else watching the folder, git included, sees whatever is there while the write runs, and
/// a crash halfway through strands it for good.
#[test]
fn a_save_puts_nothing_document_shaped_beside_the_document() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
let watched = dir.path().to_path_buf();
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let poller = std::thread::spawn(move || {
let mut seen: BTreeSet<String> = BTreeSet::new();
while stop_rx.try_recv().is_err() {
if let Ok(entries) = fs::read_dir(&watched) {
for entry in entries.flatten() {
seen.insert(entry.file_name().to_string_lossy().into_owned());
}
}
}
seen
});
// Big enough that the copy, the write and the fsync are a window a poller can see into.
atomic_write(&doc, &vec![b'z'; 32 * 1024 * 1024]).unwrap();
stop_tx.send(()).ok();
let seen = poller.join().unwrap();
let strays: Vec<&String> = seen
.iter()
.filter(|name| name.as_str() != "doc.md")
.filter(|name| !(name.starts_with(".doc.md.") && name.ends_with(".tmp")))
.collect();
assert!(strays.is_empty(), "a save put these beside the document: {strays:?}");
assert!(
seen.iter().any(|name| name.starts_with(".doc.md.")),
"the poller never caught the temp file, so this proved nothing: {seen:?}"
);
let left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["doc.md".to_string()]);
}
/// A debounced autosave and a Cmd+S both in flight are two saves of one document. Naming the temp
/// file after the target made them race on one path, and the loser took the document with it.
#[test]
fn concurrent_saves_of_one_document_all_land_and_none_loses_it() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "the original");
let payloads: Vec<String> = (0..4)
.map(|n| format!("save number {n}\n{}\n", "z".repeat(1024 * 1024)))
.collect();
let writers: Vec<_> = payloads
.iter()
.cloned()
.map(|text| {
let doc = doc.clone();
std::thread::spawn(move || atomic_write(&doc, text.as_bytes()))
})
.collect();
for writer in writers {
writer
.join()
.expect("a writer thread")
.expect("every concurrent save succeeds");
}
let landed = fs::read_to_string(&doc).expect("the document is still there");
assert!(
payloads.contains(&landed),
"the document holds neither writer's text"
);
let left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["doc.md".to_string()]);
}
#[test]
fn a_write_leaves_no_temp_and_no_backup_behind() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
atomic_write(&doc, b"two").unwrap();
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
let mut left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
left.sort();
assert_eq!(left, vec!["doc.md".to_string()]);
}
#[test]
fn a_write_keeps_the_permissions_the_file_had() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
fs::set_permissions(&doc, fs::Permissions::from_mode(0o600)).unwrap();
atomic_write(&doc, b"two").unwrap();
let mode = fs::metadata(&doc).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn a_conflict_writes_nothing() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "what is on disk");
let seen = read_document(&doc).unwrap();
let result = write_document(&doc, "what the buffer holds", Some(seen.modified_ms - 5_000))
.expect("a conflict is a result and not an error");
assert!(result.conflict);
assert_eq!(fs::read_to_string(&doc).unwrap(), "what is on disk");
}
#[test]
fn the_timestamp_the_caller_saw_lets_the_write_through() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
let seen = read_document(&doc).unwrap();
let result = write_document(&doc, "two", Some(seen.modified_ms)).unwrap();
assert!(!result.conflict);
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
}
#[test]
fn reading_a_document_writes_nothing() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "# Heading\n");
let before = fs::metadata(&doc).unwrap().modified().unwrap();
let read = read_document(&doc).unwrap();
assert_eq!(read.text, "# Heading\n");
assert_eq!(fs::metadata(&doc).unwrap().modified().unwrap(), before);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn a_document_that_is_not_utf8_is_an_error_and_not_a_lossy_read() {
let dir = root();
let doc = dir.path().join("doc.md");
fs::write(&doc, [0xff, 0xfe, 0x00, 0x41]).unwrap();
assert!(read_document(&doc).is_err());
}
#[test]
fn untitled_naming_does_not_collide() {
let dir = root();
let first = create_file(dir.path(), "untitled.md").unwrap();
let second = create_file(dir.path(), "untitled.md").unwrap();
let third = create_file(dir.path(), "untitled.md").unwrap();
assert_eq!(first.name, "untitled.md");
assert_eq!(second.name, "untitled-2.md");
assert_eq!(third.name, "untitled-3.md");
assert!(dir.path().join("untitled.md").exists());
assert!(dir.path().join("untitled-2.md").exists());
assert!(dir.path().join("untitled-3.md").exists());
assert!(first.editable);
assert_eq!(first.kind, "markdown");
}
#[test]
fn a_free_name_never_lands_on_a_file_that_is_there() {
let dir = root();
write(&dir.path().join("note.md"), "one");
write(&dir.path().join("note-2.md"), "two");
assert_eq!(free_path(dir.path(), "note.md"), dir.path().join("note-3.md"));
}
#[test]
fn a_new_folder_follows_the_same_rule() {
let dir = root();
let first = create_folder(dir.path(), "untitled").unwrap();
let second = create_folder(dir.path(), "untitled").unwrap();
assert_eq!(first.name, "untitled");
assert_eq!(first.kind, "dir");
assert!(!first.editable);
assert_eq!(second.name, "untitled-2");
}
#[test]
fn the_tree_skips_node_modules_and_the_rest() {
let dir = root();
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
write(&dir.path().join(".git/config"), "config");
write(&dir.path().join("target/debug/thing"), "binary");
write(&dir.path().join("dist/bundle.js"), "bundle");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(names(&tree), vec!["note.md".to_string()]);
}
#[test]
fn the_tree_respects_a_gitignore() {
let dir = root();
write(&dir.path().join(".gitignore"), "drafts/\nsecret.md\n");
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("secret.md"), "secret");
write(&dir.path().join("drafts/half.md"), "half");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(names(&tree), vec!["note.md".to_string()]);
}
#[test]
fn showing_ignored_files_brings_them_back() {
let dir = root();
write(&dir.path().join(".gitignore"), "secret.md\n");
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("secret.md"), "secret");
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
let tree = scan_tree(dir.path(), true).unwrap();
let mut all = Vec::new();
flat(&tree, &mut all);
assert!(all.contains(&"secret.md".to_string()));
assert!(all.contains(&"node_modules".to_string()));
}
#[test]
fn the_tree_classifies_every_kind_and_sorts_folders_first() {
let dir = root();
write(&dir.path().join("zebra.md"), "md");
write(&dir.path().join("Apple.txt"), "txt");
write(&dir.path().join("photo.png"), "png");
write(&dir.path().join("beta/inner.markdown"), "md");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(
names(&tree),
vec![
"beta".to_string(),
"Apple.txt".to_string(),
"photo.png".to_string(),
"zebra.md".to_string(),
]
);
assert_eq!(find(&tree, "zebra.md").unwrap().kind, "markdown");
assert!(find(&tree, "Apple.txt").unwrap().editable);
assert_eq!(find(&tree, "Apple.txt").unwrap().kind, "text");
assert_eq!(find(&tree, "photo.png").unwrap().kind, "other");
assert!(!find(&tree, "photo.png").unwrap().editable);
assert_eq!(find(&tree, "beta").unwrap().children.len(), 1);
}
#[test]
fn a_symlink_loop_does_not_hang_the_scan() {
let dir = root();
write(&dir.path().join("note.md"), "note");
fs::create_dir(dir.path().join("inner")).unwrap();
symlink(dir.path(), dir.path().join("inner/loop")).unwrap();
let tree = scan_tree(dir.path(), false).unwrap();
let mut all = Vec::new();
flat(&tree, &mut all);
assert!(all.contains(&"note.md".to_string()));
}
#[test]
fn path_validation_rejects_an_escape() {
let dir = root();
let roots = vec![dir.path().to_string_lossy().into_owned()];
let escape = dir.path().join("../escaped.md");
assert!(resolve_in_roots(&roots, &escape.to_string_lossy()).is_err());
assert!(resolve_in_roots(&roots, "/etc/hosts").is_err());
assert!(resolve_in_roots(&roots, "notes/relative.md").is_err());
assert!(resolve_in_roots(&[], &dir.path().join("note.md").to_string_lossy()).is_err());
}
#[test]
fn path_validation_rejects_a_symlink_pointing_out_of_the_root() {
let inside = root();
let outside = root();
let secret = outside.path().join("secret.md");
write(&secret, "secret");
symlink(&secret, inside.path().join("link.md")).unwrap();
let roots = vec![inside.path().to_string_lossy().into_owned()];
let link = inside.path().join("link.md");
assert!(resolve_in_roots(&roots, &link.to_string_lossy()).is_err());
}
#[test]
fn path_validation_does_not_treat_a_sibling_as_a_child() {
let dir = root();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::create_dir(dir.path().join("notes-old")).unwrap();
write(&dir.path().join("notes-old/note.md"), "note");
let roots = vec![dir.path().join("notes").to_string_lossy().into_owned()];
let sibling = dir.path().join("notes-old/note.md");
assert!(resolve_in_roots(&roots, &sibling.to_string_lossy()).is_err());
}
#[test]
fn path_validation_accepts_what_is_really_inside() {
let dir = root();
let doc = dir.path().join("folder/note.md");
write(&doc, "note");
let roots = vec![dir.path().to_string_lossy().into_owned()];
let resolved = resolve_in_roots(&roots, &doc.to_string_lossy()).unwrap();
assert_eq!(resolved, fs::canonicalize(&doc).unwrap());
// A file being created has no canonical path of its own, and it still has to be checked.
let unborn = dir.path().join("folder/new.md");
let resolved = resolve_in_roots(&roots, &unborn.to_string_lossy()).unwrap();
assert_eq!(
resolved,
fs::canonicalize(dir.path().join("folder")).unwrap().join("new.md")
);
}
#[test]
fn trash_does_not_hard_delete() {
let dir = root();
let unique = format!(
"margin-docs-trash-test-{}.md",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let doc = dir.path().join(&unique);
write(&doc, "recoverable");
trash_entry(&doc).unwrap();
assert!(!doc.exists(), "the file is gone from where it was");
let trashed = PathBuf::from(std::env::var("HOME").unwrap())
.join(".Trash")
.join(&unique);
assert!(trashed.exists(), "and it is sitting in the Trash instead");
// Best effort, because the contents of the Trash are not always readable or writable by a
// process that is not Finder. The test above is the assertion; this is only tidying up.
let _ = fs::remove_file(&trashed);
}
#[test]
fn a_rename_will_not_move() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
assert!(rename_entry(&doc, "../elsewhere.md").is_err());
assert!(rename_entry(&doc, "sub/other.md").is_err());
assert!(rename_entry(&doc, " ").is_err());
assert!(doc.exists());
let renamed = rename_entry(&doc, "other.md").unwrap();
assert_eq!(renamed.name, "other.md");
assert!(dir.path().join("other.md").exists());
assert!(!doc.exists());
}
#[test]
fn a_rename_onto_a_name_that_is_taken_is_refused() {
let dir = root();
write(&dir.path().join("one.md"), "one");
write(&dir.path().join("two.md"), "two");
assert!(rename_entry(&dir.path().join("one.md"), "two.md").is_err());
assert_eq!(fs::read_to_string(dir.path().join("two.md")).unwrap(), "two");
}
#[test]
fn a_move_into_the_folder_it_is_already_in_does_nothing() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
let node = move_entry(&doc, dir.path()).unwrap();
assert_eq!(node.name, "note.md");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn a_move_will_not_put_a_folder_inside_itself() {
let dir = root();
let outer = dir.path().join("outer");
let inner = outer.join("inner");
fs::create_dir_all(&inner).unwrap();
assert!(move_entry(&outer, &inner).is_err());
assert!(move_entry(&outer, &outer).is_err());
}
#[test]
fn a_move_keeps_the_name_unless_it_is_taken() {
let dir = root();
let from = dir.path().join("from");
let to = dir.path().join("to");
fs::create_dir_all(&from).unwrap();
fs::create_dir_all(&to).unwrap();
write(&from.join("note.md"), "moved");
write(&to.join("note.md"), "already here");
let node = move_entry(&from.join("note.md"), &to).unwrap();
assert_eq!(node.name, "note-2.md");
assert_eq!(fs::read_to_string(to.join("note.md")).unwrap(), "already here");
assert_eq!(fs::read_to_string(to.join("note-2.md")).unwrap(), "moved");
}
#[test]
fn duplicating_copies_a_folder_and_everything_under_it() {
let dir = root();
write(&dir.path().join("project/note.md"), "note");
write(&dir.path().join("project/deep/inner.md"), "inner");
let node = duplicate_entry(&dir.path().join("project")).unwrap();
assert_eq!(node.name, "project-2");
assert_eq!(
fs::read_to_string(dir.path().join("project-2/deep/inner.md")).unwrap(),
"inner"
);
assert_eq!(fs::read_to_string(dir.path().join("project/note.md")).unwrap(), "note");
}
#[test]
fn duplicating_a_document_is_byte_for_byte() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "---\ntitle: x\n---\n\n# ragged heading\n");
let node = duplicate_entry(&doc).unwrap();
assert_eq!(node.name, "note-2.md");
assert_eq!(
fs::read_to_string(dir.path().join("note-2.md")).unwrap(),
"---\ntitle: x\n---\n\n# ragged heading\n"
);
}
#[test]
fn a_pasted_image_lands_in_assets_beside_the_document() {
let dir = root();
let doc = dir.path().join("folder/note.md");
write(&doc, "note");
let first = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
let second = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
assert_eq!(first.rel_path, "assets/image.png");
assert_eq!(second.rel_path, "assets/image-2.png");
assert_eq!(
fs::read(dir.path().join("folder/assets/image.png")).unwrap(),
vec![0x89, 0x50, 0x4e, 0x47]
);
assert!(Path::new(&first.path).is_absolute());
}
#[test]
fn a_suggested_asset_name_is_a_name_and_not_a_path() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
let result = write_asset(&doc, &[1, 2, 3], "../../../evil.png").unwrap();
assert_eq!(result.rel_path, "assets/evil.png");
assert!(dir.path().join("assets/evil.png").exists());
}
#[test]
fn a_root_id_is_the_same_folder_every_time() {
assert_eq!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/notes"));
assert_ne!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/other"));
assert_eq!(root_id_for("/Users/x/notes").len(), 16);
}