use super::*; struct Directory(PathBuf); impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn directory() -> Directory { let directory = Directory(std::env::temp_dir().join(format!( "vamp-review-test-{}-{}", std::process::id(), NEXT_TEMP.fetch_add(1, Ordering::Relaxed), ))); fs::create_dir(&directory.0).unwrap(); directory } #[test] fn database_restarts_preserve_identity_and_exact_stale_anchors() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let storage = root.join("storage"); let mut review = Review::new(root.clone()); review.summary = "Unicode review\nMore context".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments.push(Comment { id: 7, anchor: Anchor { revision: "working:old-snapshot".into(), path: "日本語.rs".into(), side: Side::Before, start: crate::plugin::TextPosition { row: 0, column: 1 }, end: crate::plugin::TextPosition { row: 1, column: 1 }, content: "a👩‍🔬e\u{301}\r\n界x\n".into(), }, body: "Please preserve 👩‍🔬".into(), resolved: true, }); review.reviewed_files = [Some("live 👩‍🔬\r\n"), None, Some("")] .into_iter() .enumerate() .map(|(index, after)| ReviewedFile { revisions: [format!("before-{index}"), "after".into()], path: "日本語.rs".into(), old_path: (index == 0).then(|| "old.rs".into()), after: after.map(str::to_owned), }) .collect(); { let mut store = Store::open(&storage).unwrap(); store.save(&mut review).unwrap(); assert!(review.storage_id.is_some()); assert_eq!(review.storage_revision, 1); } let mut store = Store::open(&storage).unwrap(); let id = review.storage_id.unwrap(); let listed = store.list(&root).unwrap(); assert_eq!(listed[0].storage_id, Some(id)); assert_eq!(listed[0].summary, review.summary); assert_eq!(listed[0].comments, review.comments); assert_eq!(listed[0].reviewed_files, review.reviewed_files); let mut loaded = store.load(&root, id).unwrap(); assert_eq!(loaded.storage_id, Some(id)); assert_eq!(loaded.storage_revision, 1); assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.reviewed_files, review.reviewed_files); assert_eq!(loaded.verdict, review.verdict); assert!( loaded.comments[0] .anchor .mapped_range( "working:new-snapshot", Path::new("日本語.rs"), Side::Before, &["changed".into()], true, ) .is_none() ); let path = root.join("export.json"); loaded.save(&path).unwrap(); let exported = fs::read(&path).unwrap(); let json: serde_json::Value = serde_json::from_slice(&exported).unwrap(); assert_eq!(json["comments"][0]["resolved"], true); assert_eq!(json["version"], 4); assert_eq!(json["reviewed_files"][0]["after"], "live 👩‍🔬\r\n"); assert!(json["reviewed_files"][1]["after"].is_null()); assert_eq!(json["reviewed_files"][2]["after"], ""); assert!(json.get("storage_id").is_none()); assert!(json.get("storage_revision").is_none()); loaded.summary = "Database only".into(); store.save(&mut loaded).unwrap(); assert_eq!(loaded.storage_id, Some(id)); assert_eq!(loaded.storage_revision, 2); assert_eq!(fs::read(&path).unwrap(), exported); let mut imported = Review::load(&path, &root).unwrap(); assert_eq!(imported.storage_id, None); assert_eq!(imported.storage_revision, 0); assert_eq!(imported.reviewed_files, review.reviewed_files); store.save(&mut imported).unwrap(); assert_ne!(imported.storage_id, Some(id)); assert_eq!(store.list(&root).unwrap().len(), 2); assert_eq!( store .load(&root, imported.storage_id.unwrap()) .unwrap() .reviewed_files, review.reviewed_files, ); let comments = loaded.comments.clone(); let summary = loaded.summary.clone(); let verdict = loaded.verdict; loaded.reviewed_files.clear(); store.save(&mut loaded).unwrap(); drop(store); let loaded = Store::open(&storage).unwrap().load(&root, id).unwrap(); assert!(loaded.reviewed_files.is_empty()); assert_eq!(loaded.comments, comments); assert_eq!(loaded.summary, summary); assert_eq!(loaded.verdict, verdict); } #[test] fn database_repository_identity_is_canonical_and_independent() { let first = directory(); let second = directory(); let first_root = first.0.canonicalize().unwrap(); let second_root = second.0.canonicalize().unwrap(); let mut store = Store::open(&first_root.join("storage")).unwrap(); let mut a = Review::new(first_root.clone()); let mut b = Review::new(second_root.clone()); store.save(&mut a).unwrap(); store.save(&mut b).unwrap(); assert_ne!(a.storage_id, b.storage_id); assert_eq!(store.list(&first_root).unwrap().len(), 1); assert_eq!(store.list(&second_root).unwrap().len(), 1); assert!(store.load(&second_root, a.storage_id.unwrap()).is_err()); let id = a.storage_id; let revision = a.storage_revision; a.root = second_root; assert!(store.save(&mut a).is_err()); assert_eq!((a.storage_id, a.storage_revision), (id, revision)); #[cfg(unix)] { let alias = first_root.join("alias"); std::os::unix::fs::symlink(&first_root, &alias).unwrap(); assert_eq!( store.list(&alias).unwrap()[0].storage_id, store.list(&first_root).unwrap()[0].storage_id ); } } #[test] fn database_conflicts_and_locked_writes_keep_drafts_and_identity() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let storage = root.join("storage"); let mut first = Store::open(&storage).unwrap(); let mut second = Store::open(&storage).unwrap(); let mut review = Review::new(root.clone()); review.reviewed_files.push(ReviewedFile { revisions: ["before".into(), "after".into()], path: "source.rs".into(), old_path: None, after: None, }); first.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); let mut draft = second.load(&root, id).unwrap(); review.summary = "First writer".into(); review.reviewed_files[0].after = Some("First writer buffer\n".into()); first.save(&mut review).unwrap(); draft.summary = "Second writer draft".into(); draft.reviewed_files.clear(); assert!( second .save(&mut draft) .unwrap_err() .contains("another session") ); assert_eq!(draft.summary, "Second writer draft"); assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1)); assert_eq!(second.load(&root, id).unwrap().summary, "First writer"); assert_eq!( second.load(&root, id).unwrap().reviewed_files, review.reviewed_files, ); assert!(draft.reviewed_files.is_empty()); first.connection.execute_batch("BEGIN IMMEDIATE").unwrap(); let mut fresh = Review::new(root.clone()); assert!(second.save(&mut fresh).unwrap_err().contains("locked")); assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); first.connection.execute_batch("ROLLBACK").unwrap(); second.save(&mut fresh).unwrap(); assert!(fresh.storage_id.is_some()); } #[test] fn incompatible_database_schemas_are_preserved() { let previous = format!( "{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA}; INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good'); PRAGMA user_version = 2" ); for schema in [ previous.as_str(), "CREATE TABLE other (value TEXT); PRAGMA user_version = 42", "CREATE TABLE other (value TEXT); PRAGMA user_version = 1", "CREATE TABLE reviews (id INTEGER PRIMARY KEY, root TEXT NOT NULL, revision INTEGER NOT NULL, document TEXT NOT NULL) STRICT; INSERT INTO reviews VALUES (1, '/old/repo', 1, '{\"version\":3}'); PRAGMA user_version = 1", "", ] { let directory = directory(); let path = directory.0.join("reviews.sqlite3"); let connection = Connection::open(&path).unwrap(); connection.execute_batch(schema).unwrap(); drop(connection); let original = fs::read(&path).unwrap(); assert!(Store::open_read_only(&directory.0).is_err()); assert_eq!(fs::read(&path).unwrap(), original); assert!(Store::open(&directory.0).err().unwrap().contains("schema")); assert_eq!(fs::read(&path).unwrap(), original); } } #[test] fn malformed_database_rows_are_rejected_before_loading_snapshots() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); review.comments.push(Comment { id: 1, body: "Feedback".into(), resolved: false, anchor: Anchor { revision: "snapshot".into(), path: "file.rs".into(), side: Side::After, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 0, column: 1 }, content: "界\n".into(), }, }); store.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); for update in [ "resolved = 2", "side = 'invalid'", "id = '0'", "id = '18446744073709551616'", "start_row = -1", "end_column = -1", ] { assert!( store .connection .execute( &format!("UPDATE comments SET {update} WHERE review_id = ?1"), [id], ) .is_err() ); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); } store .connection .execute_batch("PRAGMA ignore_check_constraints = ON") .unwrap(); for update in [ "path = '../outside'", "end_column = 2", "resolved = 2", "side = 'invalid'", "anchor_revision = ''", "id = '0'", "position = 1", ] { store .connection .execute( &format!("UPDATE comments SET {update} WHERE review_id = ?1"), [id], ) .unwrap(); assert!(store.load(&root, id).is_err(), "{update}"); assert!(store.list(&root).is_err(), "{update}"); store.save(&mut review).unwrap(); } store .connection .execute( "UPDATE comments SET content = CAST(zeroblob(?1) AS TEXT) WHERE review_id = ?2", params![LIMIT as i64 + 1, id], ) .unwrap(); assert!(store.load(&root, id).unwrap_err().contains("64 MiB")); assert!(store.list(&root).unwrap_err().contains("64 MiB")); } #[test] fn failed_database_and_export_writes_preserve_metadata_and_documents() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); store.save(&mut review).unwrap(); let identity = (review.storage_id, review.storage_revision); store .connection .execute_batch("PRAGMA query_only = ON") .unwrap(); review.summary = "Unsaved draft".into(); assert!(store.save(&mut review).is_err()); assert_eq!((review.storage_id, review.storage_revision), identity); assert_eq!(review.summary, "Unsaved draft"); assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); let mut fresh = Review::new(root.clone()); assert!(store.save(&mut fresh).is_err()); assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); store .connection .execute_batch("PRAGMA query_only = OFF") .unwrap(); review.summary = "x".repeat(LIMIT); assert!(store.save(&mut review).unwrap_err().contains("64 MiB")); assert!( review .save(&root.join("oversized.json")) .unwrap_err() .contains("64 MiB") ); assert!(!root.join("oversized.json").exists()); assert_eq!((review.storage_id, review.storage_revision), identity); assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); let blocked = root.join("not-a-directory"); fs::write(&blocked, b"preserve").unwrap(); assert!(Store::open(&blocked).is_err()); assert_eq!(fs::read(&blocked).unwrap(), b"preserve"); } #[cfg(unix)] #[test] fn database_files_are_private_and_target_symlinks_are_refused() { use std::os::unix::fs::PermissionsExt; let directory = directory(); let storage = directory.0.join("storage"); drop(Store::open(&storage).unwrap()); let path = storage.join("reviews.sqlite3"); assert_eq!( fs::metadata(&storage).unwrap().permissions().mode() & 0o777, 0o700 ); assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600 ); let original = fs::read(&path).unwrap(); let links = directory.0.join("links"); fs::create_dir(&links).unwrap(); std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); assert!(Store::open(&links).err().unwrap().contains("symbolic link")); assert_eq!(fs::read(&path).unwrap(), original); } #[test] fn default_directory_uses_absolute_xdg_then_absolute_home() { let resolve = |xdg: Option<&str>, home: Option<&str>| { directory_from(xdg.map(OsStr::new), home.map(OsStr::new)) }; assert_eq!( resolve(Some("/data"), Some("/home/me")).unwrap(), Path::new("/data/vamp/reviews") ); for xdg in [None, Some("relative"), Some("")] { assert_eq!( resolve(xdg, Some("/home/me")).unwrap(), Path::new("/home/me/.local/share/vamp/reviews") ); } assert_eq!( resolve(Some("/data"), None).unwrap(), Path::new("/data/vamp/reviews") ); assert!(resolve(None, None).is_err()); assert!(resolve(None, Some("relative")).is_err()); assert!(resolve(None, Some("")).is_err()); } #[test] fn anchor_survives_unrelated_edit() { let anchor = Anchor { revision: "working:old".into(), path: "source.rs".into(), side: Side::After, start: crate::plugin::TextPosition { row: 1, column: 0 }, end: crate::plugin::TextPosition { row: 1, column: 6 }, content: "before\ntarget\nafter\n".into(), }; let map = |text: &str| { anchor.mapped_range( "working:new", Path::new("source.rs"), Side::After, &text .trim_end_matches('\n') .split('\n') .map(str::to_owned) .collect::>(), text.ends_with('\n'), ) }; assert_eq!( map("changed\ntarget\nafter\n"), Some(anchor.start..anchor.end) ); assert_eq!( map("inserted\nbefore\ntarget\nafter\n"), Some( crate::plugin::TextPosition { row: 2, column: 0 }..crate::plugin::TextPosition { row: 2, column: 6 } ) ); assert!(map("before\ntargEt\nafter\n").is_none()); assert!(map("before\nafter\n").is_none()); assert!(map("before\ntarget\nafter\nbefore\ntarget\nafter\n").is_none()); assert_eq!(map("target\nbefore\ntarget\nafter\n").unwrap().start.row, 2); assert_eq!(anchor.content, "before\ntarget\nafter\n"); let partial = Anchor { content: "header\r\nprefix 👩‍💻e\u{301} suffix\r\nfooter\r\n".into(), start: crate::plugin::TextPosition { row: 1, column: 7 }, end: crate::plugin::TextPosition { row: 1, column: 9 }, ..anchor.clone() }; let moved = partial .mapped_range( "working:new", Path::new("source.rs"), Side::After, &[ "inserted\r".into(), "header\r".into(), "longer prefix 👩‍💻e\u{301} suffix\r".into(), "footer\r".into(), ], true, ) .unwrap(); assert_eq!( moved.start, crate::plugin::TextPosition { row: 2, column: 14 } ); assert_eq!( moved.end, crate::plugin::TextPosition { row: 2, column: 16 } ); assert!( partial .mapped_range( "other-commit", Path::new("source.rs"), Side::After, &[ "header\r".into(), "prefix 👩‍💻e\u{301} suffix\r".into(), "footer\r".into() ], true ) .is_none() ); } #[test] fn persistence_validates_anchors_and_preserves_files_on_failure() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let path = root.join("review.json"); let mut review = Review::new(root.clone()); review.summary = "Local review".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments.push(Comment { id: 1, anchor: Anchor { revision: "working:immutable".into(), path: "source.rs".into(), side: Side::After, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 2, column: 0 }, content: "first\nlast\n".into(), }, body: "Please adjust this".into(), resolved: false, }); review.save(&path).unwrap(); let mut loaded = Review::load(&path, &root).unwrap(); assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.verdict, review.verdict); let anchor = &loaded.comments[0].anchor; assert!( anchor .mapped_range( "working:other", Path::new("source.rs"), Side::After, &["first".into(), "last".into()], true ) .is_some() ); assert!( anchor .mapped_range( "working:immutable", Path::new("source.rs"), Side::After, &["first".into(), "last".into()], true ) .is_some() ); assert!( anchor .mapped_range( "working:immutable", Path::new("source.rs"), Side::After, &["first".into(), "last".into()], false ) .is_none() ); loaded.summary = "Updated".into(); loaded.save(&path).unwrap(); assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated"); assert!(Review::new(root.clone()).save(&path).is_err()); let valid = fs::read(&path).unwrap(); for (field, value) in [ ("version", serde_json::json!(2)), ("root", serde_json::json!(root.join("other"))), ( "comments", serde_json::json!([loaded.comments[0], loaded.comments[0]]), ), ] { let mut invalid = serde_json::to_value(&loaded).unwrap(); invalid[field] = value; fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err()); } for (field, value) in [ ("path", serde_json::json!("../outside")), ("path", serde_json::json!("/absolute")), ("path", serde_json::json!(".git/config")), ("start", serde_json::json!({"row": 2, "column": 1})), ("start", serde_json::json!({"row": 3, "column": 0})), ("end", serde_json::json!({"row": 3, "column": 0})), ("revision", serde_json::json!("")), ] { let mut invalid = serde_json::to_value(&loaded).unwrap(); invalid["comments"][0]["anchor"][field] = value; fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err()); } fs::create_dir(root.join("other")).unwrap(); fs::write(&path, &valid).unwrap(); assert!(Review::load(&path, &root.join("other")).is_err()); fs::write(&path, b"external edit").unwrap(); assert!(loaded.save(&path).is_err()); assert_eq!(fs::read(&path).unwrap(), b"external edit"); assert_eq!(loaded.summary, "Updated"); let invalid = root.join("invalid.json"); loaded.comments[0].anchor.start.row = usize::MAX; assert!(loaded.save(&invalid).is_err()); assert!(!invalid.exists()); let large = root.join("large.json"); fs::File::create(&large) .unwrap() .set_len(LIMIT as u64 + 1) .unwrap(); assert!(Review::load(&large, &root).is_err()); #[cfg(unix)] { let link = root.join("link.json"); std::os::unix::fs::symlink(&path, &link).unwrap(); assert!(Review::load(&link, &root).is_err()); assert!(review.save(&link).is_err()); assert_eq!(fs::read(&path).unwrap(), b"external edit"); } } #[test] fn comment_rows_preserve_order_full_ids_and_atomic_replacement() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); review.comments = [u64::MAX, 7] .into_iter() .map(|id| Comment { id, body: format!("Feedback {id}"), resolved: id == 7, anchor: Anchor { revision: "snapshot".into(), path: "source.rs".into(), side: Side::Before, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 0, column: 1 }, content: "界\n".into(), }, }) .collect(); review.summary = "Original".into(); review.reviewed_files.push(ReviewedFile { revisions: ["before".into(), "after".into()], path: "source.rs".into(), old_path: None, after: None, }); store.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); assert!( store .connection .execute( "UPDATE comments SET review_id = ?1 WHERE review_id = ?2", params![id + 1, id], ) .is_err() ); assert!( store .connection .execute("UPDATE comments SET id = '7' WHERE review_id = ?1", [id],) .is_err() ); store .connection .execute_batch( "CREATE TEMP TRIGGER reject_comment BEFORE INSERT ON comments WHEN NEW.id = '7' BEGIN SELECT RAISE(ABORT, 'blocked comment'); END", ) .unwrap(); let revision = review.storage_revision; let original = review.comments.clone(); let original_files = review.reviewed_files.clone(); review.summary = "Unsaved".into(); review.comments[0].body = "Changed".into(); assert!( store .save(&mut review) .unwrap_err() .contains("blocked comment") ); assert_eq!(review.storage_revision, revision); let loaded = store.load(&root, id).unwrap(); assert_eq!(loaded.summary, "Original"); assert_eq!(loaded.comments, original); assert_eq!(loaded.storage_revision, revision); assert_eq!(loaded.reviewed_files, original_files); store .connection .execute_batch("DROP TRIGGER reject_comment") .unwrap(); store .connection .execute_batch( "CREATE TEMP TRIGGER reject_reviewed BEFORE INSERT ON reviewed_files BEGIN SELECT RAISE(ABORT, 'blocked reviewed file'); END", ) .unwrap(); review.reviewed_files[0].after = Some("Unsaved buffer\n".into()); assert!(store.save(&mut review).is_err()); assert_eq!(review.storage_revision, revision); let loaded = store.load(&root, id).unwrap(); assert_eq!(loaded.summary, "Original"); assert_eq!(loaded.comments, original); assert_eq!(loaded.reviewed_files, original_files); assert_eq!(loaded.storage_revision, revision); store .connection .execute_batch("DROP TRIGGER reject_reviewed") .unwrap(); review.comments.reverse(); store.save(&mut review).unwrap(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); review.comments.pop(); store.save(&mut review).unwrap(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); review.comments.clear(); store.save(&mut review).unwrap(); assert!(store.load(&root, id).unwrap().comments.is_empty()); assert_eq!(store.list(&root).unwrap()[0].summary, "Unsaved"); } #[test] fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() { use crate::plugin::TextPosition; let directory = directory(); let root = directory.0.canonicalize().unwrap(); let path = root.join("review.json"); let mut review = Review::new(root.clone()); review.comments.push(Comment { id: 1, anchor: Anchor { revision: "working:immutable".into(), path: "source.rs".into(), side: Side::After, start: TextPosition { row: 0, column: 1 }, end: TextPosition { row: 1, column: 1 }, content: "a👩‍🔬e\u{301}\n界x\n".into(), }, body: "Exact text".into(), resolved: false, }); review.save(&path).unwrap(); let mut loaded = Review::load(&path, &root).unwrap(); assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.comments[0].anchor.rows(), 0..=1); let valid = serde_json::to_value(&review).unwrap(); let mut missing_resolution = valid.clone(); missing_resolution["comments"][0] .as_object_mut() .unwrap() .remove("resolved"); for version in [2, 3, 4] { missing_resolution["version"] = version.into(); let bytes = serde_json::to_vec(&missing_resolution).unwrap(); fs::write(&path, &bytes).unwrap(); let error = Review::load(&path, &root).unwrap_err(); if version < 4 { assert!(error.contains("Unsupported review version")); } else { assert!(error.contains("missing field")); } assert_eq!(fs::read(&path).unwrap(), bytes); } for (field, value) in [ ("start", serde_json::json!({"row": 0, "column": 4})), ("end", serde_json::json!({"row": 1, "column": 3})), ("end", serde_json::json!({"row": 2, "column": 1})), ("end", serde_json::json!({"row": 3, "column": 0})), ("end", serde_json::json!({"row": 0, "column": 0})), ("start", serde_json::json!({"row": -1, "column": 0})), ( "start", serde_json::json!({"row": 0, "column": 0, "extra": 0}), ), ("start_line", serde_json::json!(1)), ] { let mut invalid = valid.clone(); invalid["comments"][0]["anchor"][field] = value; fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err(), "{field}"); } let mut wrong_version = valid; wrong_version["version"] = 1.into(); fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err()); loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 }; assert!(loaded.save(&root.join("backwards.json")).is_err()); } #[test] fn reviewed_files_reject_invalid_json_and_preserve_existing_data() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let path = root.join("review.json"); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); review.reviewed_files.push(ReviewedFile { revisions: ["before".into(), "after".into()], path: "source.rs".into(), old_path: Some("old.rs".into()), after: Some("Exact live text\r\n".into()), }); store.save(&mut review).unwrap(); let valid = serde_json::to_value(&review).unwrap(); let mut invalid_documents = Vec::new(); for (field, value) in [ ("path", serde_json::json!("")), ("path", serde_json::json!("../outside")), ("path", serde_json::json!("/absolute")), ("path", serde_json::json!(".git/config")), ("path", serde_json::json!("nul\0.rs")), ("old_path", serde_json::json!("../outside")), ("old_path", serde_json::json!("/absolute")), ("old_path", serde_json::json!(".jj/repo")), ("old_path", serde_json::json!("nul\0.rs")), ("revisions", serde_json::json!(["", "after"])), ("revisions", serde_json::json!(["before", ""])), ("revisions", serde_json::json!(["before\n", "after"])), ("revisions", serde_json::json!(["before", "after\0"])), ("revisions", serde_json::json!(["before"])), ("unexpected", serde_json::json!(true)), ] { let mut invalid = valid.clone(); invalid["reviewed_files"][0][field] = value; invalid_documents.push(invalid); } let mut duplicate = valid.clone(); duplicate["reviewed_files"] .as_array_mut() .unwrap() .push(valid["reviewed_files"][0].clone()); invalid_documents.push(duplicate); let mut missing = valid.clone(); missing.as_object_mut().unwrap().remove("reviewed_files"); invalid_documents.push(missing); let mut old = valid; old["version"] = 3.into(); invalid_documents.push(old); for invalid in invalid_documents { let bytes = serde_json::to_vec(&invalid).unwrap(); fs::write(&path, &bytes).unwrap(); assert!(Review::load(&path, &root).is_err()); assert_eq!(fs::read(&path).unwrap(), bytes); if let Ok(mut invalid) = serde_json::from_value::(invalid) { assert!(store.save(&mut invalid).is_err()); assert!(invalid.save(&root.join("invalid.json")).is_err()); assert!(!root.join("invalid.json").exists()); } } let loaded = store.load(&root, review.storage_id.unwrap()).unwrap(); assert_eq!(loaded.reviewed_files, review.reviewed_files); assert_eq!(store.list(&root).unwrap().len(), 1); #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; for old_path in [false, true] { let mut invalid = review.clone(); let path = PathBuf::from(std::ffi::OsString::from_vec(vec![0xff])); if old_path { invalid.reviewed_files[0].old_path = Some(path); } else { invalid.reviewed_files[0].path = path; } assert!(store.save(&mut invalid).is_err()); } } } #[test] fn reviewed_file_rows_enforce_identity_and_limit_snapshots_before_loading() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); review.reviewed_files.push(ReviewedFile { revisions: ["before".into(), "after".into()], path: "source.rs".into(), old_path: None, after: None, }); store.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); for update in [ "review_id = 0", "before_revision = ''", "after_revision = ''", "path = NULL", "after_content = zeroblob(1)", "position = -1", ] { assert!( store .connection .execute( &format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"), [id], ) .is_err(), "{update}", ); } assert!( store .connection .execute( "INSERT INTO reviewed_files SELECT review_id, position + 1, before_revision, after_revision, path, old_path, after_content FROM reviewed_files WHERE review_id = ?1", [id], ) .is_err() ); for update in [ "before_revision = char(10)", "after_revision = 'after' || char(0)", "path = '../outside'", "old_path = '../outside'", "old_path = 'nul' || char(0)", "position = 1", ] { store .connection .execute( &format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"), [id], ) .unwrap(); assert!(store.load(&root, id).is_err(), "{update}"); assert!(store.list(&root).is_err(), "{update}"); store.save(&mut review).unwrap(); } store .connection .execute( "UPDATE reviewed_files SET after_content = CAST(zeroblob(?1) AS TEXT) WHERE review_id = ?2", params![LIMIT as i64 + 1, id], ) .unwrap(); assert!(store.load(&root, id).unwrap_err().contains("64 MiB")); assert!(store.list(&root).unwrap_err().contains("64 MiB")); review.reviewed_files[0].after = Some("x".repeat(LIMIT)); assert!(store.save(&mut review).unwrap_err().contains("64 MiB")); }