a73x

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