dca51b0e
Add adversarial testing and input hardening for untrusted remotes
a73x 2026-03-21 13:50
Harden DAG walking against malicious input from untrusted remotes: - 1MB blob size limit before deserialization (PayloadTooLarge error) - ObjectType::Blob check on event.json tree entries - validate_collab_ref_id rejects non-hex, wrong-length, path traversal - Ref ID validation integrated into reconcile_refs 30 new tests covering malformed JSON, corrupted trees, oversized blobs, malicious ref names, and atomic failure guarantees. Includes proptest property-based tests and a cargo-fuzz target for event parsing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/Cargo.toml b/Cargo.toml index c344663..8764c48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ clap_mangen = "0.2" [dev-dependencies] tempfile = "3" proptest = "1" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..ab47f77 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,21 @@ [package] name = "git-collab-fuzz" version = "0.0.0" publish = false edition = "2021" [package.metadata] cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" git-collab = { path = ".." } serde_json = "1" [[bin]] name = "fuzz_event_parse" path = "fuzz_targets/fuzz_event_parse.rs" test = false doc = false [workspace] diff --git a/fuzz/fuzz_targets/fuzz_event_parse.rs b/fuzz/fuzz_targets/fuzz_event_parse.rs new file mode 100644 index 0000000..2baa90e --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_event_parse.rs @@ -0,0 +1,10 @@ #![no_main] use libfuzzer_sys::fuzz_target; use git_collab::event::Event; fuzz_target!(|data: &[u8]| { // Feed arbitrary bytes to the Event JSON parser. // Must not panic — Ok or Err both acceptable. let _result: Result<Event, _> = serde_json::from_slice(data); }); diff --git a/src/dag.rs b/src/dag.rs index 82df8e1..9d19c19 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -1,4 +1,4 @@ use git2::{Oid, Repository, Sort}; use git2::{ObjectType, Oid, Repository, Sort}; use crate::error::Error; use crate::event::{Action, Event}; @@ -8,6 +8,9 @@ use crate::signing::sign_event; /// The manifest blob content included in every event commit tree. const MANIFEST_JSON: &[u8] = br#"{"version":1,"format":"git-collab"}"#; /// Maximum allowed size for an event.json blob (1 MB). pub const MAX_EVENT_BLOB_SIZE: usize = 1_048_576; /// Walk the entire DAG reachable from `tip` and return the maximum clock value. /// Returns 0 if the DAG is empty or all events have clock 0 (pre-migration). pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> { @@ -23,8 +26,23 @@ pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> { let entry = tree .get_name("event.json") .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; if entry.kind() != Some(ObjectType::Blob) { return Err(Error::Git(git2::Error::from_str( "event.json entry is not a blob", ))); } let blob = repo.find_blob(entry.id())?; let event: Event = serde_json::from_slice(blob.content())?; let content = blob.content(); if content.len() > MAX_EVENT_BLOB_SIZE { return Err(Error::PayloadTooLarge { actual: content.len(), limit: MAX_EVENT_BLOB_SIZE, }); } let event: Event = serde_json::from_slice(content)?; if event.clock > max { max = event.clock; } @@ -122,8 +140,26 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) let entry = tree .get_name("event.json") .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; // Verify the entry points to a blob, not a tree or commit if entry.kind() != Some(ObjectType::Blob) { return Err(Error::Git(git2::Error::from_str( "event.json entry is not a blob", ))); } let blob = repo.find_blob(entry.id())?; let event: Event = serde_json::from_slice(blob.content())?; // Check blob size before attempting deserialization let content = blob.content(); if content.len() > MAX_EVENT_BLOB_SIZE { return Err(Error::PayloadTooLarge { actual: content.len(), limit: MAX_EVENT_BLOB_SIZE, }); } let event: Event = serde_json::from_slice(content)?; events.push((oid, event)); } Ok(events) diff --git a/src/error.rs b/src/error.rs index 8755825..8644837 100644 --- a/src/error.rs +++ b/src/error.rs @@ -31,4 +31,10 @@ pub enum Error { #[error("sync partially failed: {succeeded} of {total} refs pushed")] PartialSync { succeeded: usize, total: usize }, #[error("event.json blob exceeds {limit} byte limit (actual: {actual})")] PayloadTooLarge { actual: usize, limit: usize }, #[error("invalid ref name: {0}")] InvalidRefName(String), } diff --git a/src/sync.rs b/src/sync.rs index ec0d890..22666f3 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -13,6 +13,28 @@ use crate::sync_lock::SyncLock; use crate::trust; // --------------------------------------------------------------------------- // Ref name validation // --------------------------------------------------------------------------- /// Validate that a collab ref ID is a valid 40-character lowercase hex string. /// This prevents path traversal, null byte injection, and other malicious ref names. pub fn validate_collab_ref_id(id: &str) -> Result<(), Error> { if id.len() != 40 { return Err(Error::InvalidRefName(format!( "ref ID must be exactly 40 characters, got {}", id.len() ))); } if !id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) { return Err(Error::InvalidRefName(format!( "ref ID must contain only lowercase hex characters [0-9a-f], got {:?}", id ))); } Ok(()) } // --------------------------------------------------------------------------- // Per-ref push types (T002a) // --------------------------------------------------------------------------- @@ -455,6 +477,12 @@ fn reconcile_refs( let mut warned_unconfigured = false; for (remote_ref, id) in &sync_refs { // Validate the ref ID format before processing if let Err(e) = validate_collab_ref_id(id) { eprintln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e); continue; } // Verify all commits on the remote ref before reconciling match signing::verify_ref(repo, remote_ref) { Ok(results) => { diff --git a/tests/adversarial_test.rs b/tests/adversarial_test.rs new file mode 100644 index 0000000..91b0638 --- /dev/null +++ b/tests/adversarial_test.rs @@ -0,0 +1,699 @@ //! Adversarial & fuzz testing for untrusted input. //! //! Tests that malformed JSON, corrupted git trees, oversized payloads, //! and malicious ref names are all handled gracefully (Result::Err, never panic). mod common; use git2::Repository; use tempfile::TempDir; use git_collab::dag::{self, MAX_EVENT_BLOB_SIZE}; use git_collab::event::{Action, Author, Event}; use git_collab::sync::validate_collab_ref_id; use common::{alice, init_repo, now}; // =========================================================================== // Helper functions // =========================================================================== /// Create a git commit whose tree contains an event.json blob with the given /// raw bytes. Returns (TempDir, repo, ref_name) so callers can use dag::walk_events. fn repo_with_blob(content: &[u8]) -> (TempDir, Repository, String) { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let oid = { let blob_oid = repo.blob(content).unwrap(); let manifest = br#"{"version":1,"format":"git-collab"}"#; let manifest_blob = repo.blob(manifest).unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); drop(tb); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); repo.commit(None, &sig, &sig, "adversarial test", &tree, &[]) .unwrap() }; let ref_name = format!("refs/collab/issues/{}", oid); repo.reference(&ref_name, oid, false, "test").unwrap(); (tmp, repo, ref_name) } /// Create a git commit with a custom tree structure. The closure receives /// the repo and must return a tree OID. /// Returns (TempDir, repo, ref_name). fn repo_with_custom_tree<F>(builder_fn: F) -> (TempDir, Repository, String) where F: FnOnce(&Repository) -> git2::Oid, { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let oid = { let tree_oid = builder_fn(&repo); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); repo.commit(None, &sig, &sig, "custom tree test", &tree, &[]) .unwrap() }; let ref_name = format!("refs/collab/issues/{}", oid); repo.reference(&ref_name, oid, false, "test").unwrap(); (tmp, repo, ref_name) } /// Build a valid Event JSON string for use in tests. fn valid_event_json() -> String { serde_json::to_string(&Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Test".to_string(), body: "Body".to_string(), }, clock: 1, }) .unwrap() } // =========================================================================== // Phase 3: User Story 1 — Resilient Event Parsing // =========================================================================== #[test] fn invalid_json_returns_error() { let (_tmp, repo, ref_name) = repo_with_blob(b"{not valid json"); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "invalid JSON should return Err"); } #[test] fn empty_blob_returns_error() { let (_tmp, repo, ref_name) = repo_with_blob(b""); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "empty blob should return Err"); } #[test] fn missing_type_field_returns_error() { let json = br#"{"timestamp":"t","author":{"name":"a","email":"e"}}"#; let (_tmp, repo, ref_name) = repo_with_blob(json); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "missing action/type field should return Err"); } #[test] fn unknown_action_type_returns_error() { let json = br#"{"timestamp":"t","author":{"name":"a","email":"e"},"action":{"type":"UnknownAction"}}"#; let (_tmp, repo, ref_name) = repo_with_blob(json); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "unknown action type should return Err"); } #[test] fn missing_required_action_fields_returns_error() { // IssueOpen requires title and body let json = br#"{"timestamp":"t","author":{"name":"a","email":"e"},"action":{"type":"issue.open"}}"#; let (_tmp, repo, ref_name) = repo_with_blob(json); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_err(), "IssueOpen missing title/body should return Err" ); } #[test] fn wrong_field_types_returns_error() { // timestamp as number, author as string instead of object let json = br#"{"timestamp":123,"author":"not-object","action":{"type":"issue.open"}}"#; let (_tmp, repo, ref_name) = repo_with_blob(json); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "wrong field types should return Err"); } #[test] fn valid_json_wrong_schema_returns_error() { let json = br#"{"name":"package","version":"1.0"}"#; let (_tmp, repo, ref_name) = repo_with_blob(json); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_err(), "valid JSON with wrong schema should return Err" ); } #[test] fn deeply_nested_json_returns_error() { // Build 1000-level nested JSON: {"a":{"a":{"a":...}}} let mut json = String::from(r#"{"a":"#); for _ in 0..999 { json.push_str(r#"{"a":"#); } json.push('1'); for _ in 0..1000 { json.push('}'); } let (_tmp, repo, ref_name) = repo_with_blob(json.as_bytes()); // serde_json has a recursion limit of 128; this should either return Err // or panic (which we catch). Either way, it must not corrupt state. let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { dag::walk_events(&repo, &ref_name) })); match result { Ok(Err(_)) => {} // Clean error — good Ok(Ok(_)) => panic!("deeply nested JSON should not parse as a valid Event"), Err(_) => {} // Panic caught — acceptable (serde stack overflow) } } #[test] fn null_bytes_in_json_string_returns_error_or_ok() { // Valid Event JSON but with null bytes embedded in string fields. // serde_json accepts null bytes in strings, so this may succeed. Either is acceptable. let json = r#"{"timestamp":"2024-01-01T00:00:00Z","author":{"name":"a\u0000b","email":"e"},"action":{"type":"issue.open","title":"t\u0000t","body":"b"},"clock":1}"#; let (_tmp, repo, ref_name) = repo_with_blob(json.as_bytes()); let result = dag::walk_events(&repo, &ref_name); // Either Ok or Err is acceptable; the key requirement is no panic. let _ = result; } // =========================================================================== // Phase 4: User Story 2 — Corrupted Git Tree Structures // =========================================================================== #[test] fn missing_event_json_entry_returns_error() { // Create a commit with an empty tree (no event.json entry) let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| { let tb = repo.treebuilder(None).unwrap(); tb.write().unwrap() }); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "missing event.json should return Err"); let err_msg = format!("{}", result.unwrap_err()); assert!( err_msg.contains("missing event.json"), "error should mention missing event.json, got: {}", err_msg ); } #[test] fn event_json_points_to_tree_returns_error() { // Create a commit where event.json entry points to a tree object instead of a blob let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| { // Create an inner empty tree let inner_tb = repo.treebuilder(None).unwrap(); let inner_tree_oid = inner_tb.write().unwrap(); // Insert it as "event.json" but pointing to a tree let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", inner_tree_oid, 0o040000).unwrap(); tb.write().unwrap() }); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_err(), "event.json pointing to tree should return Err" ); } #[test] fn extra_entries_in_tree_still_works() { // Create a commit with event.json plus extra entries — should still parse OK let json = valid_event_json(); let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| { let blob_oid = repo.blob(json.as_bytes()).unwrap(); let extra = repo.blob(b"extra content").unwrap(); let extra2 = repo.blob(b"more stuff").unwrap(); let manifest = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); tb.insert("README.md", extra, 0o100644).unwrap(); tb.insert("random.txt", extra2, 0o100644).unwrap(); tb.insert("manifest.json", manifest, 0o100644).unwrap(); tb.write().unwrap() }); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_ok(), "extra entries should not prevent parsing: {:?}", result.err() ); let events = result.unwrap(); assert_eq!(events.len(), 1); } #[test] fn dag_with_one_corrupted_commit_in_middle() { // Create a 3-commit chain where the middle commit has invalid event.json let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let sk = common::test_signing_key(); // Commit 1: valid let event1 = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Test".to_string(), body: "Body".to_string(), }, clock: 0, }; let oid1 = dag::create_root_event(&repo, &event1, &sk).unwrap(); let ref_name = format!("refs/collab/issues/{}", oid1); repo.reference(&ref_name, oid1, false, "test").unwrap(); // Commit 2: corrupted (invalid JSON) let bad_blob = repo.blob(b"not json").unwrap(); let manifest_blob = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", bad_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let parent1 = repo.find_commit(oid1).unwrap(); let oid2 = repo .commit(None, &sig, &sig, "bad commit", &tree, &[&parent1]) .unwrap(); // Commit 3: valid (child of corrupted) let good_blob = repo.blob(valid_event_json().as_bytes()).unwrap(); let manifest_blob2 = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", good_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob2, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let parent2 = repo.find_commit(oid2).unwrap(); let oid3 = repo .commit(None, &sig, &sig, "good commit 3", &tree, &[&parent2]) .unwrap(); // Update ref to point to tip repo.reference(&ref_name, oid3, true, "update tip").unwrap(); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_err(), "DAG with corrupted middle commit should return Err" ); } // =========================================================================== // Phase 5: User Story 3 — Oversized Payload Rejection // =========================================================================== #[test] fn oversized_blob_returns_error() { // 2 MB blob let content = vec![b' '; 2 * 1024 * 1024]; let (_tmp, repo, ref_name) = repo_with_blob(&content); let result = dag::walk_events(&repo, &ref_name); assert!(result.is_err(), "2 MB blob should return Err"); let err_msg = format!("{}", result.unwrap_err()); assert!( err_msg.contains("exceeds") || err_msg.contains("too large") || err_msg.contains("limit"), "error should mention size limit, got: {}", err_msg ); } #[test] fn blob_at_limit_succeeds() { // Build a valid event JSON padded to just under 1 MB by making the body field large. let base = valid_event_json(); let padding_needed = MAX_EVENT_BLOB_SIZE - base.len() - 1; // -1 to stay under let body_padding = " ".repeat(padding_needed.saturating_sub(100)); let event = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Test".to_string(), body: body_padding, }, clock: 1, }; let padded = serde_json::to_string(&event).unwrap(); // Ensure we're under the limit assert!( padded.len() <= MAX_EVENT_BLOB_SIZE, "padded event is {} bytes, limit is {}", padded.len(), MAX_EVENT_BLOB_SIZE ); let (_tmp, repo, ref_name) = repo_with_blob(padded.as_bytes()); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_ok(), "blob at/under limit should succeed: {:?}", result.err() ); } #[test] fn blob_just_over_limit_returns_error() { // Exactly 1,048,577 bytes (MAX_EVENT_BLOB_SIZE + 1) let content = vec![b'x'; MAX_EVENT_BLOB_SIZE + 1]; let (_tmp, repo, ref_name) = repo_with_blob(&content); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_err(), "blob just over limit should return Err" ); } // =========================================================================== // Phase 6: User Story 4 — Malicious Ref Name Validation // =========================================================================== #[test] fn ref_id_with_path_traversal_rejected() { let result = validate_collab_ref_id("../../HEAD"); assert!(result.is_err(), "path traversal should be rejected"); } #[test] fn ref_id_with_null_bytes_rejected() { let result = validate_collab_ref_id("abc\0def"); assert!(result.is_err(), "null bytes should be rejected"); } #[test] fn ref_id_with_control_chars_rejected() { for ch in &['\n', '\r', '\t'] { let id = format!("abcdef1234567890abcdef1234567890abcdef1{}", ch); let result = validate_collab_ref_id(&id); assert!( result.is_err(), "control char {:?} should be rejected", ch ); } } #[test] fn ref_id_non_hex_rejected() { let result = validate_collab_ref_id("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"); assert!(result.is_err(), "non-hex characters should be rejected"); } #[test] fn ref_id_wrong_length_rejected() { // Too short let result = validate_collab_ref_id("abcdef"); assert!(result.is_err(), "6-char ID should be rejected"); // Too long let result = validate_collab_ref_id("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678ab"); assert!(result.is_err(), "80-char ID should be rejected"); } #[test] fn valid_hex_oid_accepted() { let result = validate_collab_ref_id("abcdef1234567890abcdef1234567890abcdef12"); assert!( result.is_ok(), "valid 40-char hex OID should be accepted: {:?}", result.err() ); } #[test] fn ref_id_uppercase_hex_rejected() { // Uppercase hex should be rejected (we require lowercase) let result = validate_collab_ref_id("ABCDEF1234567890ABCDEF1234567890ABCDEF12"); assert!(result.is_err(), "uppercase hex should be rejected"); } #[test] fn malformed_remote_event_no_local_state_change() { // FR-010: corrupted remote event must not modify local ref let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let sk = common::test_signing_key(); // Create a valid issue let event = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Original".to_string(), body: "Body".to_string(), }, clock: 0, }; let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); let local_ref = format!("refs/collab/issues/{}", oid); repo.reference(&local_ref, oid, false, "test").unwrap(); // Record the OID the local ref points to let original_oid = repo.refname_to_id(&local_ref).unwrap(); // Create a corrupted "remote" commit (bad JSON) let bad_blob = repo.blob(b"corrupted json!!!").unwrap(); let manifest_blob = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", bad_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let corrupted_oid = repo .commit(None, &sig, &sig, "corrupted", &tree, &[]) .unwrap(); // Point a "remote" ref to the corrupted commit let remote_ref = format!("refs/collab/sync/issues/{}", oid); repo.reference(&remote_ref, corrupted_oid, false, "fake remote") .unwrap(); // Try to reconcile — this should fail because walk_events on the remote // will fail when trying to read the corrupted event let reconcile_result = dag::reconcile(&repo, &local_ref, &remote_ref, &alice(), &sk); // The reconcile may or may not fail depending on whether it needs to walk events // (it only walks events for merge commits, not fast-forward). The key assertion is: // Verify local ref still points to the original OID let current_oid = repo.refname_to_id(&local_ref).unwrap(); assert_eq!( original_oid, current_oid, "local ref must not change after encountering corrupted remote event" ); // Suppress unused variable warning let _ = reconcile_result; } // =========================================================================== // Phase 7: User Story 5 — Property-Based Testing // =========================================================================== use proptest::prelude::*; /// Generate an arbitrary Author fn arb_author() -> impl Strategy<Value = Author> { ("[a-zA-Z0-9 ]{0,50}", "[a-zA-Z0-9@.]{0,50}").prop_map(|(name, email)| Author { name, email, }) } /// Generate an arbitrary Action variant fn arb_action() -> impl Strategy<Value = Action> { prop_oneof![ (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body }), ".*".prop_map(|body| Action::IssueComment { body }), proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }), Just(Action::IssueReopen), (".*", ".*", ".*", ".*").prop_map(|(title, body, base_ref, branch)| { Action::PatchCreate { title, body, base_ref, branch, fixes: None, } }), proptest::option::of(".*").prop_map(|body| Action::PatchRevise { body }), (".*",).prop_map(|(body,)| Action::PatchComment { body }), Just(Action::PatchMerge), Just(Action::Merge), ] } /// Generate an arbitrary Event fn arb_event() -> impl Strategy<Value = Event> { (arb_author(), arb_action(), 0u64..1000).prop_map(|(author, action, clock)| Event { timestamp: "2024-01-01T00:00:00Z".to_string(), author, action, clock, }) } proptest! { #[test] fn event_roundtrip_never_panics(event in arb_event()) { // Serialize to JSON, deserialize back. Must not panic. let json = serde_json::to_vec(&event).unwrap(); let _result: Result<Event, _> = serde_json::from_slice(&json); // Ok or Err both acceptable; no panic is the requirement. } #[test] fn arbitrary_bytes_never_panic_parser(data in proptest::collection::vec(any::<u8>(), 0..1024)) { // Arbitrary bytes passed to the Event parser must not panic. let _result: Result<Event, _> = serde_json::from_slice(&data); } #[test] fn walk_events_with_arbitrary_blob_never_panics(data in proptest::collection::vec(any::<u8>(), 0..4096)) { // Create a commit with arbitrary blob content and walk it. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let blob_oid = repo.blob(&data).unwrap(); let manifest_blob = repo.blob(br#"{"version":1,"format":"git-collab"}"#).unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let oid = repo.commit(None, &sig, &sig, "fuzz test", &tree, &[]).unwrap(); let ref_name = format!("refs/collab/issues/{}", oid); repo.reference(&ref_name, oid, false, "test").unwrap(); // Must not panic — Ok or Err both acceptable let _result = dag::walk_events(&repo, &ref_name); } #[test] fn validate_ref_id_never_panics(id in ".*") { // Arbitrary string passed to validate_collab_ref_id must not panic. let _result = validate_collab_ref_id(&id); } } // =========================================================================== // Phase 8: Polish & Cross-Cutting // =========================================================================== #[test] fn timestamp_not_rfc3339_accepted_by_serde() { // timestamp is just a String in the Event struct, not validated as RFC3339. // This documents that non-RFC3339 timestamps are accepted. let json = br#"{"timestamp":"not-a-date","author":{"name":"a","email":"e"},"action":{"type":"issue.open","title":"t","body":"b"},"clock":1}"#; let result: Result<Event, _> = serde_json::from_slice(json); assert!( result.is_ok(), "non-RFC3339 timestamp should be accepted by serde (it's just a String): {:?}", result.err() ); assert_eq!(result.unwrap().timestamp, "not-a-date"); } #[test] fn merge_commit_with_one_corrupted_parent() { // Create a DAG with a merge commit where one parent branch has a corrupted event. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // Branch 1: valid event let valid_json = valid_event_json(); let valid_blob = repo.blob(valid_json.as_bytes()).unwrap(); let manifest_blob = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", valid_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let valid_oid = repo .commit(None, &sig, &sig, "valid branch", &tree, &[]) .unwrap(); // Branch 2: corrupted event let bad_blob = repo.blob(b"not json at all").unwrap(); let manifest_blob2 = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", bad_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob2, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let corrupted_oid = repo .commit(None, &sig, &sig, "corrupted branch", &tree, &[]) .unwrap(); // Merge commit (valid event JSON but parents include corrupted branch) let merge_json = serde_json::to_string(&Event { timestamp: now(), author: alice(), action: Action::Merge, clock: 2, }) .unwrap(); let merge_blob = repo.blob(merge_json.as_bytes()).unwrap(); let manifest_blob3 = repo .blob(br#"{"version":1,"format":"git-collab"}"#) .unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", merge_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob3, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let valid_commit = repo.find_commit(valid_oid).unwrap(); let corrupted_commit = repo.find_commit(corrupted_oid).unwrap(); let merge_oid = repo .commit( None, &sig, &sig, "merge commit", &tree, &[&valid_commit, &corrupted_commit], ) .unwrap(); let ref_name = format!("refs/collab/issues/{}", merge_oid); repo.reference(&ref_name, merge_oid, false, "test").unwrap(); let result = dag::walk_events(&repo, &ref_name); assert!( result.is_err(), "merge with one corrupted parent should return Err" ); }