tests/adversarial_test.rs
Ref: Size: 25.4 KiB
//! 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(),
relates_to: None,
},
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(),
relates_to: None,
},
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,
relates_to: None,
},
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(),
relates_to: None,
},
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, relates_to: None }),
".*".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,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
base_commit: None,
}
}),
proptest::option::of(".*").prop_map(|body| Action::PatchRevision {
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
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"
);
}