d4dac620
Overhaul the wire format and order events by Lamport clock
a73x 2026-03-21 13:50
Commit message
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -23,3 +23,4 @@ clap_mangen = "0.2" | |||
| 23 | 23 | ||
| 24 | [dev-dependencies] | 24 | [dev-dependencies] |
| 25 | tempfile = "3" | 25 | tempfile = "3" |
| 26 | proptest = "1" | ||
fuzz/Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,21 @@ | |||
| 1 | [package] | ||
| 2 | name = "git-collab-fuzz" | ||
| 3 | version = "0.0.0" | ||
| 4 | publish = false | ||
| 5 | edition = "2021" | ||
| 6 | |||
| 7 | [package.metadata] | ||
| 8 | cargo-fuzz = true | ||
| 9 | |||
| 10 | [dependencies] | ||
| 11 | libfuzzer-sys = "0.4" | ||
| 12 | git-collab = { path = ".." } | ||
| 13 | serde_json = "1" | ||
| 14 | |||
| 15 | [[bin]] | ||
| 16 | name = "fuzz_event_parse" | ||
| 17 | path = "fuzz_targets/fuzz_event_parse.rs" | ||
| 18 | test = false | ||
| 19 | doc = false | ||
| 20 | |||
| 21 | [workspace] | ||
fuzz/fuzz_targets/fuzz_event_parse.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,10 @@ | |||
| 1 | #![no_main] | ||
| 2 | |||
| 3 | use libfuzzer_sys::fuzz_target; | ||
| 4 | use git_collab::event::Event; | ||
| 5 | |||
| 6 | fuzz_target!(|data: &[u8]| { | ||
| 7 | // Feed arbitrary bytes to the Event JSON parser. | ||
| 8 | // Must not panic — Ok or Err both acceptable. | ||
| 9 | let _result: Result<Event, _> = serde_json::from_slice(data); | ||
| 10 | }); | ||
src/dag.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,23 +1,79 @@ | |||
| 1 | use git2::{Oid, Repository, Sort}; | 1 | use git2::{ObjectType, Oid, Repository, Sort}; |
| 2 | 2 | ||
| 3 | use crate::error::Error; | 3 | use crate::error::Error; |
| 4 | use crate::event::{Action, Event}; | 4 | use crate::event::{Action, Event}; |
| 5 | use crate::identity::author_signature; | 5 | use crate::identity::author_signature; |
| 6 | use crate::signing::{sign_event, SignedEvent}; | 6 | use crate::signing::sign_event; |
| 7 | |||
| 8 | /// The manifest blob content included in every event commit tree. | ||
| 9 | const MANIFEST_JSON: &[u8] = br#"{"version":1,"format":"git-collab"}"#; | ||
| 10 | |||
| 11 | /// Maximum allowed size for an event.json blob (1 MB). | ||
| 12 | pub const MAX_EVENT_BLOB_SIZE: usize = 1_048_576; | ||
| 13 | |||
| 14 | /// Walk the entire DAG reachable from `tip` and return the maximum clock value. | ||
| 15 | /// Returns 0 if the DAG is empty or all events have clock 0 (pre-migration). | ||
| 16 | pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> { | ||
| 17 | let mut revwalk = repo.revwalk()?; | ||
| 18 | revwalk.set_sorting(Sort::TOPOLOGICAL)?; | ||
| 19 | revwalk.push(tip)?; | ||
| 20 | |||
| 21 | let mut max = 0u64; | ||
| 22 | for oid_result in revwalk { | ||
| 23 | let oid = oid_result?; | ||
| 24 | let commit = repo.find_commit(oid)?; | ||
| 25 | let tree = commit.tree()?; | ||
| 26 | let entry = tree | ||
| 27 | .get_name("event.json") | ||
| 28 | .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; | ||
| 29 | |||
| 30 | if entry.kind() != Some(ObjectType::Blob) { | ||
| 31 | return Err(Error::Git(git2::Error::from_str( | ||
| 32 | "event.json entry is not a blob", | ||
| 33 | ))); | ||
| 34 | } | ||
| 35 | |||
| 36 | let blob = repo.find_blob(entry.id())?; | ||
| 37 | let content = blob.content(); | ||
| 38 | if content.len() > MAX_EVENT_BLOB_SIZE { | ||
| 39 | return Err(Error::PayloadTooLarge { | ||
| 40 | actual: content.len(), | ||
| 41 | limit: MAX_EVENT_BLOB_SIZE, | ||
| 42 | }); | ||
| 43 | } | ||
| 44 | |||
| 45 | let event: Event = serde_json::from_slice(content)?; | ||
| 46 | if event.clock > max { | ||
| 47 | max = event.clock; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | Ok(max) | ||
| 51 | } | ||
| 7 | 52 | ||
| 8 | /// Create an orphan commit (no parents) with the given event. | 53 | /// Create an orphan commit (no parents) with the given event. |
| 9 | /// Returns the new commit OID which also serves as the entity ID. | 54 | /// Returns the new commit OID which also serves as the entity ID. |
| 55 | /// Clones the event internally and sets clock=1. | ||
| 10 | pub fn create_root_event( | 56 | pub fn create_root_event( |
| 11 | repo: &Repository, | 57 | repo: &Repository, |
| 12 | event: &Event, | 58 | event: &Event, |
| 13 | signing_key: &ed25519_dalek::SigningKey, | 59 | signing_key: &ed25519_dalek::SigningKey, |
| 14 | ) -> Result<Oid, Error> { | 60 | ) -> Result<Oid, Error> { |
| 15 | let signed = sign_event(event, signing_key)?; | 61 | let mut event = event.clone(); |
| 16 | let json = serde_json::to_vec_pretty(&signed)?; | 62 | event.clock = 1; |
| 17 | let blob_oid = repo.blob(&json)?; | 63 | |
| 64 | let detached = sign_event(&event, signing_key)?; | ||
| 65 | let event_json = serde_json::to_vec_pretty(&event)?; | ||
| 66 | |||
| 67 | let event_blob = repo.blob(&event_json)?; | ||
| 68 | let sig_blob = repo.blob(detached.signature.as_bytes())?; | ||
| 69 | let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; | ||
| 70 | let manifest_blob = repo.blob(MANIFEST_JSON)?; | ||
| 18 | 71 | ||
| 19 | let mut tb = repo.treebuilder(None)?; | 72 | let mut tb = repo.treebuilder(None)?; |
| 20 | tb.insert("event.json", blob_oid, 0o100644)?; | 73 | tb.insert("event.json", event_blob, 0o100644)?; |
| 74 | tb.insert("signature", sig_blob, 0o100644)?; | ||
| 75 | tb.insert("pubkey", pubkey_blob, 0o100644)?; | ||
| 76 | tb.insert("manifest.json", manifest_blob, 0o100644)?; | ||
| 21 | let tree_oid = tb.write()?; | 77 | let tree_oid = tb.write()?; |
| 22 | let tree = repo.find_tree(tree_oid)?; | 78 | let tree = repo.find_tree(tree_oid)?; |
| 23 | 79 | ||
| @@ -29,18 +85,32 @@ pub fn create_root_event( | |||
| 29 | } | 85 | } |
| 30 | 86 | ||
| 31 | /// Append an event to an existing DAG. The current tip is the parent. | 87 | /// Append an event to an existing DAG. The current tip is the parent. |
| 88 | /// Clones the event internally and sets clock = max_clock(tip) + 1. | ||
| 32 | pub fn append_event( | 89 | pub fn append_event( |
| 33 | repo: &Repository, | 90 | repo: &Repository, |
| 34 | ref_name: &str, | 91 | ref_name: &str, |
| 35 | event: &Event, | 92 | event: &Event, |
| 36 | signing_key: &ed25519_dalek::SigningKey, | 93 | signing_key: &ed25519_dalek::SigningKey, |
| 37 | ) -> Result<Oid, Error> { | 94 | ) -> Result<Oid, Error> { |
| 38 | let signed = sign_event(event, signing_key)?; | 95 | let tip = repo.refname_to_id(ref_name)?; |
| 39 | let json = serde_json::to_vec_pretty(&signed)?; | 96 | let current_max = max_clock(repo, tip)?; |
| 40 | let blob_oid = repo.blob(&json)?; | 97 | |
| 98 | let mut event = event.clone(); | ||
| 99 | event.clock = current_max + 1; | ||
| 100 | |||
| 101 | let detached = sign_event(&event, signing_key)?; | ||
| 102 | let event_json = serde_json::to_vec_pretty(&event)?; | ||
| 103 | |||
| 104 | let event_blob = repo.blob(&event_json)?; | ||
| 105 | let sig_blob = repo.blob(detached.signature.as_bytes())?; | ||
| 106 | let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; | ||
| 107 | let manifest_blob = repo.blob(MANIFEST_JSON)?; | ||
| 41 | 108 | ||
| 42 | let mut tb = repo.treebuilder(None)?; | 109 | let mut tb = repo.treebuilder(None)?; |
| 43 | tb.insert("event.json", blob_oid, 0o100644)?; | 110 | tb.insert("event.json", event_blob, 0o100644)?; |
| 111 | tb.insert("signature", sig_blob, 0o100644)?; | ||
| 112 | tb.insert("pubkey", pubkey_blob, 0o100644)?; | ||
| 113 | tb.insert("manifest.json", manifest_blob, 0o100644)?; | ||
| 44 | let tree_oid = tb.write()?; | 114 | let tree_oid = tb.write()?; |
| 45 | let tree = repo.find_tree(tree_oid)?; | 115 | let tree = repo.find_tree(tree_oid)?; |
| 46 | 116 | ||
| @@ -70,14 +140,26 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) | |||
| 70 | let entry = tree | 140 | let entry = tree |
| 71 | .get_name("event.json") | 141 | .get_name("event.json") |
| 72 | .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; | 142 | .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; |
| 143 | |||
| 144 | // Verify the entry points to a blob, not a tree or commit | ||
| 145 | if entry.kind() != Some(ObjectType::Blob) { | ||
| 146 | return Err(Error::Git(git2::Error::from_str( | ||
| 147 | "event.json entry is not a blob", | ||
| 148 | ))); | ||
| 149 | } | ||
| 150 | |||
| 73 | let blob = repo.find_blob(entry.id())?; | 151 | let blob = repo.find_blob(entry.id())?; |
| 152 | |||
| 153 | // Check blob size before attempting deserialization | ||
| 74 | let content = blob.content(); | 154 | let content = blob.content(); |
| 75 | // Try SignedEvent first, fall back to plain Event for backward compat | 155 | if content.len() > MAX_EVENT_BLOB_SIZE { |
| 76 | let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { | 156 | return Err(Error::PayloadTooLarge { |
| 77 | signed.event | 157 | actual: content.len(), |
| 78 | } else { | 158 | limit: MAX_EVENT_BLOB_SIZE, |
| 79 | serde_json::from_slice(content)? | 159 | }); |
| 80 | }; | 160 | } |
| 161 | |||
| 162 | let event: Event = serde_json::from_slice(content)?; | ||
| 81 | events.push((oid, event)); | 163 | events.push((oid, event)); |
| 82 | } | 164 | } |
| 83 | Ok(events) | 165 | Ok(events) |
| @@ -116,18 +198,30 @@ pub fn reconcile( | |||
| 116 | return Ok(remote_oid); | 198 | return Ok(remote_oid); |
| 117 | } | 199 | } |
| 118 | 200 | ||
| 119 | // True fork — create merge commit | 201 | // True fork — create merge commit with clock = max(local, remote) + 1 |
| 202 | let local_max = max_clock(repo, local_oid)?; | ||
| 203 | let remote_max = max_clock(repo, remote_oid)?; | ||
| 204 | let merge_clock = std::cmp::max(local_max, remote_max) + 1; | ||
| 205 | |||
| 120 | let merge_event = Event { | 206 | let merge_event = Event { |
| 121 | timestamp: chrono::Utc::now().to_rfc3339(), | 207 | timestamp: chrono::Utc::now().to_rfc3339(), |
| 122 | author: merge_author.clone(), | 208 | author: merge_author.clone(), |
| 123 | action: Action::Merge, | 209 | action: Action::Merge, |
| 210 | clock: merge_clock, | ||
| 124 | }; | 211 | }; |
| 125 | 212 | ||
| 126 | let signed = sign_event(&merge_event, signing_key)?; | 213 | let detached = sign_event(&merge_event, signing_key)?; |
| 127 | let json = serde_json::to_vec_pretty(&signed)?; | 214 | let event_json = serde_json::to_vec_pretty(&merge_event)?; |
| 128 | let blob_oid = repo.blob(&json)?; | 215 | let event_blob = repo.blob(&event_json)?; |
| 216 | let sig_blob = repo.blob(detached.signature.as_bytes())?; | ||
| 217 | let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; | ||
| 218 | let manifest_blob = repo.blob(MANIFEST_JSON)?; | ||
| 219 | |||
| 129 | let mut tb = repo.treebuilder(None)?; | 220 | let mut tb = repo.treebuilder(None)?; |
| 130 | tb.insert("event.json", blob_oid, 0o100644)?; | 221 | tb.insert("event.json", event_blob, 0o100644)?; |
| 222 | tb.insert("signature", sig_blob, 0o100644)?; | ||
| 223 | tb.insert("pubkey", pubkey_blob, 0o100644)?; | ||
| 224 | tb.insert("manifest.json", manifest_blob, 0o100644)?; | ||
| 131 | let tree_oid = tb.write()?; | 225 | let tree_oid = tb.write()?; |
| 132 | let tree = repo.find_tree(tree_oid)?; | 226 | let tree = repo.find_tree(tree_oid)?; |
| 133 | 227 | ||
| @@ -147,6 +241,72 @@ pub fn reconcile( | |||
| 147 | Ok(oid) | 241 | Ok(oid) |
| 148 | } | 242 | } |
| 149 | 243 | ||
| 244 | /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock | ||
| 245 | /// assigned in topological order. Events that already have clock>0 are left as-is. | ||
| 246 | /// This rewrites the commit chain (new OIDs) and updates the ref. | ||
| 247 | pub fn migrate_clocks( | ||
| 248 | repo: &Repository, | ||
| 249 | ref_name: &str, | ||
| 250 | signing_key: &ed25519_dalek::SigningKey, | ||
| 251 | ) -> Result<(), Error> { | ||
| 252 | let events = walk_events(repo, ref_name)?; | ||
| 253 | |||
| 254 | // Check if migration is needed: any event with clock=0? | ||
| 255 | let needs_migration = events.iter().any(|(_, e)| e.clock == 0); | ||
| 256 | if !needs_migration { | ||
| 257 | return Ok(()); | ||
| 258 | } | ||
| 259 | |||
| 260 | // Rebuild the chain with sequential clocks | ||
| 261 | let mut clock = 0u64; | ||
| 262 | let mut prev_oid: Option<Oid> = None; | ||
| 263 | |||
| 264 | for (_old_oid, event) in &events { | ||
| 265 | clock += 1; | ||
| 266 | let mut migrated = event.clone(); | ||
| 267 | if migrated.clock == 0 { | ||
| 268 | migrated.clock = clock; | ||
| 269 | } else { | ||
| 270 | clock = migrated.clock; // respect existing clocks | ||
| 271 | } | ||
| 272 | |||
| 273 | let detached = sign_event(&migrated, signing_key)?; | ||
| 274 | let event_json = serde_json::to_vec_pretty(&migrated)?; | ||
| 275 | let event_blob = repo.blob(&event_json)?; | ||
| 276 | let sig_blob = repo.blob(detached.signature.as_bytes())?; | ||
| 277 | let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; | ||
| 278 | let manifest_blob = repo.blob(MANIFEST_JSON)?; | ||
| 279 | |||
| 280 | let mut tb = repo.treebuilder(None)?; | ||
| 281 | tb.insert("event.json", event_blob, 0o100644)?; | ||
| 282 | tb.insert("signature", sig_blob, 0o100644)?; | ||
| 283 | tb.insert("pubkey", pubkey_blob, 0o100644)?; | ||
| 284 | tb.insert("manifest.json", manifest_blob, 0o100644)?; | ||
| 285 | let tree_oid = tb.write()?; | ||
| 286 | let tree = repo.find_tree(tree_oid)?; | ||
| 287 | |||
| 288 | let sig = author_signature(&migrated.author)?; | ||
| 289 | let message = commit_message(&migrated.action); | ||
| 290 | |||
| 291 | let parents: Vec<git2::Commit> = if let Some(pid) = prev_oid { | ||
| 292 | vec![repo.find_commit(pid)?] | ||
| 293 | } else { | ||
| 294 | vec![] | ||
| 295 | }; | ||
| 296 | let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); | ||
| 297 | |||
| 298 | let new_oid = repo.commit(None, &sig, &sig, &message, &tree, &parent_refs)?; | ||
| 299 | prev_oid = Some(new_oid); | ||
| 300 | } | ||
| 301 | |||
| 302 | // Update the ref to point to the new tip | ||
| 303 | if let Some(new_tip) = prev_oid { | ||
| 304 | repo.reference(ref_name, new_tip, true, "migrate clocks")?; | ||
| 305 | } | ||
| 306 | |||
| 307 | Ok(()) | ||
| 308 | } | ||
| 309 | |||
| 150 | fn commit_message(action: &Action) -> String { | 310 | fn commit_message(action: &Action) -> String { |
| 151 | match action { | 311 | match action { |
| 152 | Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title), | 312 | Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title), |
src/error.rs
| Old | New | ||
|---|---|---|---|
| @@ -25,4 +25,16 @@ pub enum Error { | |||
| 25 | 25 | ||
| 26 | #[error("untrusted key: {0}")] | 26 | #[error("untrusted key: {0}")] |
| 27 | UntrustedKey(String), | 27 | UntrustedKey(String), |
| 28 | |||
| 29 | #[error("another sync is in progress (pid {pid}, started {since})")] | ||
| 30 | SyncLocked { pid: u32, since: String }, | ||
| 31 | |||
| 32 | #[error("sync partially failed: {succeeded} of {total} refs pushed")] | ||
| 33 | PartialSync { succeeded: usize, total: usize }, | ||
| 34 | |||
| 35 | #[error("event.json blob exceeds {limit} byte limit (actual: {actual})")] | ||
| 36 | PayloadTooLarge { actual: usize, limit: usize }, | ||
| 37 | |||
| 38 | #[error("invalid ref name: {0}")] | ||
| 39 | InvalidRefName(String), | ||
| 28 | } | 40 | } |
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -11,38 +11,50 @@ pub struct Event { | |||
| 11 | pub timestamp: String, | 11 | pub timestamp: String, |
| 12 | pub author: Author, | 12 | pub author: Author, |
| 13 | pub action: Action, | 13 | pub action: Action, |
| 14 | #[serde(default)] | ||
| 15 | pub clock: u64, | ||
| 14 | } | 16 | } |
| 15 | 17 | ||
| 16 | #[derive(Debug, Clone, Serialize, Deserialize)] | 18 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 17 | #[serde(tag = "type")] | 19 | #[serde(tag = "type")] |
| 18 | pub enum Action { | 20 | pub enum Action { |
| 21 | #[serde(rename = "issue.open")] | ||
| 19 | IssueOpen { | 22 | IssueOpen { |
| 20 | title: String, | 23 | title: String, |
| 21 | body: String, | 24 | body: String, |
| 22 | }, | 25 | }, |
| 26 | #[serde(rename = "issue.comment")] | ||
| 23 | IssueComment { | 27 | IssueComment { |
| 24 | body: String, | 28 | body: String, |
| 25 | }, | 29 | }, |
| 30 | #[serde(rename = "issue.close")] | ||
| 26 | IssueClose { | 31 | IssueClose { |
| 27 | reason: Option<String>, | 32 | reason: Option<String>, |
| 28 | }, | 33 | }, |
| 34 | #[serde(rename = "issue.edit")] | ||
| 29 | IssueEdit { | 35 | IssueEdit { |
| 30 | title: Option<String>, | 36 | title: Option<String>, |
| 31 | body: Option<String>, | 37 | body: Option<String>, |
| 32 | }, | 38 | }, |
| 39 | #[serde(rename = "issue.label")] | ||
| 33 | IssueLabel { | 40 | IssueLabel { |
| 34 | label: String, | 41 | label: String, |
| 35 | }, | 42 | }, |
| 43 | #[serde(rename = "issue.unlabel")] | ||
| 36 | IssueUnlabel { | 44 | IssueUnlabel { |
| 37 | label: String, | 45 | label: String, |
| 38 | }, | 46 | }, |
| 47 | #[serde(rename = "issue.assign")] | ||
| 39 | IssueAssign { | 48 | IssueAssign { |
| 40 | assignee: String, | 49 | assignee: String, |
| 41 | }, | 50 | }, |
| 51 | #[serde(rename = "issue.unassign")] | ||
| 42 | IssueUnassign { | 52 | IssueUnassign { |
| 43 | assignee: String, | 53 | assignee: String, |
| 44 | }, | 54 | }, |
| 55 | #[serde(rename = "issue.reopen")] | ||
| 45 | IssueReopen, | 56 | IssueReopen, |
| 57 | #[serde(rename = "patch.create")] | ||
| 46 | PatchCreate { | 58 | PatchCreate { |
| 47 | title: String, | 59 | title: String, |
| 48 | body: String, | 60 | body: String, |
| @@ -51,25 +63,32 @@ pub enum Action { | |||
| 51 | #[serde(default, skip_serializing_if = "Option::is_none")] | 63 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 52 | fixes: Option<String>, | 64 | fixes: Option<String>, |
| 53 | }, | 65 | }, |
| 66 | #[serde(rename = "patch.revise")] | ||
| 54 | PatchRevise { | 67 | PatchRevise { |
| 55 | body: Option<String>, | 68 | body: Option<String>, |
| 56 | }, | 69 | }, |
| 70 | #[serde(rename = "patch.review")] | ||
| 57 | PatchReview { | 71 | PatchReview { |
| 58 | verdict: ReviewVerdict, | 72 | verdict: ReviewVerdict, |
| 59 | body: String, | 73 | body: String, |
| 60 | }, | 74 | }, |
| 75 | #[serde(rename = "patch.comment")] | ||
| 61 | PatchComment { | 76 | PatchComment { |
| 62 | body: String, | 77 | body: String, |
| 63 | }, | 78 | }, |
| 79 | #[serde(rename = "patch.inline_comment")] | ||
| 64 | PatchInlineComment { | 80 | PatchInlineComment { |
| 65 | file: String, | 81 | file: String, |
| 66 | line: u32, | 82 | line: u32, |
| 67 | body: String, | 83 | body: String, |
| 68 | }, | 84 | }, |
| 85 | #[serde(rename = "patch.close")] | ||
| 69 | PatchClose { | 86 | PatchClose { |
| 70 | reason: Option<String>, | 87 | reason: Option<String>, |
| 71 | }, | 88 | }, |
| 89 | #[serde(rename = "patch.merge")] | ||
| 72 | PatchMerge, | 90 | PatchMerge, |
| 91 | #[serde(rename = "collab.merge")] | ||
| 73 | Merge, | 92 | Merge, |
| 74 | } | 93 | } |
| 75 | 94 | ||
src/issue.rs
| Old | New | ||
|---|---|---|---|
| @@ -16,6 +16,7 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate: | |||
| 16 | title: title.to_string(), | 16 | title: title.to_string(), |
| 17 | body: body.to_string(), | 17 | body: body.to_string(), |
| 18 | }, | 18 | }, |
| 19 | clock: 0, | ||
| 19 | }; | 20 | }; |
| 20 | let oid = dag::create_root_event(repo, &event, &sk)?; | 21 | let oid = dag::create_root_event(repo, &event, &sk)?; |
| 21 | let id = oid.to_string(); | 22 | let id = oid.to_string(); |
| @@ -82,6 +83,7 @@ pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crat | |||
| 82 | action: Action::IssueLabel { | 83 | action: Action::IssueLabel { |
| 83 | label: label.to_string(), | 84 | label: label.to_string(), |
| 84 | }, | 85 | }, |
| 86 | clock: 0, | ||
| 85 | }; | 87 | }; |
| 86 | dag::append_event(repo, &ref_name, &event, &sk)?; | 88 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 87 | Ok(()) | 89 | Ok(()) |
| @@ -97,6 +99,7 @@ pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), cr | |||
| 97 | action: Action::IssueUnlabel { | 99 | action: Action::IssueUnlabel { |
| 98 | label: label.to_string(), | 100 | label: label.to_string(), |
| 99 | }, | 101 | }, |
| 102 | clock: 0, | ||
| 100 | }; | 103 | }; |
| 101 | dag::append_event(repo, &ref_name, &event, &sk)?; | 104 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 102 | Ok(()) | 105 | Ok(()) |
| @@ -116,6 +119,7 @@ pub fn assign( | |||
| 116 | action: Action::IssueAssign { | 119 | action: Action::IssueAssign { |
| 117 | assignee: assignee.to_string(), | 120 | assignee: assignee.to_string(), |
| 118 | }, | 121 | }, |
| 122 | clock: 0, | ||
| 119 | }; | 123 | }; |
| 120 | dag::append_event(repo, &ref_name, &event, &sk)?; | 124 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 121 | Ok(()) | 125 | Ok(()) |
| @@ -135,6 +139,7 @@ pub fn unassign( | |||
| 135 | action: Action::IssueUnassign { | 139 | action: Action::IssueUnassign { |
| 136 | assignee: assignee.to_string(), | 140 | assignee: assignee.to_string(), |
| 137 | }, | 141 | }, |
| 142 | clock: 0, | ||
| 138 | }; | 143 | }; |
| 139 | dag::append_event(repo, &ref_name, &event, &sk)?; | 144 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 140 | Ok(()) | 145 | Ok(()) |
| @@ -161,6 +166,7 @@ pub fn edit( | |||
| 161 | title: title.map(|s| s.to_string()), | 166 | title: title.map(|s| s.to_string()), |
| 162 | body: body.map(|s| s.to_string()), | 167 | body: body.map(|s| s.to_string()), |
| 163 | }, | 168 | }, |
| 169 | clock: 0, | ||
| 164 | }; | 170 | }; |
| 165 | dag::append_event(repo, &ref_name, &event, &sk)?; | 171 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 166 | Ok(()) | 172 | Ok(()) |
| @@ -176,6 +182,7 @@ pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), cra | |||
| 176 | action: Action::IssueComment { | 182 | action: Action::IssueComment { |
| 177 | body: body.to_string(), | 183 | body: body.to_string(), |
| 178 | }, | 184 | }, |
| 185 | clock: 0, | ||
| 179 | }; | 186 | }; |
| 180 | dag::append_event(repo, &ref_name, &event, &sk)?; | 187 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 181 | Ok(()) | 188 | Ok(()) |
| @@ -195,6 +202,7 @@ pub fn close( | |||
| 195 | action: Action::IssueClose { | 202 | action: Action::IssueClose { |
| 196 | reason: reason.map(|s| s.to_string()), | 203 | reason: reason.map(|s| s.to_string()), |
| 197 | }, | 204 | }, |
| 205 | clock: 0, | ||
| 198 | }; | 206 | }; |
| 199 | dag::append_event(repo, &ref_name, &event, &sk)?; | 207 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 200 | Ok(()) | 208 | Ok(()) |
| @@ -208,6 +216,7 @@ pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Er | |||
| 208 | timestamp: chrono::Utc::now().to_rfc3339(), | 216 | timestamp: chrono::Utc::now().to_rfc3339(), |
| 209 | author, | 217 | author, |
| 210 | action: Action::IssueReopen, | 218 | action: Action::IssueReopen, |
| 219 | clock: 0, | ||
| 211 | }; | 220 | }; |
| 212 | dag::append_event(repo, &ref_name, &event, &sk)?; | 221 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 213 | Ok(()) | 222 | Ok(()) |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ pub mod patch; | |||
| 9 | pub mod state; | 9 | pub mod state; |
| 10 | pub mod signing; | 10 | pub mod signing; |
| 11 | pub mod sync; | 11 | pub mod sync; |
| 12 | pub mod sync_lock; | ||
| 12 | pub mod trust; | 13 | pub mod trust; |
| 13 | pub mod tui; | 14 | pub mod tui; |
| 14 | 15 | ||
src/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,15 @@ fn main() { | |||
| 15 | }; | 15 | }; |
| 16 | 16 | ||
| 17 | if let Err(e) = git_collab::run(cli, &repo) { | 17 | if let Err(e) = git_collab::run(cli, &repo) { |
| 18 | eprintln!("error: {}", e); | 18 | match &e { |
| 19 | std::process::exit(1); | 19 | git_collab::error::Error::PartialSync { .. } => { |
| 20 | // Summary already printed by sync; just exit with code 1 | ||
| 21 | std::process::exit(1); | ||
| 22 | } | ||
| 23 | _ => { | ||
| 24 | eprintln!("error: {}", e); | ||
| 25 | std::process::exit(1); | ||
| 26 | } | ||
| 27 | } | ||
| 20 | } | 28 | } |
| 21 | } | 29 | } |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -50,6 +50,7 @@ pub fn create( | |||
| 50 | branch: branch.to_string(), | 50 | branch: branch.to_string(), |
| 51 | fixes: fixes.map(|s| s.to_string()), | 51 | fixes: fixes.map(|s| s.to_string()), |
| 52 | }, | 52 | }, |
| 53 | clock: 0, | ||
| 53 | }; | 54 | }; |
| 54 | let oid = dag::create_root_event(repo, &event, &sk)?; | 55 | let oid = dag::create_root_event(repo, &event, &sk)?; |
| 55 | let id = oid.to_string(); | 56 | let id = oid.to_string(); |
| @@ -104,6 +105,7 @@ pub fn comment( | |||
| 104 | timestamp: chrono::Utc::now().to_rfc3339(), | 105 | timestamp: chrono::Utc::now().to_rfc3339(), |
| 105 | author, | 106 | author, |
| 106 | action, | 107 | action, |
| 108 | clock: 0, | ||
| 107 | }; | 109 | }; |
| 108 | dag::append_event(repo, &ref_name, &event, &sk)?; | 110 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 109 | Ok(()) | 111 | Ok(()) |
| @@ -125,6 +127,7 @@ pub fn review( | |||
| 125 | verdict, | 127 | verdict, |
| 126 | body: body.to_string(), | 128 | body: body.to_string(), |
| 127 | }, | 129 | }, |
| 130 | clock: 0, | ||
| 128 | }; | 131 | }; |
| 129 | dag::append_event(repo, &ref_name, &event, &sk)?; | 132 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 130 | Ok(()) | 133 | Ok(()) |
| @@ -144,6 +147,7 @@ pub fn revise( | |||
| 144 | action: Action::PatchRevise { | 147 | action: Action::PatchRevise { |
| 145 | body: body.map(|s| s.to_string()), | 148 | body: body.map(|s| s.to_string()), |
| 146 | }, | 149 | }, |
| 150 | clock: 0, | ||
| 147 | }; | 151 | }; |
| 148 | dag::append_event(repo, &ref_name, &event, &sk)?; | 152 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 149 | Ok(()) | 153 | Ok(()) |
| @@ -207,6 +211,7 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er | |||
| 207 | timestamp: chrono::Utc::now().to_rfc3339(), | 211 | timestamp: chrono::Utc::now().to_rfc3339(), |
| 208 | author: author.clone(), | 212 | author: author.clone(), |
| 209 | action: Action::PatchMerge, | 213 | action: Action::PatchMerge, |
| 214 | clock: 0, | ||
| 210 | }; | 215 | }; |
| 211 | dag::append_event(repo, &ref_name, &event, &sk)?; | 216 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 212 | 217 | ||
| @@ -219,6 +224,7 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er | |||
| 219 | action: Action::IssueClose { | 224 | action: Action::IssueClose { |
| 220 | reason: Some(format!("Fixed by patch {:.8}", p.id)), | 225 | reason: Some(format!("Fixed by patch {:.8}", p.id)), |
| 221 | }, | 226 | }, |
| 227 | clock: 0, | ||
| 222 | }; | 228 | }; |
| 223 | dag::append_event(repo, &issue_ref, &close_event, &sk)?; | 229 | dag::append_event(repo, &issue_ref, &close_event, &sk)?; |
| 224 | } | 230 | } |
| @@ -297,6 +303,7 @@ pub fn close( | |||
| 297 | action: Action::PatchClose { | 303 | action: Action::PatchClose { |
| 298 | reason: reason.map(|s| s.to_string()), | 304 | reason: reason.map(|s| s.to_string()), |
| 299 | }, | 305 | }, |
| 306 | clock: 0, | ||
| 300 | }; | 307 | }; |
| 301 | dag::append_event(repo, &ref_name, &event, &sk)?; | 308 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 302 | Ok(()) | 309 | Ok(()) |
src/signing.rs
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,6 @@ use base64::Engine; | |||
| 6 | use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; | 6 | use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; |
| 7 | use git2::Oid; | 7 | use git2::Oid; |
| 8 | use rand_core::OsRng; | 8 | use rand_core::OsRng; |
| 9 | use serde::{Deserialize, Serialize}; | ||
| 10 | 9 | ||
| 11 | use git2::{Repository, Sort}; | 10 | use git2::{Repository, Sort}; |
| 12 | 11 | ||
| @@ -28,12 +27,11 @@ pub fn signing_key_dir() -> Result<PathBuf, Error> { | |||
| 28 | } | 27 | } |
| 29 | } | 28 | } |
| 30 | 29 | ||
| 31 | /// Wrapper around Event that adds Ed25519 signature fields. | 30 | /// Detached signature data for an event commit. |
| 32 | /// Serialized as the event.json blob in git commits. | 31 | /// Stored as separate blobs (`signature` and `pubkey`) in the commit tree, |
| 33 | #[derive(Debug, Clone, Serialize, Deserialize)] | 32 | /// alongside the plain `event.json`. |
| 34 | pub struct SignedEvent { | 33 | #[derive(Debug, Clone)] |
| 35 | #[serde(flatten)] | 34 | pub struct DetachedSignature { |
| 36 | pub event: Event, | ||
| 37 | pub signature: String, | 35 | pub signature: String, |
| 38 | pub pubkey: String, | 36 | pub pubkey: String, |
| 39 | } | 37 | } |
| @@ -152,33 +150,35 @@ pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> { | |||
| 152 | Ok(json.into_bytes()) | 150 | Ok(json.into_bytes()) |
| 153 | } | 151 | } |
| 154 | 152 | ||
| 155 | /// Sign an Event with the given signing key, producing a SignedEvent. | 153 | /// Sign an Event with the given signing key, producing a detached signature. |
| 156 | pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> { | 154 | pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<DetachedSignature, Error> { |
| 157 | let canonical = canonical_json(event)?; | 155 | let canonical = canonical_json(event)?; |
| 158 | let signature = signing_key.sign(&canonical); | 156 | let signature = signing_key.sign(&canonical); |
| 159 | let verifying_key = signing_key.verifying_key(); | 157 | let verifying_key = signing_key.verifying_key(); |
| 160 | 158 | ||
| 161 | Ok(SignedEvent { | 159 | Ok(DetachedSignature { |
| 162 | event: event.clone(), | ||
| 163 | signature: STANDARD.encode(signature.to_bytes()), | 160 | signature: STANDARD.encode(signature.to_bytes()), |
| 164 | pubkey: STANDARD.encode(verifying_key.to_bytes()), | 161 | pubkey: STANDARD.encode(verifying_key.to_bytes()), |
| 165 | }) | 162 | }) |
| 166 | } | 163 | } |
| 167 | 164 | ||
| 168 | /// Verify a SignedEvent's signature against its embedded public key. | 165 | /// Verify a detached signature against an event and its public key. |
| 169 | /// | 166 | /// |
| 170 | /// Returns `Missing` if signature or pubkey fields are empty, | 167 | /// Returns `Missing` if signature or pubkey fields are empty, |
| 171 | /// `Valid` if the signature checks out, `Invalid` otherwise. | 168 | /// `Valid` if the signature checks out, `Invalid` otherwise. |
| 172 | pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> { | 169 | pub fn verify_detached( |
| 173 | if signed.signature.is_empty() || signed.pubkey.is_empty() { | 170 | event: &Event, |
| 171 | sig: &DetachedSignature, | ||
| 172 | ) -> Result<VerifyStatus, Error> { | ||
| 173 | if sig.signature.is_empty() || sig.pubkey.is_empty() { | ||
| 174 | return Ok(VerifyStatus::Missing); | 174 | return Ok(VerifyStatus::Missing); |
| 175 | } | 175 | } |
| 176 | 176 | ||
| 177 | let sig_bytes = match STANDARD.decode(&signed.signature) { | 177 | let sig_bytes = match STANDARD.decode(&sig.signature) { |
| 178 | Ok(b) => b, | 178 | Ok(b) => b, |
| 179 | Err(_) => return Ok(VerifyStatus::Invalid), | 179 | Err(_) => return Ok(VerifyStatus::Invalid), |
| 180 | }; | 180 | }; |
| 181 | let pubkey_bytes = match STANDARD.decode(&signed.pubkey) { | 181 | let pubkey_bytes = match STANDARD.decode(&sig.pubkey) { |
| 182 | Ok(b) => b, | 182 | Ok(b) => b, |
| 183 | Err(_) => return Ok(VerifyStatus::Invalid), | 183 | Err(_) => return Ok(VerifyStatus::Invalid), |
| 184 | }; | 184 | }; |
| @@ -198,7 +198,7 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> | |||
| 198 | Err(_) => return Ok(VerifyStatus::Invalid), | 198 | Err(_) => return Ok(VerifyStatus::Invalid), |
| 199 | }; | 199 | }; |
| 200 | 200 | ||
| 201 | let canonical = canonical_json(&signed.event)?; | 201 | let canonical = canonical_json(event)?; |
| 202 | 202 | ||
| 203 | match verifying_key.verify(&canonical, &signature) { | 203 | match verifying_key.verify(&canonical, &signature) { |
| 204 | Ok(()) => Ok(VerifyStatus::Valid), | 204 | Ok(()) => Ok(VerifyStatus::Valid), |
| @@ -208,9 +208,8 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> | |||
| 208 | 208 | ||
| 209 | /// Walk the DAG for the given ref and verify every event commit's signature. | 209 | /// Walk the DAG for the given ref and verify every event commit's signature. |
| 210 | /// | 210 | /// |
| 211 | /// For each commit, reads `event.json` from the tree: | 211 | /// For each commit, reads `event.json`, `signature`, and `pubkey` blobs from the tree. |
| 212 | /// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`. | 212 | /// If signature/pubkey blobs are missing, marks as `Missing`. |
| 213 | /// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`. | ||
| 214 | /// | 213 | /// |
| 215 | /// Returns one `SignatureVerificationResult` per commit. | 214 | /// Returns one `SignatureVerificationResult` per commit. |
| 216 | pub fn verify_ref( | 215 | pub fn verify_ref( |
| @@ -227,28 +226,42 @@ pub fn verify_ref( | |||
| 227 | let oid = oid_result?; | 226 | let oid = oid_result?; |
| 228 | let commit = repo.find_commit(oid)?; | 227 | let commit = repo.find_commit(oid)?; |
| 229 | let tree = commit.tree()?; | 228 | let tree = commit.tree()?; |
| 230 | let entry = tree | 229 | |
| 230 | // Read event.json | ||
| 231 | let event_entry = tree | ||
| 231 | .get_name("event.json") | 232 | .get_name("event.json") |
| 232 | .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; | 233 | .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; |
| 233 | let blob = repo.find_blob(entry.id())?; | 234 | let event_blob = repo.find_blob(event_entry.id())?; |
| 234 | let content = blob.content(); | 235 | let event: Event = serde_json::from_slice(event_blob.content())?; |
| 235 | 236 | ||
| 236 | // Try to deserialize as SignedEvent first | 237 | // Read signature and pubkey blob OIDs |
| 237 | if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { | 238 | let sig_oid = tree.get_name("signature").map(|e| e.id()); |
| 238 | if signed.signature.is_empty() || signed.pubkey.is_empty() { | 239 | let pubkey_oid = tree.get_name("pubkey").map(|e| e.id()); |
| 239 | results.push(SignatureVerificationResult { | 240 | |
| 240 | commit_id: oid, | 241 | match (sig_oid, pubkey_oid) { |
| 241 | status: VerifyStatus::Missing, | 242 | (Some(sid), Some(pid)) => { |
| 242 | pubkey: None, | 243 | let sig_blob = repo.find_blob(sid)?; |
| 243 | error: Some("missing signature".to_string()), | 244 | let pubkey_blob = repo.find_blob(pid)?; |
| 244 | }); | 245 | let sig_str = std::str::from_utf8(sig_blob.content()) |
| 245 | } else { | 246 | .unwrap_or("") |
| 246 | match verify_signed_event(&signed)? { | 247 | .trim() |
| 248 | .to_string(); | ||
| 249 | let pubkey_str = std::str::from_utf8(pubkey_blob.content()) | ||
| 250 | .unwrap_or("") | ||
| 251 | .trim() | ||
| 252 | .to_string(); | ||
| 253 | |||
| 254 | let detached = DetachedSignature { | ||
| 255 | signature: sig_str.clone(), | ||
| 256 | pubkey: pubkey_str.clone(), | ||
| 257 | }; | ||
| 258 | |||
| 259 | match verify_detached(&event, &detached)? { | ||
| 247 | VerifyStatus::Valid => { | 260 | VerifyStatus::Valid => { |
| 248 | results.push(SignatureVerificationResult { | 261 | results.push(SignatureVerificationResult { |
| 249 | commit_id: oid, | 262 | commit_id: oid, |
| 250 | status: VerifyStatus::Valid, | 263 | status: VerifyStatus::Valid, |
| 251 | pubkey: Some(signed.pubkey), | 264 | pubkey: Some(pubkey_str), |
| 252 | error: None, | 265 | error: None, |
| 253 | }); | 266 | }); |
| 254 | } | 267 | } |
| @@ -256,7 +269,7 @@ pub fn verify_ref( | |||
| 256 | results.push(SignatureVerificationResult { | 269 | results.push(SignatureVerificationResult { |
| 257 | commit_id: oid, | 270 | commit_id: oid, |
| 258 | status: VerifyStatus::Invalid, | 271 | status: VerifyStatus::Invalid, |
| 259 | pubkey: Some(signed.pubkey), | 272 | pubkey: Some(pubkey_str), |
| 260 | error: Some("invalid signature".to_string()), | 273 | error: Some("invalid signature".to_string()), |
| 261 | }); | 274 | }); |
| 262 | } | 275 | } |
| @@ -269,25 +282,23 @@ pub fn verify_ref( | |||
| 269 | }); | 282 | }); |
| 270 | } | 283 | } |
| 271 | VerifyStatus::Untrusted => { | 284 | VerifyStatus::Untrusted => { |
| 272 | // verify_signed_event never returns Untrusted, | ||
| 273 | // but handle it for exhaustiveness | ||
| 274 | results.push(SignatureVerificationResult { | 285 | results.push(SignatureVerificationResult { |
| 275 | commit_id: oid, | 286 | commit_id: oid, |
| 276 | status: VerifyStatus::Untrusted, | 287 | status: VerifyStatus::Untrusted, |
| 277 | pubkey: Some(signed.pubkey), | 288 | pubkey: Some(pubkey_str), |
| 278 | error: Some("untrusted key".to_string()), | 289 | error: Some("untrusted key".to_string()), |
| 279 | }); | 290 | }); |
| 280 | } | 291 | } |
| 281 | } | 292 | } |
| 282 | } | 293 | } |
| 283 | } else { | 294 | _ => { |
| 284 | // Plain Event without signature fields | 295 | results.push(SignatureVerificationResult { |
| 285 | results.push(SignatureVerificationResult { | 296 | commit_id: oid, |
| 286 | commit_id: oid, | 297 | status: VerifyStatus::Missing, |
| 287 | status: VerifyStatus::Missing, | 298 | pubkey: None, |
| 288 | pubkey: None, | 299 | error: Some("missing signature".to_string()), |
| 289 | error: Some("missing signature".to_string()), | 300 | }); |
| 290 | }); | 301 | } |
| 291 | } | 302 | } |
| 292 | } | 303 | } |
| 293 | 304 | ||
| @@ -300,7 +311,7 @@ mod tests { | |||
| 300 | use crate::event::{Action, Author}; | 311 | use crate::event::{Action, Author}; |
| 301 | 312 | ||
| 302 | #[test] | 313 | #[test] |
| 303 | fn signed_event_flatten_round_trip() { | 314 | fn detached_signature_round_trip() { |
| 304 | let event = Event { | 315 | let event = Event { |
| 305 | timestamp: "2026-03-21T00:00:00Z".to_string(), | 316 | timestamp: "2026-03-21T00:00:00Z".to_string(), |
| 306 | author: Author { | 317 | author: Author { |
| @@ -311,19 +322,16 @@ mod tests { | |||
| 311 | title: "Test".to_string(), | 322 | title: "Test".to_string(), |
| 312 | body: "Body".to_string(), | 323 | body: "Body".to_string(), |
| 313 | }, | 324 | }, |
| 325 | clock: 0, | ||
| 314 | }; | 326 | }; |
| 315 | let signed = SignedEvent { | 327 | |
| 316 | event, | 328 | let sk = SigningKey::generate(&mut rand_core::OsRng); |
| 317 | signature: "dGVzdA==".to_string(), | 329 | let sig = sign_event(&event, &sk).unwrap(); |
| 318 | pubkey: "cHVia2V5".to_string(), | 330 | |
| 319 | }; | 331 | assert!(!sig.signature.is_empty()); |
| 320 | let json = serde_json::to_string_pretty(&signed).unwrap(); | 332 | assert!(!sig.pubkey.is_empty()); |
| 321 | let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); | 333 | |
| 322 | assert_eq!(deserialized.signature, "dGVzdA=="); | 334 | let status = verify_detached(&event, &sig).unwrap(); |
| 323 | assert_eq!(deserialized.pubkey, "cHVia2V5"); | 335 | assert_eq!(status, VerifyStatus::Valid); |
| 324 | match deserialized.event.action { | ||
| 325 | Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"), | ||
| 326 | _ => panic!("Wrong action type after round-trip"), | ||
| 327 | } | ||
| 328 | } | 336 | } |
| 329 | } | 337 | } |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -83,10 +83,9 @@ impl IssueState { | |||
| 83 | let events = dag::walk_events(repo, ref_name)?; | 83 | let events = dag::walk_events(repo, ref_name)?; |
| 84 | let mut state: Option<IssueState> = None; | 84 | let mut state: Option<IssueState> = None; |
| 85 | 85 | ||
| 86 | // Track the timestamp of the latest status-changing event so that | 86 | // Track the (clock, commit_oid_hex) of the latest status-changing event. |
| 87 | // concurrent close/reopen conflicts resolve deterministically: | 87 | // Higher clock wins; on tie, lexicographically higher OID wins. |
| 88 | // the event with the later timestamp wins. | 88 | let mut status_key: Option<(u64, String)> = None; |
| 89 | let mut status_ts: Option<String> = None; | ||
| 90 | 89 | ||
| 91 | for (oid, event) in events { | 90 | for (oid, event) in events { |
| 92 | match event.action { | 91 | match event.action { |
| @@ -117,11 +116,12 @@ impl IssueState { | |||
| 117 | } | 116 | } |
| 118 | Action::IssueClose { reason } => { | 117 | Action::IssueClose { reason } => { |
| 119 | if let Some(ref mut s) = state { | 118 | if let Some(ref mut s) = state { |
| 120 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { | 119 | let key = (event.clock, oid.to_string()); |
| 120 | if status_key.as_ref().is_none_or(|k| key >= *k) { | ||
| 121 | s.status = IssueStatus::Closed; | 121 | s.status = IssueStatus::Closed; |
| 122 | s.close_reason = reason; | 122 | s.close_reason = reason; |
| 123 | s.closed_by = Some(oid); | 123 | s.closed_by = Some(oid); |
| 124 | status_ts = Some(event.timestamp.clone()); | 124 | status_key = Some(key); |
| 125 | } | 125 | } |
| 126 | } | 126 | } |
| 127 | } | 127 | } |
| @@ -161,11 +161,12 @@ impl IssueState { | |||
| 161 | } | 161 | } |
| 162 | Action::IssueReopen => { | 162 | Action::IssueReopen => { |
| 163 | if let Some(ref mut s) = state { | 163 | if let Some(ref mut s) = state { |
| 164 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { | 164 | let key = (event.clock, oid.to_string()); |
| 165 | if status_key.as_ref().is_none_or(|k| key >= *k) { | ||
| 165 | s.status = IssueStatus::Open; | 166 | s.status = IssueStatus::Open; |
| 166 | s.close_reason = None; | 167 | s.close_reason = None; |
| 167 | s.closed_by = None; | 168 | s.closed_by = None; |
| 168 | status_ts = Some(event.timestamp.clone()); | 169 | status_key = Some(key); |
| 169 | } | 170 | } |
| 170 | } | 171 | } |
| 171 | } | 172 | } |
| @@ -209,7 +210,7 @@ impl PatchState { | |||
| 209 | let events = dag::walk_events(repo, ref_name)?; | 210 | let events = dag::walk_events(repo, ref_name)?; |
| 210 | let mut state: Option<PatchState> = None; | 211 | let mut state: Option<PatchState> = None; |
| 211 | 212 | ||
| 212 | let mut status_ts: Option<String> = None; | 213 | let mut status_key: Option<(u64, String)> = None; |
| 213 | 214 | ||
| 214 | for (oid, event) in events { | 215 | for (oid, event) in events { |
| 215 | match event.action { | 216 | match event.action { |
| @@ -275,17 +276,19 @@ impl PatchState { | |||
| 275 | } | 276 | } |
| 276 | Action::PatchClose { .. } => { | 277 | Action::PatchClose { .. } => { |
| 277 | if let Some(ref mut s) = state { | 278 | if let Some(ref mut s) = state { |
| 278 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { | 279 | let key = (event.clock, oid.to_string()); |
| 280 | if status_key.as_ref().is_none_or(|k| key >= *k) { | ||
| 279 | s.status = PatchStatus::Closed; | 281 | s.status = PatchStatus::Closed; |
| 280 | status_ts = Some(event.timestamp.clone()); | 282 | status_key = Some(key); |
| 281 | } | 283 | } |
| 282 | } | 284 | } |
| 283 | } | 285 | } |
| 284 | Action::PatchMerge => { | 286 | Action::PatchMerge => { |
| 285 | if let Some(ref mut s) = state { | 287 | if let Some(ref mut s) = state { |
| 286 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { | 288 | let key = (event.clock, oid.to_string()); |
| 289 | if status_key.as_ref().is_none_or(|k| key >= *k) { | ||
| 287 | s.status = PatchStatus::Merged; | 290 | s.status = PatchStatus::Merged; |
| 288 | status_ts = Some(event.timestamp.clone()); | 291 | status_key = Some(key); |
| 289 | } | 292 | } |
| 290 | } | 293 | } |
| 291 | } | 294 | } |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,13 +1,258 @@ | |||
| 1 | use std::fs; | ||
| 2 | use std::path::{Path, PathBuf}; | ||
| 1 | use std::process::Command; | 3 | use std::process::Command; |
| 2 | 4 | ||
| 3 | use git2::Repository; | 5 | use git2::Repository; |
| 6 | use serde::{Deserialize, Serialize}; | ||
| 4 | 7 | ||
| 5 | use crate::dag; | 8 | use crate::dag; |
| 6 | use crate::error::Error; | 9 | use crate::error::Error; |
| 7 | use crate::identity::get_author; | 10 | use crate::identity::get_author; |
| 8 | use crate::signing; | 11 | use crate::signing; |
| 12 | use crate::sync_lock::SyncLock; | ||
| 9 | use crate::trust; | 13 | use crate::trust; |
| 10 | 14 | ||
| 15 | // --------------------------------------------------------------------------- | ||
| 16 | // Ref name validation | ||
| 17 | // --------------------------------------------------------------------------- | ||
| 18 | |||
| 19 | /// Validate that a collab ref ID is a valid 40-character lowercase hex string. | ||
| 20 | /// This prevents path traversal, null byte injection, and other malicious ref names. | ||
| 21 | pub fn validate_collab_ref_id(id: &str) -> Result<(), Error> { | ||
| 22 | if id.len() != 40 { | ||
| 23 | return Err(Error::InvalidRefName(format!( | ||
| 24 | "ref ID must be exactly 40 characters, got {}", | ||
| 25 | id.len() | ||
| 26 | ))); | ||
| 27 | } | ||
| 28 | if !id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) { | ||
| 29 | return Err(Error::InvalidRefName(format!( | ||
| 30 | "ref ID must contain only lowercase hex characters [0-9a-f], got {:?}", | ||
| 31 | id | ||
| 32 | ))); | ||
| 33 | } | ||
| 34 | Ok(()) | ||
| 35 | } | ||
| 36 | |||
| 37 | // --------------------------------------------------------------------------- | ||
| 38 | // Per-ref push types (T002a) | ||
| 39 | // --------------------------------------------------------------------------- | ||
| 40 | |||
| 41 | /// Status of pushing a single ref. | ||
| 42 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| 43 | pub enum PushStatus { | ||
| 44 | Pushed, | ||
| 45 | Failed, | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Result of pushing a single ref to the remote. | ||
| 49 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 50 | pub struct RefPushResult { | ||
| 51 | pub ref_name: String, | ||
| 52 | pub status: PushStatus, | ||
| 53 | pub error: Option<String>, | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Aggregated outcome of the entire push phase. | ||
| 57 | #[derive(Debug, Clone)] | ||
| 58 | pub struct SyncResult { | ||
| 59 | pub results: Vec<RefPushResult>, | ||
| 60 | pub remote: String, | ||
| 61 | } | ||
| 62 | |||
| 63 | impl SyncResult { | ||
| 64 | pub fn succeeded(&self) -> Vec<&RefPushResult> { | ||
| 65 | self.results | ||
| 66 | .iter() | ||
| 67 | .filter(|r| r.status == PushStatus::Pushed) | ||
| 68 | .collect() | ||
| 69 | } | ||
| 70 | |||
| 71 | pub fn failed(&self) -> Vec<&RefPushResult> { | ||
| 72 | self.results | ||
| 73 | .iter() | ||
| 74 | .filter(|r| r.status == PushStatus::Failed) | ||
| 75 | .collect() | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn is_complete(&self) -> bool { | ||
| 79 | self.results.iter().all(|r| r.status == PushStatus::Pushed) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | // --------------------------------------------------------------------------- | ||
| 84 | // Persistent sync state (T002b) | ||
| 85 | // --------------------------------------------------------------------------- | ||
| 86 | |||
| 87 | /// Persistent record of a partially-completed sync. | ||
| 88 | #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 89 | pub struct SyncState { | ||
| 90 | pub remote: String, | ||
| 91 | pub pending_refs: Vec<(String, String)>, | ||
| 92 | pub timestamp: String, | ||
| 93 | } | ||
| 94 | |||
| 95 | impl SyncState { | ||
| 96 | /// Path to the sync state file within a repo. | ||
| 97 | fn path(repo: &Repository) -> PathBuf { | ||
| 98 | let git_dir = repo.path(); | ||
| 99 | git_dir.join("collab").join("sync-state.json") | ||
| 100 | } | ||
| 101 | |||
| 102 | /// Load sync state from disk. Returns None if the file does not exist. | ||
| 103 | /// If the file is corrupted, prints a warning, deletes it, and returns None. | ||
| 104 | pub fn load(repo: &Repository) -> Option<SyncState> { | ||
| 105 | let path = Self::path(repo); | ||
| 106 | if !path.exists() { | ||
| 107 | return None; | ||
| 108 | } | ||
| 109 | match fs::read_to_string(&path) { | ||
| 110 | Ok(contents) => match serde_json::from_str(&contents) { | ||
| 111 | Ok(state) => Some(state), | ||
| 112 | Err(e) => { | ||
| 113 | eprintln!( | ||
| 114 | "warning: corrupted sync state file ({}); ignoring and proceeding with full sync", | ||
| 115 | e | ||
| 116 | ); | ||
| 117 | let _ = fs::remove_file(&path); | ||
| 118 | None | ||
| 119 | } | ||
| 120 | }, | ||
| 121 | Err(_) => None, | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Save sync state to disk. | ||
| 126 | pub fn save(&self, repo: &Repository) -> Result<(), Error> { | ||
| 127 | let path = Self::path(repo); | ||
| 128 | if let Some(parent) = path.parent() { | ||
| 129 | fs::create_dir_all(parent)?; | ||
| 130 | } | ||
| 131 | let json = serde_json::to_string_pretty(self)?; | ||
| 132 | fs::write(&path, json)?; | ||
| 133 | Ok(()) | ||
| 134 | } | ||
| 135 | |||
| 136 | /// Delete the sync state file. | ||
| 137 | pub fn clear(repo: &Repository) -> Result<(), Error> { | ||
| 138 | let path = Self::path(repo); | ||
| 139 | if path.exists() { | ||
| 140 | fs::remove_file(&path)?; | ||
| 141 | } | ||
| 142 | Ok(()) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | // --------------------------------------------------------------------------- | ||
| 147 | // Push helpers (T003, T004) | ||
| 148 | // --------------------------------------------------------------------------- | ||
| 149 | |||
| 150 | /// Push a single ref to the remote. Returns a RefPushResult. | ||
| 151 | fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult { | ||
| 152 | let refspec = format!("+{}:{}", ref_name, ref_name); | ||
| 153 | match Command::new("git") | ||
| 154 | .args(["push", remote_name, &refspec]) | ||
| 155 | .current_dir(workdir) | ||
| 156 | .output() | ||
| 157 | { | ||
| 158 | Ok(output) => { | ||
| 159 | if output.status.success() { | ||
| 160 | RefPushResult { | ||
| 161 | ref_name: ref_name.to_string(), | ||
| 162 | status: PushStatus::Pushed, | ||
| 163 | error: None, | ||
| 164 | } | ||
| 165 | } else { | ||
| 166 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); | ||
| 167 | RefPushResult { | ||
| 168 | ref_name: ref_name.to_string(), | ||
| 169 | status: PushStatus::Failed, | ||
| 170 | error: Some(stderr), | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 174 | Err(e) => RefPushResult { | ||
| 175 | ref_name: ref_name.to_string(), | ||
| 176 | status: PushStatus::Failed, | ||
| 177 | error: Some(format!("failed to run git push: {}", e)), | ||
| 178 | }, | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | /// Collect all collab ref names that should be pushed. | ||
| 183 | fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> { | ||
| 184 | let mut refs = Vec::new(); | ||
| 185 | for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] { | ||
| 186 | let iter = repo.references_glob(pattern)?; | ||
| 187 | for r in iter { | ||
| 188 | let r = r?; | ||
| 189 | if let Some(name) = r.name() { | ||
| 190 | refs.push(name.to_string()); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | } | ||
| 194 | Ok(refs) | ||
| 195 | } | ||
| 196 | |||
| 197 | // --------------------------------------------------------------------------- | ||
| 198 | // Summary printing (T012) | ||
| 199 | // --------------------------------------------------------------------------- | ||
| 200 | |||
| 201 | /// Print a failure summary to stderr. | ||
| 202 | fn print_sync_summary(result: &SyncResult) { | ||
| 203 | let succeeded = result.succeeded().len(); | ||
| 204 | let total = result.results.len(); | ||
| 205 | |||
| 206 | if succeeded == 0 { | ||
| 207 | eprintln!("\nSync failed: {} of {} refs pushed.", succeeded, total); | ||
| 208 | } else { | ||
| 209 | eprintln!( | ||
| 210 | "\nSync partially failed: {} of {} refs pushed.", | ||
| 211 | succeeded, total | ||
| 212 | ); | ||
| 213 | } | ||
| 214 | |||
| 215 | let failed = result.failed(); | ||
| 216 | eprintln!("Failed refs:"); | ||
| 217 | for f in &failed { | ||
| 218 | eprintln!( | ||
| 219 | " {}: {}", | ||
| 220 | f.ref_name, | ||
| 221 | f.error.as_deref().unwrap_or("unknown error") | ||
| 222 | ); | ||
| 223 | } | ||
| 224 | |||
| 225 | eprintln!( | ||
| 226 | "\nRun `collab sync {}` again to retry {} failed ref(s).", | ||
| 227 | result.remote, | ||
| 228 | failed.len() | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | |||
| 232 | /// Clean up refs/collab/sync/* refs. | ||
| 233 | fn cleanup_sync_refs(repo: &Repository) -> Result<(), Error> { | ||
| 234 | for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] { | ||
| 235 | let refs: Vec<String> = repo | ||
| 236 | .references_glob(&format!("{}*", prefix))? | ||
| 237 | .filter_map(|r| r.ok()?.name().map(|n| n.to_string())) | ||
| 238 | .collect(); | ||
| 239 | for ref_name in refs { | ||
| 240 | let mut r = repo.find_reference(&ref_name)?; | ||
| 241 | r.delete()?; | ||
| 242 | } | ||
| 243 | } | ||
| 244 | // Also clean up any refs under refs/collab/sync/ with remote-name prefix | ||
| 245 | let refs: Vec<String> = repo | ||
| 246 | .references_glob("refs/collab/sync/*")? | ||
| 247 | .filter_map(|r| r.ok()?.name().map(|n| n.to_string())) | ||
| 248 | .collect(); | ||
| 249 | for ref_name in refs { | ||
| 250 | let mut r = repo.find_reference(&ref_name)?; | ||
| 251 | r.delete()?; | ||
| 252 | } | ||
| 253 | Ok(()) | ||
| 254 | } | ||
| 255 | |||
| 11 | /// Add collab refspecs to all remotes. | 256 | /// Add collab refspecs to all remotes. |
| 12 | pub fn init(repo: &Repository) -> Result<(), Error> { | 257 | pub fn init(repo: &Repository) -> Result<(), Error> { |
| 13 | let remotes = repo.remotes()?; | 258 | let remotes = repo.remotes()?; |
| @@ -26,9 +271,21 @@ pub fn init(repo: &Repository) -> Result<(), Error> { | |||
| 26 | 271 | ||
| 27 | /// Sync with a specific remote: fetch, reconcile, push. | 272 | /// Sync with a specific remote: fetch, reconcile, push. |
| 28 | pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | 273 | pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { |
| 29 | let author = get_author(repo)?; | 274 | // Acquire advisory lock — held until _lock is dropped (RAII) |
| 275 | let _lock = SyncLock::acquire(repo)?; | ||
| 276 | |||
| 30 | let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf(); | 277 | let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf(); |
| 31 | 278 | ||
| 279 | // Check for existing sync state (resume mode) | ||
| 280 | if let Some(state) = SyncState::load(repo) { | ||
| 281 | if state.remote == remote_name { | ||
| 282 | return sync_resume(repo, remote_name, &workdir, &state); | ||
| 283 | } | ||
| 284 | // State is for a different remote — ignore it, run full sync | ||
| 285 | } | ||
| 286 | |||
| 287 | let author = get_author(repo)?; | ||
| 288 | |||
| 32 | // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) | 289 | // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) |
| 33 | println!("Fetching from '{}'...", remote_name); | 290 | println!("Fetching from '{}'...", remote_name); |
| 34 | let fetch_status = Command::new("git") | 291 | let fetch_status = Command::new("git") |
| @@ -56,57 +313,146 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { | |||
| 56 | reconcile_refs(&repo, "issues", &author, &sk)?; | 313 | reconcile_refs(&repo, "issues", &author, &sk)?; |
| 57 | reconcile_refs(&repo, "patches", &author, &sk)?; | 314 | reconcile_refs(&repo, "patches", &author, &sk)?; |
| 58 | 315 | ||
| 59 | // Step 3: Push collab refs using system git | 316 | // Step 3: Push collab refs individually |
| 60 | println!("Pushing to '{}'...", remote_name); | 317 | println!("Pushing to '{}'...", remote_name); |
| 61 | let mut push_args = vec!["push", remote_name]; | 318 | let refs_to_push = collect_push_refs(&repo)?; |
| 62 | let mut refspecs: Vec<String> = Vec::new(); | ||
| 63 | |||
| 64 | for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] { | ||
| 65 | let refs = repo.references_glob(pattern)?; | ||
| 66 | for r in refs { | ||
| 67 | let r = r?; | ||
| 68 | if let Some(name) = r.name() { | ||
| 69 | refspecs.push(format!("+{}:{}", name, name)); | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | ||
| 73 | 319 | ||
| 74 | if refspecs.is_empty() { | 320 | if refs_to_push.is_empty() { |
| 75 | println!("Nothing to push."); | 321 | println!("Nothing to push."); |
| 76 | } else { | 322 | } else { |
| 77 | let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect(); | 323 | let sync_result = push_refs_individually(&workdir, remote_name, &refs_to_push); |
| 78 | push_args.extend(refspec_strs); | 324 | |
| 79 | 325 | if !sync_result.is_complete() { | |
| 80 | let push_status = Command::new("git") | 326 | // Save state for resume |
| 81 | .args(&push_args) | 327 | let now = chrono::Utc::now().to_rfc3339(); |
| 82 | .current_dir(&workdir) | 328 | let pending: Vec<(String, String)> = sync_result |
| 83 | .status() | 329 | .failed() |
| 84 | .map_err(|e| Error::Cmd(format!("failed to run git push: {}", e)))?; | 330 | .iter() |
| 85 | 331 | .map(|r| { | |
| 86 | if !push_status.success() { | 332 | ( |
| 87 | return Err(Error::Cmd(format!( | 333 | r.ref_name.clone(), |
| 88 | "git push exited with status {}", | 334 | r.error.clone().unwrap_or_else(|| "unknown error".to_string()), |
| 89 | push_status | 335 | ) |
| 90 | ))); | 336 | }) |
| 337 | .collect(); | ||
| 338 | let state = SyncState { | ||
| 339 | remote: remote_name.to_string(), | ||
| 340 | pending_refs: pending, | ||
| 341 | timestamp: now, | ||
| 342 | }; | ||
| 343 | state.save(&repo)?; | ||
| 344 | |||
| 345 | print_sync_summary(&sync_result); | ||
| 346 | |||
| 347 | // Clean up sync refs before returning error | ||
| 348 | cleanup_sync_refs(&repo)?; | ||
| 349 | |||
| 350 | let succeeded = sync_result.succeeded().len(); | ||
| 351 | let total = sync_result.results.len(); | ||
| 352 | return Err(Error::PartialSync { | ||
| 353 | succeeded, | ||
| 354 | total, | ||
| 355 | }); | ||
| 91 | } | 356 | } |
| 92 | } | 357 | } |
| 93 | 358 | ||
| 94 | // Step 4: Clean up sync refs | 359 | // Step 4: Clean up sync refs |
| 95 | for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] { | 360 | cleanup_sync_refs(&repo)?; |
| 96 | let refs: Vec<String> = repo | ||
| 97 | .references_glob(&format!("{}*", prefix))? | ||
| 98 | .filter_map(|r| r.ok()?.name().map(|n| n.to_string())) | ||
| 99 | .collect(); | ||
| 100 | for ref_name in refs { | ||
| 101 | let mut r = repo.find_reference(&ref_name)?; | ||
| 102 | r.delete()?; | ||
| 103 | } | ||
| 104 | } | ||
| 105 | 361 | ||
| 106 | println!("Sync complete."); | 362 | println!("Sync complete."); |
| 107 | Ok(()) | 363 | Ok(()) |
| 108 | } | 364 | } |
| 109 | 365 | ||
| 366 | /// Resume a partially-failed sync by retrying only the pending refs. | ||
| 367 | fn sync_resume( | ||
| 368 | repo: &Repository, | ||
| 369 | remote_name: &str, | ||
| 370 | workdir: &Path, | ||
| 371 | state: &SyncState, | ||
| 372 | ) -> Result<(), Error> { | ||
| 373 | println!( | ||
| 374 | "Resuming sync to '{}' ({} refs pending from previous failure)...", | ||
| 375 | remote_name, | ||
| 376 | state.pending_refs.len() | ||
| 377 | ); | ||
| 378 | |||
| 379 | // Print pending refs with their last error | ||
| 380 | for (ref_name, last_error) in &state.pending_refs { | ||
| 381 | println!(" Pending: {} (last error: {})", ref_name, last_error); | ||
| 382 | } | ||
| 383 | |||
| 384 | // Clean up stale sync refs (T018b) | ||
| 385 | cleanup_sync_refs(repo)?; | ||
| 386 | |||
| 387 | // Push the pending refs | ||
| 388 | let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect(); | ||
| 389 | let sync_result = push_refs_individually(workdir, remote_name, &ref_names); | ||
| 390 | |||
| 391 | if sync_result.is_complete() { | ||
| 392 | // All pending refs pushed successfully | ||
| 393 | SyncState::clear(repo)?; | ||
| 394 | if sync_result.results.is_empty() { | ||
| 395 | println!("All pending refs are already up to date. Clearing stale sync state."); | ||
| 396 | } else { | ||
| 397 | println!("\nSync complete. All previously-failed refs pushed."); | ||
| 398 | } | ||
| 399 | println!("Sync complete."); | ||
| 400 | Ok(()) | ||
| 401 | } else { | ||
| 402 | // Some refs still failing - update state | ||
| 403 | let now = chrono::Utc::now().to_rfc3339(); | ||
| 404 | let pending: Vec<(String, String)> = sync_result | ||
| 405 | .failed() | ||
| 406 | .iter() | ||
| 407 | .map(|r| { | ||
| 408 | ( | ||
| 409 | r.ref_name.clone(), | ||
| 410 | r.error.clone().unwrap_or_else(|| "unknown error".to_string()), | ||
| 411 | ) | ||
| 412 | }) | ||
| 413 | .collect(); | ||
| 414 | let new_state = SyncState { | ||
| 415 | remote: remote_name.to_string(), | ||
| 416 | pending_refs: pending, | ||
| 417 | timestamp: now, | ||
| 418 | }; | ||
| 419 | new_state.save(repo)?; | ||
| 420 | |||
| 421 | print_sync_summary(&sync_result); | ||
| 422 | eprintln!("To force a full sync, delete .git/collab/sync-state.json"); | ||
| 423 | |||
| 424 | let succeeded = sync_result.succeeded().len(); | ||
| 425 | let total = sync_result.results.len(); | ||
| 426 | Err(Error::PartialSync { | ||
| 427 | succeeded, | ||
| 428 | total, | ||
| 429 | }) | ||
| 430 | } | ||
| 431 | } | ||
| 432 | |||
| 433 | /// Push refs one at a time, printing per-ref status, and return aggregated results. | ||
| 434 | fn push_refs_individually(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult { | ||
| 435 | let mut results = Vec::new(); | ||
| 436 | for ref_name in refs { | ||
| 437 | let result = push_ref(workdir, remote_name, ref_name); | ||
| 438 | match &result.status { | ||
| 439 | PushStatus::Pushed => println!(" Pushed {}", ref_name), | ||
| 440 | PushStatus::Failed => { | ||
| 441 | eprintln!( | ||
| 442 | " FAILED {}: {}", | ||
| 443 | ref_name, | ||
| 444 | result.error.as_deref().unwrap_or("unknown error") | ||
| 445 | ); | ||
| 446 | } | ||
| 447 | } | ||
| 448 | results.push(result); | ||
| 449 | } | ||
| 450 | SyncResult { | ||
| 451 | results, | ||
| 452 | remote: remote_name.to_string(), | ||
| 453 | } | ||
| 454 | } | ||
| 455 | |||
| 110 | /// Reconcile all refs of a given kind (issues or patches) from sync refs. | 456 | /// Reconcile all refs of a given kind (issues or patches) from sync refs. |
| 111 | fn reconcile_refs( | 457 | fn reconcile_refs( |
| 112 | repo: &Repository, | 458 | repo: &Repository, |
| @@ -131,6 +477,12 @@ fn reconcile_refs( | |||
| 131 | let mut warned_unconfigured = false; | 477 | let mut warned_unconfigured = false; |
| 132 | 478 | ||
| 133 | for (remote_ref, id) in &sync_refs { | 479 | for (remote_ref, id) in &sync_refs { |
| 480 | // Validate the ref ID format before processing | ||
| 481 | if let Err(e) = validate_collab_ref_id(id) { | ||
| 482 | eprintln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e); | ||
| 483 | continue; | ||
| 484 | } | ||
| 485 | |||
| 134 | // Verify all commits on the remote ref before reconciling | 486 | // Verify all commits on the remote ref before reconciling |
| 135 | match signing::verify_ref(repo, remote_ref) { | 487 | match signing::verify_ref(repo, remote_ref) { |
| 136 | Ok(results) => { | 488 | Ok(results) => { |
src/sync_lock.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,203 @@ | |||
| 1 | use std::fs::{self, OpenOptions}; | ||
| 2 | use std::io::Write; | ||
| 3 | use std::path::PathBuf; | ||
| 4 | use std::process::Command; | ||
| 5 | |||
| 6 | use serde::{Deserialize, Serialize}; | ||
| 7 | |||
| 8 | use crate::error::Error; | ||
| 9 | |||
| 10 | /// Default staleness threshold in minutes. | ||
| 11 | const STALE_THRESHOLD_MINUTES: u64 = 10; | ||
| 12 | |||
| 13 | /// Metadata stored in the lockfile for stale detection and error reporting. | ||
| 14 | #[derive(Debug, Serialize, Deserialize)] | ||
| 15 | pub struct SyncLockInfo { | ||
| 16 | pub pid: u32, | ||
| 17 | pub timestamp: String, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl SyncLockInfo { | ||
| 21 | /// Serialize to JSON string. | ||
| 22 | pub fn to_json(&self) -> String { | ||
| 23 | serde_json::to_string(self).expect("SyncLockInfo serialization should not fail") | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Deserialize from JSON string. | ||
| 27 | pub fn from_json(json: &str) -> Result<Self, Error> { | ||
| 28 | Ok(serde_json::from_str(json)?) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | /// RAII guard that owns the lockfile. Deletes the lockfile on drop. | ||
| 33 | #[derive(Debug)] | ||
| 34 | pub struct SyncLock { | ||
| 35 | pub lock_path: PathBuf, | ||
| 36 | } | ||
| 37 | |||
| 38 | impl SyncLock { | ||
| 39 | /// Attempt to acquire the sync lock for the given repository. | ||
| 40 | /// | ||
| 41 | /// Creates `.git/collab/sync.lock` atomically using `create_new(true)`. | ||
| 42 | /// If the lockfile already exists, checks for staleness before returning an error. | ||
| 43 | pub fn acquire(repo: &git2::Repository) -> Result<Self, Error> { | ||
| 44 | let collab_dir = repo.path().join("collab"); | ||
| 45 | fs::create_dir_all(&collab_dir)?; | ||
| 46 | let lock_path = collab_dir.join("sync.lock"); | ||
| 47 | |||
| 48 | match Self::try_create_lock(&lock_path) { | ||
| 49 | Ok(lock) => Ok(lock), | ||
| 50 | Err(_) => { | ||
| 51 | // Lockfile exists — check if stale | ||
| 52 | Self::handle_existing_lock(&lock_path) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Try to atomically create the lockfile. Returns Err on any failure. | ||
| 58 | fn try_create_lock(lock_path: &PathBuf) -> Result<Self, Error> { | ||
| 59 | let info = SyncLockInfo { | ||
| 60 | pid: std::process::id(), | ||
| 61 | timestamp: chrono::Utc::now().to_rfc3339(), | ||
| 62 | }; | ||
| 63 | let json = info.to_json(); | ||
| 64 | |||
| 65 | let mut file = OpenOptions::new() | ||
| 66 | .write(true) | ||
| 67 | .create_new(true) | ||
| 68 | .open(lock_path)?; | ||
| 69 | file.write_all(json.as_bytes())?; | ||
| 70 | file.flush()?; | ||
| 71 | |||
| 72 | Ok(SyncLock { | ||
| 73 | lock_path: lock_path.clone(), | ||
| 74 | }) | ||
| 75 | } | ||
| 76 | |||
| 77 | /// Handle the case where a lockfile already exists. | ||
| 78 | /// Check staleness and either clean up and retry, or return SyncLocked error. | ||
| 79 | fn handle_existing_lock(lock_path: &PathBuf) -> Result<Self, Error> { | ||
| 80 | let content = match fs::read_to_string(lock_path) { | ||
| 81 | Ok(c) => c, | ||
| 82 | Err(_) => { | ||
| 83 | // Can't read — treat as corrupted/stale | ||
| 84 | Self::remove_stale_and_retry(lock_path)?; | ||
| 85 | return Self::try_create_or_contention(lock_path); | ||
| 86 | } | ||
| 87 | }; | ||
| 88 | |||
| 89 | let info = match SyncLockInfo::from_json(&content) { | ||
| 90 | Ok(info) => info, | ||
| 91 | Err(_) => { | ||
| 92 | // Corrupted JSON — treat as stale | ||
| 93 | Self::remove_stale_and_retry(lock_path)?; | ||
| 94 | return Self::try_create_or_contention(lock_path); | ||
| 95 | } | ||
| 96 | }; | ||
| 97 | |||
| 98 | if is_stale(&info, STALE_THRESHOLD_MINUTES) { | ||
| 99 | Self::remove_stale_and_retry(lock_path)?; | ||
| 100 | return Self::try_create_or_contention(lock_path); | ||
| 101 | } | ||
| 102 | |||
| 103 | // Lock is held by a live process — return error with enhanced message | ||
| 104 | Err(Error::SyncLocked { | ||
| 105 | pid: info.pid, | ||
| 106 | since: format!( | ||
| 107 | "{} — wait for it to finish, or remove .git/collab/sync.lock if the process is no longer running", | ||
| 108 | format_lock_age(&info.timestamp) | ||
| 109 | ), | ||
| 110 | }) | ||
| 111 | } | ||
| 112 | |||
| 113 | /// Remove the stale lockfile. Ignores errors if the file is already gone. | ||
| 114 | fn remove_stale_and_retry(lock_path: &PathBuf) -> Result<(), Error> { | ||
| 115 | eprintln!("Removing stale sync lock..."); | ||
| 116 | let _ = fs::remove_file(lock_path); | ||
| 117 | Ok(()) | ||
| 118 | } | ||
| 119 | |||
| 120 | /// Try to create the lock; if it fails (race with another process), read the | ||
| 121 | /// new holder's info and return SyncLocked. | ||
| 122 | fn try_create_or_contention(lock_path: &PathBuf) -> Result<Self, Error> { | ||
| 123 | match Self::try_create_lock(lock_path) { | ||
| 124 | Ok(lock) => Ok(lock), | ||
| 125 | Err(_) => { | ||
| 126 | // Another process won the race — read the new lock info | ||
| 127 | let content = fs::read_to_string(lock_path).unwrap_or_default(); | ||
| 128 | let info = SyncLockInfo::from_json(&content).unwrap_or(SyncLockInfo { | ||
| 129 | pid: 0, | ||
| 130 | timestamp: String::new(), | ||
| 131 | }); | ||
| 132 | Err(Error::SyncLocked { | ||
| 133 | pid: info.pid, | ||
| 134 | since: format!( | ||
| 135 | "{} — wait for it to finish, or remove .git/collab/sync.lock if the process is no longer running", | ||
| 136 | format_lock_age(&info.timestamp) | ||
| 137 | ), | ||
| 138 | }) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | impl Drop for SyncLock { | ||
| 145 | fn drop(&mut self) { | ||
| 146 | let _ = fs::remove_file(&self.lock_path); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | /// Check if a process with the given PID is alive using `kill -0`. | ||
| 151 | pub fn is_process_alive(pid: u32) -> bool { | ||
| 152 | Command::new("kill") | ||
| 153 | .args(["-0", &pid.to_string()]) | ||
| 154 | .stdout(std::process::Stdio::null()) | ||
| 155 | .stderr(std::process::Stdio::null()) | ||
| 156 | .status() | ||
| 157 | .map(|s| s.success()) | ||
| 158 | .unwrap_or(false) | ||
| 159 | } | ||
| 160 | |||
| 161 | /// Check if a lock is stale based on PID liveness and timestamp age. | ||
| 162 | pub fn is_stale(info: &SyncLockInfo, threshold_minutes: u64) -> bool { | ||
| 163 | // If PID is dead, it's stale | ||
| 164 | if !is_process_alive(info.pid) { | ||
| 165 | return true; | ||
| 166 | } | ||
| 167 | |||
| 168 | // If the lock is older than the threshold, treat as stale (PID reuse protection) | ||
| 169 | if let Ok(lock_time) = chrono::DateTime::parse_from_rfc3339(&info.timestamp) { | ||
| 170 | let age = chrono::Utc::now() - lock_time.with_timezone(&chrono::Utc); | ||
| 171 | if age.num_minutes() >= threshold_minutes as i64 { | ||
| 172 | return true; | ||
| 173 | } | ||
| 174 | } else { | ||
| 175 | // Can't parse timestamp — treat as stale | ||
| 176 | return true; | ||
| 177 | } | ||
| 178 | |||
| 179 | false | ||
| 180 | } | ||
| 181 | |||
| 182 | /// Convert an RFC 3339 timestamp to a human-readable duration (e.g., "3 seconds ago"). | ||
| 183 | pub fn format_lock_age(timestamp: &str) -> String { | ||
| 184 | let lock_time = match chrono::DateTime::parse_from_rfc3339(timestamp) { | ||
| 185 | Ok(t) => t, | ||
| 186 | Err(_) => return format!("unknown time ({})", timestamp), | ||
| 187 | }; | ||
| 188 | |||
| 189 | let age = chrono::Utc::now() - lock_time.with_timezone(&chrono::Utc); | ||
| 190 | let secs = age.num_seconds(); | ||
| 191 | |||
| 192 | if secs < 0 { | ||
| 193 | "just now".to_string() | ||
| 194 | } else if secs < 60 { | ||
| 195 | format!("{} second{} ago", secs, if secs == 1 { "" } else { "s" }) | ||
| 196 | } else if secs < 3600 { | ||
| 197 | let mins = secs / 60; | ||
| 198 | format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" }) | ||
| 199 | } else { | ||
| 200 | let hours = secs / 3600; | ||
| 201 | format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) | ||
| 202 | } | ||
| 203 | } | ||
src/tui.rs
| Old | New | ||
|---|---|---|---|
| @@ -1991,6 +1991,7 @@ mod tests { | |||
| 1991 | title: "Test Issue".to_string(), | 1991 | title: "Test Issue".to_string(), |
| 1992 | body: "This is the body".to_string(), | 1992 | body: "This is the body".to_string(), |
| 1993 | }, | 1993 | }, |
| 1994 | clock: 0, | ||
| 1994 | }, | 1995 | }, |
| 1995 | ), | 1996 | ), |
| 1996 | ( | 1997 | ( |
| @@ -2004,6 +2005,7 @@ mod tests { | |||
| 2004 | action: Action::IssueComment { | 2005 | action: Action::IssueComment { |
| 2005 | body: "A comment on the issue".to_string(), | 2006 | body: "A comment on the issue".to_string(), |
| 2006 | }, | 2007 | }, |
| 2008 | clock: 0, | ||
| 2007 | }, | 2009 | }, |
| 2008 | ), | 2010 | ), |
| 2009 | ( | 2011 | ( |
| @@ -2014,6 +2016,7 @@ mod tests { | |||
| 2014 | action: Action::IssueClose { | 2016 | action: Action::IssueClose { |
| 2015 | reason: Some("fixed".to_string()), | 2017 | reason: Some("fixed".to_string()), |
| 2016 | }, | 2018 | }, |
| 2019 | clock: 0, | ||
| 2017 | }, | 2020 | }, |
| 2018 | ), | 2021 | ), |
| 2019 | ] | 2022 | ] |
| @@ -2097,6 +2100,7 @@ mod tests { | |||
| 2097 | title: "My Issue".to_string(), | 2100 | title: "My Issue".to_string(), |
| 2098 | body: "Description here".to_string(), | 2101 | body: "Description here".to_string(), |
| 2099 | }, | 2102 | }, |
| 2103 | clock: 0, | ||
| 2100 | }; | 2104 | }; |
| 2101 | let detail = format_event_detail(&oid, &event); | 2105 | let detail = format_event_detail(&oid, &event); |
| 2102 | assert!(detail.contains("aaaaaaa")); | 2106 | assert!(detail.contains("aaaaaaa")); |
| @@ -2116,6 +2120,7 @@ mod tests { | |||
| 2116 | action: Action::IssueClose { | 2120 | action: Action::IssueClose { |
| 2117 | reason: Some("resolved".to_string()), | 2121 | reason: Some("resolved".to_string()), |
| 2118 | }, | 2122 | }, |
| 2123 | clock: 0, | ||
| 2119 | }; | 2124 | }; |
| 2120 | let detail = format_event_detail(&oid, &event); | 2125 | let detail = format_event_detail(&oid, &event); |
| 2121 | assert!(detail.contains("Issue Close")); | 2126 | assert!(detail.contains("Issue Close")); |
| @@ -2132,6 +2137,7 @@ mod tests { | |||
| 2132 | verdict: ReviewVerdict::Approve, | 2137 | verdict: ReviewVerdict::Approve, |
| 2133 | body: "Looks good!".to_string(), | 2138 | body: "Looks good!".to_string(), |
| 2134 | }, | 2139 | }, |
| 2140 | clock: 0, | ||
| 2135 | }; | 2141 | }; |
| 2136 | let detail = format_event_detail(&oid, &event); | 2142 | let detail = format_event_detail(&oid, &event); |
| 2137 | assert!(detail.contains("Patch Review")); | 2143 | assert!(detail.contains("Patch Review")); |
| @@ -2146,6 +2152,7 @@ mod tests { | |||
| 2146 | timestamp: "2026-01-01T00:00:00Z".to_string(), | 2152 | timestamp: "2026-01-01T00:00:00Z".to_string(), |
| 2147 | author: test_author(), | 2153 | author: test_author(), |
| 2148 | action: Action::IssueReopen, | 2154 | action: Action::IssueReopen, |
| 2155 | clock: 0, | ||
| 2149 | }; | 2156 | }; |
| 2150 | let detail = format_event_detail(&oid, &event); | 2157 | let detail = format_event_detail(&oid, &event); |
| 2151 | assert!(detail.contains("1234567")); | 2158 | assert!(detail.contains("1234567")); |
tests/adversarial_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,699 @@ | |||
| 1 | //! Adversarial & fuzz testing for untrusted input. | ||
| 2 | //! | ||
| 3 | //! Tests that malformed JSON, corrupted git trees, oversized payloads, | ||
| 4 | //! and malicious ref names are all handled gracefully (Result::Err, never panic). | ||
| 5 | |||
| 6 | mod common; | ||
| 7 | |||
| 8 | use git2::Repository; | ||
| 9 | use tempfile::TempDir; | ||
| 10 | |||
| 11 | use git_collab::dag::{self, MAX_EVENT_BLOB_SIZE}; | ||
| 12 | use git_collab::event::{Action, Author, Event}; | ||
| 13 | use git_collab::sync::validate_collab_ref_id; | ||
| 14 | |||
| 15 | use common::{alice, init_repo, now}; | ||
| 16 | |||
| 17 | // =========================================================================== | ||
| 18 | // Helper functions | ||
| 19 | // =========================================================================== | ||
| 20 | |||
| 21 | /// Create a git commit whose tree contains an event.json blob with the given | ||
| 22 | /// raw bytes. Returns (TempDir, repo, ref_name) so callers can use dag::walk_events. | ||
| 23 | fn repo_with_blob(content: &[u8]) -> (TempDir, Repository, String) { | ||
| 24 | let tmp = TempDir::new().unwrap(); | ||
| 25 | let repo = init_repo(tmp.path(), &alice()); | ||
| 26 | |||
| 27 | let oid = { | ||
| 28 | let blob_oid = repo.blob(content).unwrap(); | ||
| 29 | let manifest = br#"{"version":1,"format":"git-collab"}"#; | ||
| 30 | let manifest_blob = repo.blob(manifest).unwrap(); | ||
| 31 | |||
| 32 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 33 | tb.insert("event.json", blob_oid, 0o100644).unwrap(); | ||
| 34 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 35 | let tree_oid = tb.write().unwrap(); | ||
| 36 | drop(tb); | ||
| 37 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 38 | |||
| 39 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 40 | repo.commit(None, &sig, &sig, "adversarial test", &tree, &[]) | ||
| 41 | .unwrap() | ||
| 42 | }; | ||
| 43 | |||
| 44 | let ref_name = format!("refs/collab/issues/{}", oid); | ||
| 45 | repo.reference(&ref_name, oid, false, "test").unwrap(); | ||
| 46 | |||
| 47 | (tmp, repo, ref_name) | ||
| 48 | } | ||
| 49 | |||
| 50 | /// Create a git commit with a custom tree structure. The closure receives | ||
| 51 | /// the repo and must return a tree OID. | ||
| 52 | /// Returns (TempDir, repo, ref_name). | ||
| 53 | fn repo_with_custom_tree<F>(builder_fn: F) -> (TempDir, Repository, String) | ||
| 54 | where | ||
| 55 | F: FnOnce(&Repository) -> git2::Oid, | ||
| 56 | { | ||
| 57 | let tmp = TempDir::new().unwrap(); | ||
| 58 | let repo = init_repo(tmp.path(), &alice()); | ||
| 59 | |||
| 60 | let oid = { | ||
| 61 | let tree_oid = builder_fn(&repo); | ||
| 62 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 63 | |||
| 64 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 65 | repo.commit(None, &sig, &sig, "custom tree test", &tree, &[]) | ||
| 66 | .unwrap() | ||
| 67 | }; | ||
| 68 | |||
| 69 | let ref_name = format!("refs/collab/issues/{}", oid); | ||
| 70 | repo.reference(&ref_name, oid, false, "test").unwrap(); | ||
| 71 | |||
| 72 | (tmp, repo, ref_name) | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Build a valid Event JSON string for use in tests. | ||
| 76 | fn valid_event_json() -> String { | ||
| 77 | serde_json::to_string(&Event { | ||
| 78 | timestamp: now(), | ||
| 79 | author: alice(), | ||
| 80 | action: Action::IssueOpen { | ||
| 81 | title: "Test".to_string(), | ||
| 82 | body: "Body".to_string(), | ||
| 83 | }, | ||
| 84 | clock: 1, | ||
| 85 | }) | ||
| 86 | .unwrap() | ||
| 87 | } | ||
| 88 | |||
| 89 | // =========================================================================== | ||
| 90 | // Phase 3: User Story 1 — Resilient Event Parsing | ||
| 91 | // =========================================================================== | ||
| 92 | |||
| 93 | #[test] | ||
| 94 | fn invalid_json_returns_error() { | ||
| 95 | let (_tmp, repo, ref_name) = repo_with_blob(b"{not valid json"); | ||
| 96 | let result = dag::walk_events(&repo, &ref_name); | ||
| 97 | assert!(result.is_err(), "invalid JSON should return Err"); | ||
| 98 | } | ||
| 99 | |||
| 100 | #[test] | ||
| 101 | fn empty_blob_returns_error() { | ||
| 102 | let (_tmp, repo, ref_name) = repo_with_blob(b""); | ||
| 103 | let result = dag::walk_events(&repo, &ref_name); | ||
| 104 | assert!(result.is_err(), "empty blob should return Err"); | ||
| 105 | } | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn missing_type_field_returns_error() { | ||
| 109 | let json = br#"{"timestamp":"t","author":{"name":"a","email":"e"}}"#; | ||
| 110 | let (_tmp, repo, ref_name) = repo_with_blob(json); | ||
| 111 | let result = dag::walk_events(&repo, &ref_name); | ||
| 112 | assert!(result.is_err(), "missing action/type field should return Err"); | ||
| 113 | } | ||
| 114 | |||
| 115 | #[test] | ||
| 116 | fn unknown_action_type_returns_error() { | ||
| 117 | let json = | ||
| 118 | br#"{"timestamp":"t","author":{"name":"a","email":"e"},"action":{"type":"UnknownAction"}}"#; | ||
| 119 | let (_tmp, repo, ref_name) = repo_with_blob(json); | ||
| 120 | let result = dag::walk_events(&repo, &ref_name); | ||
| 121 | assert!(result.is_err(), "unknown action type should return Err"); | ||
| 122 | } | ||
| 123 | |||
| 124 | #[test] | ||
| 125 | fn missing_required_action_fields_returns_error() { | ||
| 126 | // IssueOpen requires title and body | ||
| 127 | let json = | ||
| 128 | br#"{"timestamp":"t","author":{"name":"a","email":"e"},"action":{"type":"issue.open"}}"#; | ||
| 129 | let (_tmp, repo, ref_name) = repo_with_blob(json); | ||
| 130 | let result = dag::walk_events(&repo, &ref_name); | ||
| 131 | assert!( | ||
| 132 | result.is_err(), | ||
| 133 | "IssueOpen missing title/body should return Err" | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 137 | #[test] | ||
| 138 | fn wrong_field_types_returns_error() { | ||
| 139 | // timestamp as number, author as string instead of object | ||
| 140 | let json = br#"{"timestamp":123,"author":"not-object","action":{"type":"issue.open"}}"#; | ||
| 141 | let (_tmp, repo, ref_name) = repo_with_blob(json); | ||
| 142 | let result = dag::walk_events(&repo, &ref_name); | ||
| 143 | assert!(result.is_err(), "wrong field types should return Err"); | ||
| 144 | } | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | fn valid_json_wrong_schema_returns_error() { | ||
| 148 | let json = br#"{"name":"package","version":"1.0"}"#; | ||
| 149 | let (_tmp, repo, ref_name) = repo_with_blob(json); | ||
| 150 | let result = dag::walk_events(&repo, &ref_name); | ||
| 151 | assert!( | ||
| 152 | result.is_err(), | ||
| 153 | "valid JSON with wrong schema should return Err" | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | |||
| 157 | #[test] | ||
| 158 | fn deeply_nested_json_returns_error() { | ||
| 159 | // Build 1000-level nested JSON: {"a":{"a":{"a":...}}} | ||
| 160 | let mut json = String::from(r#"{"a":"#); | ||
| 161 | for _ in 0..999 { | ||
| 162 | json.push_str(r#"{"a":"#); | ||
| 163 | } | ||
| 164 | json.push('1'); | ||
| 165 | for _ in 0..1000 { | ||
| 166 | json.push('}'); | ||
| 167 | } | ||
| 168 | |||
| 169 | let (_tmp, repo, ref_name) = repo_with_blob(json.as_bytes()); | ||
| 170 | // serde_json has a recursion limit of 128; this should either return Err | ||
| 171 | // or panic (which we catch). Either way, it must not corrupt state. | ||
| 172 | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 173 | dag::walk_events(&repo, &ref_name) | ||
| 174 | })); | ||
| 175 | match result { | ||
| 176 | Ok(Err(_)) => {} // Clean error — good | ||
| 177 | Ok(Ok(_)) => panic!("deeply nested JSON should not parse as a valid Event"), | ||
| 178 | Err(_) => {} // Panic caught — acceptable (serde stack overflow) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | #[test] | ||
| 183 | fn null_bytes_in_json_string_returns_error_or_ok() { | ||
| 184 | // Valid Event JSON but with null bytes embedded in string fields. | ||
| 185 | // serde_json accepts null bytes in strings, so this may succeed. Either is acceptable. | ||
| 186 | 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}"#; | ||
| 187 | let (_tmp, repo, ref_name) = repo_with_blob(json.as_bytes()); | ||
| 188 | let result = dag::walk_events(&repo, &ref_name); | ||
| 189 | // Either Ok or Err is acceptable; the key requirement is no panic. | ||
| 190 | let _ = result; | ||
| 191 | } | ||
| 192 | |||
| 193 | // =========================================================================== | ||
| 194 | // Phase 4: User Story 2 — Corrupted Git Tree Structures | ||
| 195 | // =========================================================================== | ||
| 196 | |||
| 197 | #[test] | ||
| 198 | fn missing_event_json_entry_returns_error() { | ||
| 199 | // Create a commit with an empty tree (no event.json entry) | ||
| 200 | let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| { | ||
| 201 | let tb = repo.treebuilder(None).unwrap(); | ||
| 202 | tb.write().unwrap() | ||
| 203 | }); | ||
| 204 | let result = dag::walk_events(&repo, &ref_name); | ||
| 205 | assert!(result.is_err(), "missing event.json should return Err"); | ||
| 206 | let err_msg = format!("{}", result.unwrap_err()); | ||
| 207 | assert!( | ||
| 208 | err_msg.contains("missing event.json"), | ||
| 209 | "error should mention missing event.json, got: {}", | ||
| 210 | err_msg | ||
| 211 | ); | ||
| 212 | } | ||
| 213 | |||
| 214 | #[test] | ||
| 215 | fn event_json_points_to_tree_returns_error() { | ||
| 216 | // Create a commit where event.json entry points to a tree object instead of a blob | ||
| 217 | let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| { | ||
| 218 | // Create an inner empty tree | ||
| 219 | let inner_tb = repo.treebuilder(None).unwrap(); | ||
| 220 | let inner_tree_oid = inner_tb.write().unwrap(); | ||
| 221 | // Insert it as "event.json" but pointing to a tree | ||
| 222 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 223 | tb.insert("event.json", inner_tree_oid, 0o040000).unwrap(); | ||
| 224 | tb.write().unwrap() | ||
| 225 | }); | ||
| 226 | let result = dag::walk_events(&repo, &ref_name); | ||
| 227 | assert!( | ||
| 228 | result.is_err(), | ||
| 229 | "event.json pointing to tree should return Err" | ||
| 230 | ); | ||
| 231 | } | ||
| 232 | |||
| 233 | #[test] | ||
| 234 | fn extra_entries_in_tree_still_works() { | ||
| 235 | // Create a commit with event.json plus extra entries — should still parse OK | ||
| 236 | let json = valid_event_json(); | ||
| 237 | let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| { | ||
| 238 | let blob_oid = repo.blob(json.as_bytes()).unwrap(); | ||
| 239 | |||
| 240 | let extra = repo.blob(b"extra content").unwrap(); | ||
| 241 | let extra2 = repo.blob(b"more stuff").unwrap(); | ||
| 242 | let manifest = repo | ||
| 243 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 244 | .unwrap(); | ||
| 245 | |||
| 246 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 247 | tb.insert("event.json", blob_oid, 0o100644).unwrap(); | ||
| 248 | tb.insert("README.md", extra, 0o100644).unwrap(); | ||
| 249 | tb.insert("random.txt", extra2, 0o100644).unwrap(); | ||
| 250 | tb.insert("manifest.json", manifest, 0o100644).unwrap(); | ||
| 251 | tb.write().unwrap() | ||
| 252 | }); | ||
| 253 | let result = dag::walk_events(&repo, &ref_name); | ||
| 254 | assert!( | ||
| 255 | result.is_ok(), | ||
| 256 | "extra entries should not prevent parsing: {:?}", | ||
| 257 | result.err() | ||
| 258 | ); | ||
| 259 | let events = result.unwrap(); | ||
| 260 | assert_eq!(events.len(), 1); | ||
| 261 | } | ||
| 262 | |||
| 263 | #[test] | ||
| 264 | fn dag_with_one_corrupted_commit_in_middle() { | ||
| 265 | // Create a 3-commit chain where the middle commit has invalid event.json | ||
| 266 | let tmp = TempDir::new().unwrap(); | ||
| 267 | let repo = init_repo(tmp.path(), &alice()); | ||
| 268 | let sk = common::test_signing_key(); | ||
| 269 | |||
| 270 | // Commit 1: valid | ||
| 271 | let event1 = Event { | ||
| 272 | timestamp: now(), | ||
| 273 | author: alice(), | ||
| 274 | action: Action::IssueOpen { | ||
| 275 | title: "Test".to_string(), | ||
| 276 | body: "Body".to_string(), | ||
| 277 | }, | ||
| 278 | clock: 0, | ||
| 279 | }; | ||
| 280 | let oid1 = dag::create_root_event(&repo, &event1, &sk).unwrap(); | ||
| 281 | let ref_name = format!("refs/collab/issues/{}", oid1); | ||
| 282 | repo.reference(&ref_name, oid1, false, "test").unwrap(); | ||
| 283 | |||
| 284 | // Commit 2: corrupted (invalid JSON) | ||
| 285 | let bad_blob = repo.blob(b"not json").unwrap(); | ||
| 286 | let manifest_blob = repo | ||
| 287 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 288 | .unwrap(); | ||
| 289 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 290 | tb.insert("event.json", bad_blob, 0o100644).unwrap(); | ||
| 291 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 292 | let tree_oid = tb.write().unwrap(); | ||
| 293 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 294 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 295 | let parent1 = repo.find_commit(oid1).unwrap(); | ||
| 296 | let oid2 = repo | ||
| 297 | .commit(None, &sig, &sig, "bad commit", &tree, &[&parent1]) | ||
| 298 | .unwrap(); | ||
| 299 | |||
| 300 | // Commit 3: valid (child of corrupted) | ||
| 301 | let good_blob = repo.blob(valid_event_json().as_bytes()).unwrap(); | ||
| 302 | let manifest_blob2 = repo | ||
| 303 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 304 | .unwrap(); | ||
| 305 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 306 | tb.insert("event.json", good_blob, 0o100644).unwrap(); | ||
| 307 | tb.insert("manifest.json", manifest_blob2, 0o100644).unwrap(); | ||
| 308 | let tree_oid = tb.write().unwrap(); | ||
| 309 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 310 | let parent2 = repo.find_commit(oid2).unwrap(); | ||
| 311 | let oid3 = repo | ||
| 312 | .commit(None, &sig, &sig, "good commit 3", &tree, &[&parent2]) | ||
| 313 | .unwrap(); | ||
| 314 | |||
| 315 | // Update ref to point to tip | ||
| 316 | repo.reference(&ref_name, oid3, true, "update tip").unwrap(); | ||
| 317 | |||
| 318 | let result = dag::walk_events(&repo, &ref_name); | ||
| 319 | assert!( | ||
| 320 | result.is_err(), | ||
| 321 | "DAG with corrupted middle commit should return Err" | ||
| 322 | ); | ||
| 323 | } | ||
| 324 | |||
| 325 | // =========================================================================== | ||
| 326 | // Phase 5: User Story 3 — Oversized Payload Rejection | ||
| 327 | // =========================================================================== | ||
| 328 | |||
| 329 | #[test] | ||
| 330 | fn oversized_blob_returns_error() { | ||
| 331 | // 2 MB blob | ||
| 332 | let content = vec![b' '; 2 * 1024 * 1024]; | ||
| 333 | let (_tmp, repo, ref_name) = repo_with_blob(&content); | ||
| 334 | let result = dag::walk_events(&repo, &ref_name); | ||
| 335 | assert!(result.is_err(), "2 MB blob should return Err"); | ||
| 336 | let err_msg = format!("{}", result.unwrap_err()); | ||
| 337 | assert!( | ||
| 338 | err_msg.contains("exceeds") || err_msg.contains("too large") || err_msg.contains("limit"), | ||
| 339 | "error should mention size limit, got: {}", | ||
| 340 | err_msg | ||
| 341 | ); | ||
| 342 | } | ||
| 343 | |||
| 344 | #[test] | ||
| 345 | fn blob_at_limit_succeeds() { | ||
| 346 | // Build a valid event JSON padded to just under 1 MB by making the body field large. | ||
| 347 | let base = valid_event_json(); | ||
| 348 | let padding_needed = MAX_EVENT_BLOB_SIZE - base.len() - 1; // -1 to stay under | ||
| 349 | let body_padding = " ".repeat(padding_needed.saturating_sub(100)); | ||
| 350 | let event = Event { | ||
| 351 | timestamp: now(), | ||
| 352 | author: alice(), | ||
| 353 | action: Action::IssueOpen { | ||
| 354 | title: "Test".to_string(), | ||
| 355 | body: body_padding, | ||
| 356 | }, | ||
| 357 | clock: 1, | ||
| 358 | }; | ||
| 359 | let padded = serde_json::to_string(&event).unwrap(); | ||
| 360 | // Ensure we're under the limit | ||
| 361 | assert!( | ||
| 362 | padded.len() <= MAX_EVENT_BLOB_SIZE, | ||
| 363 | "padded event is {} bytes, limit is {}", | ||
| 364 | padded.len(), | ||
| 365 | MAX_EVENT_BLOB_SIZE | ||
| 366 | ); | ||
| 367 | |||
| 368 | let (_tmp, repo, ref_name) = repo_with_blob(padded.as_bytes()); | ||
| 369 | let result = dag::walk_events(&repo, &ref_name); | ||
| 370 | assert!( | ||
| 371 | result.is_ok(), | ||
| 372 | "blob at/under limit should succeed: {:?}", | ||
| 373 | result.err() | ||
| 374 | ); | ||
| 375 | } | ||
| 376 | |||
| 377 | #[test] | ||
| 378 | fn blob_just_over_limit_returns_error() { | ||
| 379 | // Exactly 1,048,577 bytes (MAX_EVENT_BLOB_SIZE + 1) | ||
| 380 | let content = vec![b'x'; MAX_EVENT_BLOB_SIZE + 1]; | ||
| 381 | let (_tmp, repo, ref_name) = repo_with_blob(&content); | ||
| 382 | let result = dag::walk_events(&repo, &ref_name); | ||
| 383 | assert!( | ||
| 384 | result.is_err(), | ||
| 385 | "blob just over limit should return Err" | ||
| 386 | ); | ||
| 387 | } | ||
| 388 | |||
| 389 | // =========================================================================== | ||
| 390 | // Phase 6: User Story 4 — Malicious Ref Name Validation | ||
| 391 | // =========================================================================== | ||
| 392 | |||
| 393 | #[test] | ||
| 394 | fn ref_id_with_path_traversal_rejected() { | ||
| 395 | let result = validate_collab_ref_id("../../HEAD"); | ||
| 396 | assert!(result.is_err(), "path traversal should be rejected"); | ||
| 397 | } | ||
| 398 | |||
| 399 | #[test] | ||
| 400 | fn ref_id_with_null_bytes_rejected() { | ||
| 401 | let result = validate_collab_ref_id("abc\0def"); | ||
| 402 | assert!(result.is_err(), "null bytes should be rejected"); | ||
| 403 | } | ||
| 404 | |||
| 405 | #[test] | ||
| 406 | fn ref_id_with_control_chars_rejected() { | ||
| 407 | for ch in &['\n', '\r', '\t'] { | ||
| 408 | let id = format!("abcdef1234567890abcdef1234567890abcdef1{}", ch); | ||
| 409 | let result = validate_collab_ref_id(&id); | ||
| 410 | assert!( | ||
| 411 | result.is_err(), | ||
| 412 | "control char {:?} should be rejected", | ||
| 413 | ch | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | } | ||
| 417 | |||
| 418 | #[test] | ||
| 419 | fn ref_id_non_hex_rejected() { | ||
| 420 | let result = validate_collab_ref_id("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"); | ||
| 421 | assert!(result.is_err(), "non-hex characters should be rejected"); | ||
| 422 | } | ||
| 423 | |||
| 424 | #[test] | ||
| 425 | fn ref_id_wrong_length_rejected() { | ||
| 426 | // Too short | ||
| 427 | let result = validate_collab_ref_id("abcdef"); | ||
| 428 | assert!(result.is_err(), "6-char ID should be rejected"); | ||
| 429 | |||
| 430 | // Too long | ||
| 431 | let result = | ||
| 432 | validate_collab_ref_id("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678ab"); | ||
| 433 | assert!(result.is_err(), "80-char ID should be rejected"); | ||
| 434 | } | ||
| 435 | |||
| 436 | #[test] | ||
| 437 | fn valid_hex_oid_accepted() { | ||
| 438 | let result = validate_collab_ref_id("abcdef1234567890abcdef1234567890abcdef12"); | ||
| 439 | assert!( | ||
| 440 | result.is_ok(), | ||
| 441 | "valid 40-char hex OID should be accepted: {:?}", | ||
| 442 | result.err() | ||
| 443 | ); | ||
| 444 | } | ||
| 445 | |||
| 446 | #[test] | ||
| 447 | fn ref_id_uppercase_hex_rejected() { | ||
| 448 | // Uppercase hex should be rejected (we require lowercase) | ||
| 449 | let result = validate_collab_ref_id("ABCDEF1234567890ABCDEF1234567890ABCDEF12"); | ||
| 450 | assert!(result.is_err(), "uppercase hex should be rejected"); | ||
| 451 | } | ||
| 452 | |||
| 453 | #[test] | ||
| 454 | fn malformed_remote_event_no_local_state_change() { | ||
| 455 | // FR-010: corrupted remote event must not modify local ref | ||
| 456 | let tmp = TempDir::new().unwrap(); | ||
| 457 | let repo = init_repo(tmp.path(), &alice()); | ||
| 458 | let sk = common::test_signing_key(); | ||
| 459 | |||
| 460 | // Create a valid issue | ||
| 461 | let event = Event { | ||
| 462 | timestamp: now(), | ||
| 463 | author: alice(), | ||
| 464 | action: Action::IssueOpen { | ||
| 465 | title: "Original".to_string(), | ||
| 466 | body: "Body".to_string(), | ||
| 467 | }, | ||
| 468 | clock: 0, | ||
| 469 | }; | ||
| 470 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 471 | let local_ref = format!("refs/collab/issues/{}", oid); | ||
| 472 | repo.reference(&local_ref, oid, false, "test").unwrap(); | ||
| 473 | |||
| 474 | // Record the OID the local ref points to | ||
| 475 | let original_oid = repo.refname_to_id(&local_ref).unwrap(); | ||
| 476 | |||
| 477 | // Create a corrupted "remote" commit (bad JSON) | ||
| 478 | let bad_blob = repo.blob(b"corrupted json!!!").unwrap(); | ||
| 479 | let manifest_blob = repo | ||
| 480 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 481 | .unwrap(); | ||
| 482 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 483 | tb.insert("event.json", bad_blob, 0o100644).unwrap(); | ||
| 484 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 485 | let tree_oid = tb.write().unwrap(); | ||
| 486 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 487 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 488 | let corrupted_oid = repo | ||
| 489 | .commit(None, &sig, &sig, "corrupted", &tree, &[]) | ||
| 490 | .unwrap(); | ||
| 491 | |||
| 492 | // Point a "remote" ref to the corrupted commit | ||
| 493 | let remote_ref = format!("refs/collab/sync/issues/{}", oid); | ||
| 494 | repo.reference(&remote_ref, corrupted_oid, false, "fake remote") | ||
| 495 | .unwrap(); | ||
| 496 | |||
| 497 | // Try to reconcile — this should fail because walk_events on the remote | ||
| 498 | // will fail when trying to read the corrupted event | ||
| 499 | let reconcile_result = dag::reconcile(&repo, &local_ref, &remote_ref, &alice(), &sk); | ||
| 500 | // The reconcile may or may not fail depending on whether it needs to walk events | ||
| 501 | // (it only walks events for merge commits, not fast-forward). The key assertion is: | ||
| 502 | |||
| 503 | // Verify local ref still points to the original OID | ||
| 504 | let current_oid = repo.refname_to_id(&local_ref).unwrap(); | ||
| 505 | assert_eq!( | ||
| 506 | original_oid, current_oid, | ||
| 507 | "local ref must not change after encountering corrupted remote event" | ||
| 508 | ); | ||
| 509 | // Suppress unused variable warning | ||
| 510 | let _ = reconcile_result; | ||
| 511 | } | ||
| 512 | |||
| 513 | // =========================================================================== | ||
| 514 | // Phase 7: User Story 5 — Property-Based Testing | ||
| 515 | // =========================================================================== | ||
| 516 | |||
| 517 | use proptest::prelude::*; | ||
| 518 | |||
| 519 | /// Generate an arbitrary Author | ||
| 520 | fn arb_author() -> impl Strategy<Value = Author> { | ||
| 521 | ("[a-zA-Z0-9 ]{0,50}", "[a-zA-Z0-9@.]{0,50}").prop_map(|(name, email)| Author { | ||
| 522 | name, | ||
| 523 | email, | ||
| 524 | }) | ||
| 525 | } | ||
| 526 | |||
| 527 | /// Generate an arbitrary Action variant | ||
| 528 | fn arb_action() -> impl Strategy<Value = Action> { | ||
| 529 | prop_oneof![ | ||
| 530 | (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body }), | ||
| 531 | ".*".prop_map(|body| Action::IssueComment { body }), | ||
| 532 | proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }), | ||
| 533 | Just(Action::IssueReopen), | ||
| 534 | (".*", ".*", ".*", ".*").prop_map(|(title, body, base_ref, branch)| { | ||
| 535 | Action::PatchCreate { | ||
| 536 | title, | ||
| 537 | body, | ||
| 538 | base_ref, | ||
| 539 | branch, | ||
| 540 | fixes: None, | ||
| 541 | } | ||
| 542 | }), | ||
| 543 | proptest::option::of(".*").prop_map(|body| Action::PatchRevise { body }), | ||
| 544 | (".*",).prop_map(|(body,)| Action::PatchComment { body }), | ||
| 545 | Just(Action::PatchMerge), | ||
| 546 | Just(Action::Merge), | ||
| 547 | ] | ||
| 548 | } | ||
| 549 | |||
| 550 | /// Generate an arbitrary Event | ||
| 551 | fn arb_event() -> impl Strategy<Value = Event> { | ||
| 552 | (arb_author(), arb_action(), 0u64..1000).prop_map(|(author, action, clock)| Event { | ||
| 553 | timestamp: "2024-01-01T00:00:00Z".to_string(), | ||
| 554 | author, | ||
| 555 | action, | ||
| 556 | clock, | ||
| 557 | }) | ||
| 558 | } | ||
| 559 | |||
| 560 | proptest! { | ||
| 561 | #[test] | ||
| 562 | fn event_roundtrip_never_panics(event in arb_event()) { | ||
| 563 | // Serialize to JSON, deserialize back. Must not panic. | ||
| 564 | let json = serde_json::to_vec(&event).unwrap(); | ||
| 565 | let _result: Result<Event, _> = serde_json::from_slice(&json); | ||
| 566 | // Ok or Err both acceptable; no panic is the requirement. | ||
| 567 | } | ||
| 568 | |||
| 569 | #[test] | ||
| 570 | fn arbitrary_bytes_never_panic_parser(data in proptest::collection::vec(any::<u8>(), 0..1024)) { | ||
| 571 | // Arbitrary bytes passed to the Event parser must not panic. | ||
| 572 | let _result: Result<Event, _> = serde_json::from_slice(&data); | ||
| 573 | } | ||
| 574 | |||
| 575 | #[test] | ||
| 576 | fn walk_events_with_arbitrary_blob_never_panics(data in proptest::collection::vec(any::<u8>(), 0..4096)) { | ||
| 577 | // Create a commit with arbitrary blob content and walk it. | ||
| 578 | let tmp = TempDir::new().unwrap(); | ||
| 579 | let repo = init_repo(tmp.path(), &alice()); | ||
| 580 | |||
| 581 | let blob_oid = repo.blob(&data).unwrap(); | ||
| 582 | let manifest_blob = repo.blob(br#"{"version":1,"format":"git-collab"}"#).unwrap(); | ||
| 583 | |||
| 584 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 585 | tb.insert("event.json", blob_oid, 0o100644).unwrap(); | ||
| 586 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 587 | let tree_oid = tb.write().unwrap(); | ||
| 588 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 589 | |||
| 590 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 591 | let oid = repo.commit(None, &sig, &sig, "fuzz test", &tree, &[]).unwrap(); | ||
| 592 | let ref_name = format!("refs/collab/issues/{}", oid); | ||
| 593 | repo.reference(&ref_name, oid, false, "test").unwrap(); | ||
| 594 | |||
| 595 | // Must not panic — Ok or Err both acceptable | ||
| 596 | let _result = dag::walk_events(&repo, &ref_name); | ||
| 597 | } | ||
| 598 | |||
| 599 | #[test] | ||
| 600 | fn validate_ref_id_never_panics(id in ".*") { | ||
| 601 | // Arbitrary string passed to validate_collab_ref_id must not panic. | ||
| 602 | let _result = validate_collab_ref_id(&id); | ||
| 603 | } | ||
| 604 | } | ||
| 605 | |||
| 606 | // =========================================================================== | ||
| 607 | // Phase 8: Polish & Cross-Cutting | ||
| 608 | // =========================================================================== | ||
| 609 | |||
| 610 | #[test] | ||
| 611 | fn timestamp_not_rfc3339_accepted_by_serde() { | ||
| 612 | // timestamp is just a String in the Event struct, not validated as RFC3339. | ||
| 613 | // This documents that non-RFC3339 timestamps are accepted. | ||
| 614 | let json = br#"{"timestamp":"not-a-date","author":{"name":"a","email":"e"},"action":{"type":"issue.open","title":"t","body":"b"},"clock":1}"#; | ||
| 615 | let result: Result<Event, _> = serde_json::from_slice(json); | ||
| 616 | assert!( | ||
| 617 | result.is_ok(), | ||
| 618 | "non-RFC3339 timestamp should be accepted by serde (it's just a String): {:?}", | ||
| 619 | result.err() | ||
| 620 | ); | ||
| 621 | assert_eq!(result.unwrap().timestamp, "not-a-date"); | ||
| 622 | } | ||
| 623 | |||
| 624 | #[test] | ||
| 625 | fn merge_commit_with_one_corrupted_parent() { | ||
| 626 | // Create a DAG with a merge commit where one parent branch has a corrupted event. | ||
| 627 | let tmp = TempDir::new().unwrap(); | ||
| 628 | let repo = init_repo(tmp.path(), &alice()); | ||
| 629 | |||
| 630 | // Branch 1: valid event | ||
| 631 | let valid_json = valid_event_json(); | ||
| 632 | let valid_blob = repo.blob(valid_json.as_bytes()).unwrap(); | ||
| 633 | let manifest_blob = repo | ||
| 634 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 635 | .unwrap(); | ||
| 636 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 637 | tb.insert("event.json", valid_blob, 0o100644).unwrap(); | ||
| 638 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 639 | let tree_oid = tb.write().unwrap(); | ||
| 640 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 641 | let sig = git2::Signature::now("Test", "test@example.com").unwrap(); | ||
| 642 | let valid_oid = repo | ||
| 643 | .commit(None, &sig, &sig, "valid branch", &tree, &[]) | ||
| 644 | .unwrap(); | ||
| 645 | |||
| 646 | // Branch 2: corrupted event | ||
| 647 | let bad_blob = repo.blob(b"not json at all").unwrap(); | ||
| 648 | let manifest_blob2 = repo | ||
| 649 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 650 | .unwrap(); | ||
| 651 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 652 | tb.insert("event.json", bad_blob, 0o100644).unwrap(); | ||
| 653 | tb.insert("manifest.json", manifest_blob2, 0o100644).unwrap(); | ||
| 654 | let tree_oid = tb.write().unwrap(); | ||
| 655 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 656 | let corrupted_oid = repo | ||
| 657 | .commit(None, &sig, &sig, "corrupted branch", &tree, &[]) | ||
| 658 | .unwrap(); | ||
| 659 | |||
| 660 | // Merge commit (valid event JSON but parents include corrupted branch) | ||
| 661 | let merge_json = serde_json::to_string(&Event { | ||
| 662 | timestamp: now(), | ||
| 663 | author: alice(), | ||
| 664 | action: Action::Merge, | ||
| 665 | clock: 2, | ||
| 666 | }) | ||
| 667 | .unwrap(); | ||
| 668 | let merge_blob = repo.blob(merge_json.as_bytes()).unwrap(); | ||
| 669 | let manifest_blob3 = repo | ||
| 670 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 671 | .unwrap(); | ||
| 672 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 673 | tb.insert("event.json", merge_blob, 0o100644).unwrap(); | ||
| 674 | tb.insert("manifest.json", manifest_blob3, 0o100644).unwrap(); | ||
| 675 | let tree_oid = tb.write().unwrap(); | ||
| 676 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 677 | |||
| 678 | let valid_commit = repo.find_commit(valid_oid).unwrap(); | ||
| 679 | let corrupted_commit = repo.find_commit(corrupted_oid).unwrap(); | ||
| 680 | let merge_oid = repo | ||
| 681 | .commit( | ||
| 682 | None, | ||
| 683 | &sig, | ||
| 684 | &sig, | ||
| 685 | "merge commit", | ||
| 686 | &tree, | ||
| 687 | &[&valid_commit, &corrupted_commit], | ||
| 688 | ) | ||
| 689 | .unwrap(); | ||
| 690 | |||
| 691 | let ref_name = format!("refs/collab/issues/{}", merge_oid); | ||
| 692 | repo.reference(&ref_name, merge_oid, false, "test").unwrap(); | ||
| 693 | |||
| 694 | let result = dag::walk_events(&repo, &ref_name); | ||
| 695 | assert!( | ||
| 696 | result.is_err(), | ||
| 697 | "merge with one corrupted parent should return Err" | ||
| 698 | ); | ||
| 699 | } | ||
tests/collab_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,7 @@ use tempfile::TempDir; | |||
| 5 | use git_collab::dag; | 5 | use git_collab::dag; |
| 6 | use git_collab::error::Error; | 6 | use git_collab::error::Error; |
| 7 | use git_collab::event::{Action, Author, Event, ReviewVerdict}; | 7 | use git_collab::event::{Action, Author, Event, ReviewVerdict}; |
| 8 | use git_collab::signing::{self, SignedEvent, VerifyStatus}; | 8 | use git_collab::signing::{self, DetachedSignature, VerifyStatus}; |
| 9 | use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 9 | use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 10 | 10 | ||
| 11 | use common::{ | 11 | use common::{ |
| @@ -95,6 +95,7 @@ fn test_issue_edit_updates_title_and_body() { | |||
| 95 | title: Some("New title".to_string()), | 95 | title: Some("New title".to_string()), |
| 96 | body: Some("New body".to_string()), | 96 | body: Some("New body".to_string()), |
| 97 | }, | 97 | }, |
| 98 | clock: 0, | ||
| 98 | }; | 99 | }; |
| 99 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | 100 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); |
| 100 | 101 | ||
| @@ -119,6 +120,7 @@ fn test_issue_edit_partial_update() { | |||
| 119 | title: None, | 120 | title: None, |
| 120 | body: Some("Added body".to_string()), | 121 | body: Some("Added body".to_string()), |
| 121 | }, | 122 | }, |
| 123 | clock: 0, | ||
| 122 | }; | 124 | }; |
| 123 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | 125 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); |
| 124 | 126 | ||
| @@ -329,6 +331,7 @@ fn test_patch_review_workflow() { | |||
| 329 | action: Action::PatchRevise { | 331 | action: Action::PatchRevise { |
| 330 | body: Some("Updated implementation".to_string()), | 332 | body: Some("Updated implementation".to_string()), |
| 331 | }, | 333 | }, |
| 334 | clock: 0, | ||
| 332 | }; | 335 | }; |
| 333 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); | 336 | dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); |
| 334 | 337 | ||
| @@ -504,26 +507,47 @@ fn test_signed_event_in_dag() { | |||
| 504 | title: "Signed issue".to_string(), | 507 | title: "Signed issue".to_string(), |
| 505 | body: "".to_string(), | 508 | body: "".to_string(), |
| 506 | }, | 509 | }, |
| 510 | clock: 0, | ||
| 507 | }; | 511 | }; |
| 508 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | 512 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); |
| 509 | let id = oid.to_string(); | 513 | let id = oid.to_string(); |
| 510 | let ref_name = format!("refs/collab/issues/{}", id); | 514 | let ref_name = format!("refs/collab/issues/{}", id); |
| 511 | repo.reference(&ref_name, oid, false, "test open").unwrap(); | 515 | repo.reference(&ref_name, oid, false, "test open").unwrap(); |
| 512 | 516 | ||
| 513 | // Read the raw blob from the commit and deserialize as SignedEvent | 517 | // Read the raw blobs from the commit tree |
| 514 | let tip = repo.refname_to_id(&ref_name).unwrap(); | 518 | let tip = repo.refname_to_id(&ref_name).unwrap(); |
| 515 | let commit = repo.find_commit(tip).unwrap(); | 519 | let commit = repo.find_commit(tip).unwrap(); |
| 516 | let tree = commit.tree().unwrap(); | 520 | let tree = commit.tree().unwrap(); |
| 517 | let entry = tree.get_name("event.json").unwrap(); | ||
| 518 | let blob = repo.find_blob(entry.id()).unwrap(); | ||
| 519 | let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap(); | ||
| 520 | 521 | ||
| 521 | // Assert signature and pubkey are present | 522 | // event.json should be a plain Event (no signature fields) |
| 522 | assert!(!signed.signature.is_empty(), "signature should be present"); | 523 | let event_entry = tree.get_name("event.json").unwrap(); |
| 523 | assert!(!signed.pubkey.is_empty(), "pubkey should be present"); | 524 | let event_blob = repo.find_blob(event_entry.id()).unwrap(); |
| 524 | 525 | let event: Event = serde_json::from_slice(event_blob.content()).unwrap(); | |
| 525 | // Verify the signature | 526 | assert!(matches!(event.action, Action::IssueOpen { .. })); |
| 526 | let status = signing::verify_signed_event(&signed).unwrap(); | 527 | |
| 528 | // signature and pubkey should be separate blobs | ||
| 529 | let sig_entry = tree.get_name("signature").expect("signature blob should exist"); | ||
| 530 | let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist"); | ||
| 531 | let sig_blob = repo.find_blob(sig_entry.id()).unwrap(); | ||
| 532 | let pk_blob = repo.find_blob(pk_entry.id()).unwrap(); | ||
| 533 | let sig_str = std::str::from_utf8(sig_blob.content()).unwrap(); | ||
| 534 | let pk_str = std::str::from_utf8(pk_blob.content()).unwrap(); | ||
| 535 | assert!(!sig_str.is_empty(), "signature should be present"); | ||
| 536 | assert!(!pk_str.is_empty(), "pubkey should be present"); | ||
| 537 | |||
| 538 | // manifest.json should exist | ||
| 539 | let manifest_entry = tree.get_name("manifest.json").expect("manifest.json should exist"); | ||
| 540 | let manifest_blob = repo.find_blob(manifest_entry.id()).unwrap(); | ||
| 541 | let manifest_str = std::str::from_utf8(manifest_blob.content()).unwrap(); | ||
| 542 | assert!(manifest_str.contains("\"version\":1"), "manifest should contain version"); | ||
| 543 | assert!(manifest_str.contains("\"format\":\"git-collab\""), "manifest should contain format"); | ||
| 544 | |||
| 545 | // Verify the detached signature | ||
| 546 | let detached = DetachedSignature { | ||
| 547 | signature: sig_str.to_string(), | ||
| 548 | pubkey: pk_str.to_string(), | ||
| 549 | }; | ||
| 550 | let status = signing::verify_detached(&event, &detached).unwrap(); | ||
| 527 | assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid"); | 551 | assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid"); |
| 528 | 552 | ||
| 529 | // walk_events should still extract the Event correctly | 553 | // walk_events should still extract the Event correctly |
| @@ -609,6 +633,7 @@ fn create_branch_patch( | |||
| 609 | branch: branch.to_string(), | 633 | branch: branch.to_string(), |
| 610 | fixes: None, | 634 | fixes: None, |
| 611 | }, | 635 | }, |
| 636 | clock: 0, | ||
| 612 | }; | 637 | }; |
| 613 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | 638 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); |
| 614 | let id = oid.to_string(); | 639 | let id = oid.to_string(); |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -66,6 +66,7 @@ pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, S | |||
| 66 | title: title.to_string(), | 66 | title: title.to_string(), |
| 67 | body: "".to_string(), | 67 | body: "".to_string(), |
| 68 | }, | 68 | }, |
| 69 | clock: 0, | ||
| 69 | }; | 70 | }; |
| 70 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | 71 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); |
| 71 | let id = oid.to_string(); | 72 | let id = oid.to_string(); |
| @@ -83,6 +84,7 @@ pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &st | |||
| 83 | action: Action::IssueComment { | 84 | action: Action::IssueComment { |
| 84 | body: body.to_string(), | 85 | body: body.to_string(), |
| 85 | }, | 86 | }, |
| 87 | clock: 0, | ||
| 86 | }; | 88 | }; |
| 87 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); | 89 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); |
| 88 | } | 90 | } |
| @@ -94,6 +96,7 @@ pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) { | |||
| 94 | timestamp: now(), | 96 | timestamp: now(), |
| 95 | author: author.clone(), | 97 | author: author.clone(), |
| 96 | action: Action::IssueClose { reason: None }, | 98 | action: Action::IssueClose { reason: None }, |
| 99 | clock: 0, | ||
| 97 | }; | 100 | }; |
| 98 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); | 101 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); |
| 99 | } | 102 | } |
| @@ -105,6 +108,7 @@ pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) { | |||
| 105 | timestamp: now(), | 108 | timestamp: now(), |
| 106 | author: author.clone(), | 109 | author: author.clone(), |
| 107 | action: Action::IssueReopen, | 110 | action: Action::IssueReopen, |
| 111 | clock: 0, | ||
| 108 | }; | 112 | }; |
| 109 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); | 113 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); |
| 110 | } | 114 | } |
| @@ -122,6 +126,7 @@ pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, | |||
| 122 | branch: "test-branch".to_string(), | 126 | branch: "test-branch".to_string(), |
| 123 | fixes: None, | 127 | fixes: None, |
| 124 | }, | 128 | }, |
| 129 | clock: 0, | ||
| 125 | }; | 130 | }; |
| 126 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); | 131 | let oid = dag::create_root_event(repo, &event, &sk).unwrap(); |
| 127 | let id = oid.to_string(); | 132 | let id = oid.to_string(); |
| @@ -140,6 +145,7 @@ pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: R | |||
| 140 | verdict, | 145 | verdict, |
| 141 | body: "review comment".to_string(), | 146 | body: "review comment".to_string(), |
| 142 | }, | 147 | }, |
| 148 | clock: 0, | ||
| 143 | }; | 149 | }; |
| 144 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); | 150 | dag::append_event(repo, ref_name, &event, &sk).unwrap(); |
| 145 | } | 151 | } |
| @@ -272,14 +278,18 @@ impl TestRepo { | |||
| 272 | } | 278 | } |
| 273 | } | 279 | } |
| 274 | 280 | ||
| 275 | /// Create an unsigned event commit (plain Event JSON, no signature fields). | 281 | /// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs). |
| 276 | /// Returns the commit OID. | 282 | /// Returns the commit OID. |
| 277 | pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { | 283 | pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { |
| 278 | let json = serde_json::to_vec_pretty(event).unwrap(); | 284 | let json = serde_json::to_vec_pretty(event).unwrap(); |
| 279 | let blob_oid = repo.blob(&json).unwrap(); | 285 | let blob_oid = repo.blob(&json).unwrap(); |
| 286 | let manifest = br#"{"version":1,"format":"git-collab"}"#; | ||
| 287 | let manifest_blob = repo.blob(manifest).unwrap(); | ||
| 280 | 288 | ||
| 281 | let mut tb = repo.treebuilder(None).unwrap(); | 289 | let mut tb = repo.treebuilder(None).unwrap(); |
| 282 | tb.insert("event.json", blob_oid, 0o100644).unwrap(); | 290 | tb.insert("event.json", blob_oid, 0o100644).unwrap(); |
| 291 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 292 | // No signature or pubkey blobs — this is an unsigned event | ||
| 283 | let tree_oid = tb.write().unwrap(); | 293 | let tree_oid = tb.write().unwrap(); |
| 284 | let tree = repo.find_tree(tree_oid).unwrap(); | 294 | let tree = repo.find_tree(tree_oid).unwrap(); |
| 285 | 295 | ||
| @@ -288,18 +298,27 @@ pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { | |||
| 288 | .unwrap() | 298 | .unwrap() |
| 289 | } | 299 | } |
| 290 | 300 | ||
| 291 | /// Create a tampered event commit: sign the event, then modify the body but keep | 301 | /// Create a tampered event commit: sign the event, then modify the event.json but keep |
| 292 | /// the original signature. Returns the commit OID. | 302 | /// the original signature. Returns the commit OID. |
| 293 | pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid { | 303 | pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid { |
| 294 | let sk = test_signing_key(); | 304 | let sk = test_signing_key(); |
| 295 | let mut signed = signing::sign_event(event, &sk).unwrap(); | 305 | let detached = signing::sign_event(event, &sk).unwrap(); |
| 306 | |||
| 296 | // Tamper with the event content while keeping the original signature | 307 | // Tamper with the event content while keeping the original signature |
| 297 | signed.event.timestamp = "2099-01-01T00:00:00Z".to_string(); | 308 | let mut tampered_event = event.clone(); |
| 298 | let json = serde_json::to_vec_pretty(&signed).unwrap(); | 309 | tampered_event.timestamp = "2099-01-01T00:00:00Z".to_string(); |
| 299 | let blob_oid = repo.blob(&json).unwrap(); | 310 | let json = serde_json::to_vec_pretty(&tampered_event).unwrap(); |
| 311 | let event_blob = repo.blob(&json).unwrap(); | ||
| 312 | let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap(); | ||
| 313 | let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap(); | ||
| 314 | let manifest = br#"{"version":1,"format":"git-collab"}"#; | ||
| 315 | let manifest_blob = repo.blob(manifest).unwrap(); | ||
| 300 | 316 | ||
| 301 | let mut tb = repo.treebuilder(None).unwrap(); | 317 | let mut tb = repo.treebuilder(None).unwrap(); |
| 302 | tb.insert("event.json", blob_oid, 0o100644).unwrap(); | 318 | tb.insert("event.json", event_blob, 0o100644).unwrap(); |
| 319 | tb.insert("signature", sig_blob, 0o100644).unwrap(); | ||
| 320 | tb.insert("pubkey", pubkey_blob, 0o100644).unwrap(); | ||
| 321 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 303 | let tree_oid = tb.write().unwrap(); | 322 | let tree_oid = tb.write().unwrap(); |
| 304 | let tree = repo.find_tree(tree_oid).unwrap(); | 323 | let tree = repo.find_tree(tree_oid).unwrap(); |
| 305 | 324 | ||
tests/crdt_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,576 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use ed25519_dalek::SigningKey; | ||
| 4 | use git2::{Oid, Repository}; | ||
| 5 | use rand_core::OsRng; | ||
| 6 | use tempfile::TempDir; | ||
| 7 | |||
| 8 | use git_collab::dag; | ||
| 9 | use git_collab::event::{Action, Author, Event}; | ||
| 10 | use git_collab::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | ||
| 11 | |||
| 12 | fn alice() -> Author { | ||
| 13 | Author { | ||
| 14 | name: "Alice".to_string(), | ||
| 15 | email: "alice@example.com".to_string(), | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | fn bob() -> Author { | ||
| 20 | Author { | ||
| 21 | name: "Bob".to_string(), | ||
| 22 | email: "bob@example.com".to_string(), | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | fn test_sk() -> SigningKey { | ||
| 27 | SigningKey::generate(&mut OsRng) | ||
| 28 | } | ||
| 29 | |||
| 30 | fn init_repo(dir: &std::path::Path) -> Repository { | ||
| 31 | let repo = Repository::init(dir).unwrap(); | ||
| 32 | { | ||
| 33 | let mut config = repo.config().unwrap(); | ||
| 34 | config.set_str("user.name", "Test").unwrap(); | ||
| 35 | config.set_str("user.email", "test@test.com").unwrap(); | ||
| 36 | } | ||
| 37 | repo | ||
| 38 | } | ||
| 39 | |||
| 40 | fn read_event_clock(repo: &Repository, oid: Oid) -> u64 { | ||
| 41 | let commit = repo.find_commit(oid).unwrap(); | ||
| 42 | let tree = commit.tree().unwrap(); | ||
| 43 | let entry = tree.get_name("event.json").unwrap(); | ||
| 44 | let blob = repo.find_blob(entry.id()).unwrap(); | ||
| 45 | let event: Event = serde_json::from_slice(blob.content()).unwrap(); | ||
| 46 | event.clock | ||
| 47 | } | ||
| 48 | |||
| 49 | // ── Phase 2: Clock propagation tests ───────────────────────────── | ||
| 50 | |||
| 51 | #[test] | ||
| 52 | fn create_root_event_sets_clock_to_1() { | ||
| 53 | let dir = TempDir::new().unwrap(); | ||
| 54 | let repo = init_repo(dir.path()); | ||
| 55 | let sk = test_sk(); | ||
| 56 | |||
| 57 | let event = Event { | ||
| 58 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 59 | author: alice(), | ||
| 60 | action: Action::IssueOpen { | ||
| 61 | title: "test".to_string(), | ||
| 62 | body: "body".to_string(), | ||
| 63 | }, | ||
| 64 | clock: 0, // caller passes 0, DAG should override to 1 | ||
| 65 | }; | ||
| 66 | |||
| 67 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 68 | assert_eq!(read_event_clock(&repo, oid), 1); | ||
| 69 | } | ||
| 70 | |||
| 71 | #[test] | ||
| 72 | fn append_event_increments_clock() { | ||
| 73 | let dir = TempDir::new().unwrap(); | ||
| 74 | let repo = init_repo(dir.path()); | ||
| 75 | let sk = test_sk(); | ||
| 76 | |||
| 77 | let open_event = Event { | ||
| 78 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 79 | author: alice(), | ||
| 80 | action: Action::IssueOpen { | ||
| 81 | title: "test".to_string(), | ||
| 82 | body: "body".to_string(), | ||
| 83 | }, | ||
| 84 | clock: 0, | ||
| 85 | }; | ||
| 86 | |||
| 87 | let root_oid = dag::create_root_event(&repo, &open_event, &sk).unwrap(); | ||
| 88 | let ref_name = format!("refs/collab/issues/{}", root_oid); | ||
| 89 | repo.reference(&ref_name, root_oid, false, "test").unwrap(); | ||
| 90 | |||
| 91 | // First append should get clock=2 | ||
| 92 | let comment_event = Event { | ||
| 93 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 94 | author: alice(), | ||
| 95 | action: Action::IssueComment { | ||
| 96 | body: "comment 1".to_string(), | ||
| 97 | }, | ||
| 98 | clock: 0, | ||
| 99 | }; | ||
| 100 | let oid2 = dag::append_event(&repo, &ref_name, &comment_event, &sk).unwrap(); | ||
| 101 | assert_eq!(read_event_clock(&repo, oid2), 2); | ||
| 102 | |||
| 103 | // Second append should get clock=3 | ||
| 104 | let comment_event2 = Event { | ||
| 105 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 106 | author: alice(), | ||
| 107 | action: Action::IssueComment { | ||
| 108 | body: "comment 2".to_string(), | ||
| 109 | }, | ||
| 110 | clock: 0, | ||
| 111 | }; | ||
| 112 | let oid3 = dag::append_event(&repo, &ref_name, &comment_event2, &sk).unwrap(); | ||
| 113 | assert_eq!(read_event_clock(&repo, oid3), 3); | ||
| 114 | } | ||
| 115 | |||
| 116 | #[test] | ||
| 117 | fn max_clock_returns_highest_clock_in_dag() { | ||
| 118 | let dir = TempDir::new().unwrap(); | ||
| 119 | let repo = init_repo(dir.path()); | ||
| 120 | let sk = test_sk(); | ||
| 121 | |||
| 122 | let event = Event { | ||
| 123 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 124 | author: alice(), | ||
| 125 | action: Action::IssueOpen { | ||
| 126 | title: "test".to_string(), | ||
| 127 | body: "body".to_string(), | ||
| 128 | }, | ||
| 129 | clock: 0, | ||
| 130 | }; | ||
| 131 | |||
| 132 | let root_oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 133 | let ref_name = format!("refs/collab/issues/{}", root_oid); | ||
| 134 | repo.reference(&ref_name, root_oid, false, "test").unwrap(); | ||
| 135 | |||
| 136 | assert_eq!(dag::max_clock(&repo, root_oid).unwrap(), 1); | ||
| 137 | |||
| 138 | let comment = Event { | ||
| 139 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 140 | author: alice(), | ||
| 141 | action: Action::IssueComment { | ||
| 142 | body: "comment".to_string(), | ||
| 143 | }, | ||
| 144 | clock: 0, | ||
| 145 | }; | ||
| 146 | let tip = dag::append_event(&repo, &ref_name, &comment, &sk).unwrap(); | ||
| 147 | assert_eq!(dag::max_clock(&repo, tip).unwrap(), 2); | ||
| 148 | } | ||
| 149 | |||
| 150 | // ── Phase 3: Reconcile merge clock test ────────────────────────── | ||
| 151 | |||
| 152 | #[test] | ||
| 153 | fn reconcile_merge_clock_is_max_of_both_branches_plus_one() { | ||
| 154 | let dir = TempDir::new().unwrap(); | ||
| 155 | let repo = init_repo(dir.path()); | ||
| 156 | let sk = test_sk(); | ||
| 157 | |||
| 158 | // Create root event (clock=1) | ||
| 159 | let open = Event { | ||
| 160 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 161 | author: alice(), | ||
| 162 | action: Action::IssueOpen { | ||
| 163 | title: "test".to_string(), | ||
| 164 | body: "body".to_string(), | ||
| 165 | }, | ||
| 166 | clock: 0, | ||
| 167 | }; | ||
| 168 | let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap(); | ||
| 169 | let local_ref = "refs/collab/issues/test-reconcile"; | ||
| 170 | let remote_ref = "refs/collab/sync/issues/test-reconcile"; | ||
| 171 | repo.reference(local_ref, root_oid, false, "test").unwrap(); | ||
| 172 | repo.reference(remote_ref, root_oid, false, "test").unwrap(); | ||
| 173 | |||
| 174 | // Append 2 events on local (clock=2,3) | ||
| 175 | let comment1 = Event { | ||
| 176 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 177 | author: alice(), | ||
| 178 | action: Action::IssueComment { | ||
| 179 | body: "local 1".to_string(), | ||
| 180 | }, | ||
| 181 | clock: 0, | ||
| 182 | }; | ||
| 183 | dag::append_event(&repo, local_ref, &comment1, &sk).unwrap(); | ||
| 184 | let comment2 = Event { | ||
| 185 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 186 | author: alice(), | ||
| 187 | action: Action::IssueComment { | ||
| 188 | body: "local 2".to_string(), | ||
| 189 | }, | ||
| 190 | clock: 0, | ||
| 191 | }; | ||
| 192 | dag::append_event(&repo, local_ref, &comment2, &sk).unwrap(); | ||
| 193 | |||
| 194 | // Append 4 events on remote (clock=2,3,4,5) | ||
| 195 | for i in 1..=4 { | ||
| 196 | let comment = Event { | ||
| 197 | timestamp: format!("2026-02-{:02}T00:00:00Z", i), | ||
| 198 | author: bob(), | ||
| 199 | action: Action::IssueComment { | ||
| 200 | body: format!("remote {}", i), | ||
| 201 | }, | ||
| 202 | clock: 0, | ||
| 203 | }; | ||
| 204 | dag::append_event(&repo, remote_ref, &comment, &sk).unwrap(); | ||
| 205 | } | ||
| 206 | |||
| 207 | let merge_oid = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); | ||
| 208 | // Remote max is 5, local max is 3, so merge should be 6 | ||
| 209 | assert_eq!(read_event_clock(&repo, merge_oid), 6); | ||
| 210 | } | ||
| 211 | |||
| 212 | // ── Phase 5: Concurrent status resolution using clock+OID ─────── | ||
| 213 | |||
| 214 | #[test] | ||
| 215 | fn concurrent_issue_close_reopen_higher_clock_wins() { | ||
| 216 | // When one branch closes and another reopens at the same clock, | ||
| 217 | // the OID tiebreaker should produce a deterministic result. | ||
| 218 | // But when clocks differ, the higher clock always wins. | ||
| 219 | let dir = TempDir::new().unwrap(); | ||
| 220 | let repo = init_repo(dir.path()); | ||
| 221 | let sk = test_sk(); | ||
| 222 | |||
| 223 | // Create root issue (clock=1) | ||
| 224 | let open = Event { | ||
| 225 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 226 | author: alice(), | ||
| 227 | action: Action::IssueOpen { | ||
| 228 | title: "test".to_string(), | ||
| 229 | body: "body".to_string(), | ||
| 230 | }, | ||
| 231 | clock: 0, | ||
| 232 | }; | ||
| 233 | let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap(); | ||
| 234 | let local_ref = "refs/collab/issues/test-concurrent"; | ||
| 235 | let remote_ref = "refs/collab/sync/issues/test-concurrent"; | ||
| 236 | repo.reference(local_ref, root_oid, false, "test").unwrap(); | ||
| 237 | repo.reference(remote_ref, root_oid, false, "test").unwrap(); | ||
| 238 | |||
| 239 | // Local: close (clock=2) | ||
| 240 | let close = Event { | ||
| 241 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 242 | author: alice(), | ||
| 243 | action: Action::IssueClose { reason: None }, | ||
| 244 | clock: 0, | ||
| 245 | }; | ||
| 246 | dag::append_event(&repo, local_ref, &close, &sk).unwrap(); | ||
| 247 | |||
| 248 | // Remote: add comment (clock=2) then reopen (clock=3) | ||
| 249 | let comment = Event { | ||
| 250 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 251 | author: bob(), | ||
| 252 | action: Action::IssueComment { | ||
| 253 | body: "comment".to_string(), | ||
| 254 | }, | ||
| 255 | clock: 0, | ||
| 256 | }; | ||
| 257 | dag::append_event(&repo, remote_ref, &comment, &sk).unwrap(); | ||
| 258 | let reopen = Event { | ||
| 259 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 260 | author: bob(), | ||
| 261 | action: Action::IssueReopen, | ||
| 262 | clock: 0, | ||
| 263 | }; | ||
| 264 | dag::append_event(&repo, remote_ref, &reopen, &sk).unwrap(); | ||
| 265 | |||
| 266 | // Reconcile | ||
| 267 | dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); | ||
| 268 | |||
| 269 | // After reconcile: close had clock=2, reopen had clock=3, so reopen wins | ||
| 270 | let state = IssueState::from_ref(&repo, local_ref, "test-concurrent").unwrap(); | ||
| 271 | assert_eq!(state.status, IssueStatus::Open); | ||
| 272 | } | ||
| 273 | |||
| 274 | #[test] | ||
| 275 | fn concurrent_issue_same_clock_oid_breaks_tie() { | ||
| 276 | // When two status changes have the same clock, higher OID (lexicographic) wins. | ||
| 277 | let dir = TempDir::new().unwrap(); | ||
| 278 | let repo = init_repo(dir.path()); | ||
| 279 | let sk = test_sk(); | ||
| 280 | |||
| 281 | // Create root issue (clock=1) | ||
| 282 | let open = Event { | ||
| 283 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 284 | author: alice(), | ||
| 285 | action: Action::IssueOpen { | ||
| 286 | title: "test".to_string(), | ||
| 287 | body: "body".to_string(), | ||
| 288 | }, | ||
| 289 | clock: 0, | ||
| 290 | }; | ||
| 291 | let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap(); | ||
| 292 | let local_ref = "refs/collab/issues/test-tie"; | ||
| 293 | let remote_ref = "refs/collab/sync/issues/test-tie"; | ||
| 294 | repo.reference(local_ref, root_oid, false, "test").unwrap(); | ||
| 295 | repo.reference(remote_ref, root_oid, false, "test").unwrap(); | ||
| 296 | |||
| 297 | // Both branches: close at clock=2 (both directly from root) | ||
| 298 | let close1 = Event { | ||
| 299 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 300 | author: alice(), | ||
| 301 | action: Action::IssueClose { | ||
| 302 | reason: Some("alice close".to_string()), | ||
| 303 | }, | ||
| 304 | clock: 0, | ||
| 305 | }; | ||
| 306 | dag::append_event(&repo, local_ref, &close1, &sk).unwrap(); | ||
| 307 | |||
| 308 | let close2 = Event { | ||
| 309 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 310 | author: bob(), | ||
| 311 | action: Action::IssueClose { | ||
| 312 | reason: Some("bob close".to_string()), | ||
| 313 | }, | ||
| 314 | clock: 0, | ||
| 315 | }; | ||
| 316 | dag::append_event(&repo, remote_ref, &close2, &sk).unwrap(); | ||
| 317 | |||
| 318 | // Reconcile | ||
| 319 | dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); | ||
| 320 | |||
| 321 | // Both are closes with clock=2, so the issue should be closed. | ||
| 322 | // The one with the higher OID wins and determines the close reason. | ||
| 323 | let state = IssueState::from_ref(&repo, local_ref, "test-tie").unwrap(); | ||
| 324 | assert_eq!(state.status, IssueStatus::Closed); | ||
| 325 | // We can't predict which OID wins, but one of the reasons should be present | ||
| 326 | assert!( | ||
| 327 | state.close_reason == Some("alice close".to_string()) | ||
| 328 | || state.close_reason == Some("bob close".to_string()) | ||
| 329 | ); | ||
| 330 | } | ||
| 331 | |||
| 332 | #[test] | ||
| 333 | fn concurrent_patch_close_merge_higher_clock_wins() { | ||
| 334 | let dir = TempDir::new().unwrap(); | ||
| 335 | let repo = init_repo(dir.path()); | ||
| 336 | let sk = test_sk(); | ||
| 337 | |||
| 338 | // Need a branch for the patch | ||
| 339 | let sig = git2::Signature::now("Test", "test@test.com").unwrap(); | ||
| 340 | let tree_oid = repo.treebuilder(None).unwrap().write().unwrap(); | ||
| 341 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 342 | let initial = repo | ||
| 343 | .commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[]) | ||
| 344 | .unwrap(); | ||
| 345 | let initial_commit = repo.find_commit(initial).unwrap(); | ||
| 346 | repo.branch("test-branch", &initial_commit, false).unwrap(); | ||
| 347 | |||
| 348 | // Create root patch (clock=1) | ||
| 349 | let create = Event { | ||
| 350 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 351 | author: alice(), | ||
| 352 | action: Action::PatchCreate { | ||
| 353 | title: "test patch".to_string(), | ||
| 354 | body: "body".to_string(), | ||
| 355 | base_ref: "main".to_string(), | ||
| 356 | branch: "test-branch".to_string(), | ||
| 357 | fixes: None, | ||
| 358 | }, | ||
| 359 | clock: 0, | ||
| 360 | }; | ||
| 361 | let root_oid = dag::create_root_event(&repo, &create, &sk).unwrap(); | ||
| 362 | let local_ref = "refs/collab/patches/test-patch"; | ||
| 363 | let remote_ref = "refs/collab/sync/patches/test-patch"; | ||
| 364 | repo.reference(local_ref, root_oid, false, "test").unwrap(); | ||
| 365 | repo.reference(remote_ref, root_oid, false, "test") | ||
| 366 | .unwrap(); | ||
| 367 | |||
| 368 | // Local: close (clock=2) | ||
| 369 | let close = Event { | ||
| 370 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 371 | author: alice(), | ||
| 372 | action: Action::PatchClose { reason: None }, | ||
| 373 | clock: 0, | ||
| 374 | }; | ||
| 375 | dag::append_event(&repo, local_ref, &close, &sk).unwrap(); | ||
| 376 | |||
| 377 | // Remote: comment (clock=2), then merge (clock=3) | ||
| 378 | let comment = Event { | ||
| 379 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 380 | author: bob(), | ||
| 381 | action: Action::PatchComment { | ||
| 382 | body: "lgtm".to_string(), | ||
| 383 | }, | ||
| 384 | clock: 0, | ||
| 385 | }; | ||
| 386 | dag::append_event(&repo, remote_ref, &comment, &sk).unwrap(); | ||
| 387 | let merge = Event { | ||
| 388 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 389 | author: bob(), | ||
| 390 | action: Action::PatchMerge, | ||
| 391 | clock: 0, | ||
| 392 | }; | ||
| 393 | dag::append_event(&repo, remote_ref, &merge, &sk).unwrap(); | ||
| 394 | |||
| 395 | // Reconcile | ||
| 396 | dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); | ||
| 397 | |||
| 398 | // Merge at clock=3 should win over close at clock=2 | ||
| 399 | let state = PatchState::from_ref(&repo, local_ref, "test-patch").unwrap(); | ||
| 400 | assert_eq!(state.status, PatchStatus::Merged); | ||
| 401 | } | ||
| 402 | |||
| 403 | // ── Phase 7: Migration tests ──────────────────────────────────── | ||
| 404 | |||
| 405 | #[test] | ||
| 406 | fn migrate_clocks_assigns_sequential_clocks() { | ||
| 407 | let dir = TempDir::new().unwrap(); | ||
| 408 | let repo = init_repo(dir.path()); | ||
| 409 | let sk = test_sk(); | ||
| 410 | |||
| 411 | // Create a DAG with events that have clock=0 (simulating pre-migration data) | ||
| 412 | // We need to create commits directly to simulate old format | ||
| 413 | let open = Event { | ||
| 414 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 415 | author: alice(), | ||
| 416 | action: Action::IssueOpen { | ||
| 417 | title: "test".to_string(), | ||
| 418 | body: "body".to_string(), | ||
| 419 | }, | ||
| 420 | clock: 0, | ||
| 421 | }; | ||
| 422 | let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap(); | ||
| 423 | let ref_name = "refs/collab/issues/test-migrate"; | ||
| 424 | repo.reference(ref_name, root_oid, false, "test").unwrap(); | ||
| 425 | |||
| 426 | // Append a couple events | ||
| 427 | let comment = Event { | ||
| 428 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 429 | author: alice(), | ||
| 430 | action: Action::IssueComment { | ||
| 431 | body: "comment".to_string(), | ||
| 432 | }, | ||
| 433 | clock: 0, | ||
| 434 | }; | ||
| 435 | dag::append_event(&repo, ref_name, &comment, &sk).unwrap(); | ||
| 436 | |||
| 437 | // After creation through the DAG functions, clocks should already be set | ||
| 438 | // Let's verify the walk returns correct clocks | ||
| 439 | let events = dag::walk_events(&repo, ref_name).unwrap(); | ||
| 440 | assert_eq!(events.len(), 2); | ||
| 441 | assert_eq!(events[0].1.clock, 1); | ||
| 442 | assert_eq!(events[1].1.clock, 2); | ||
| 443 | |||
| 444 | // Now test migrate_clocks on a ref that has correct clocks (should be a no-op effectively) | ||
| 445 | dag::migrate_clocks(&repo, ref_name, &sk).unwrap(); | ||
| 446 | let events_after = dag::walk_events(&repo, ref_name).unwrap(); | ||
| 447 | assert_eq!(events_after.len(), 2); | ||
| 448 | assert!(events_after[0].1.clock >= 1); | ||
| 449 | assert!(events_after[1].1.clock >= 2); | ||
| 450 | } | ||
| 451 | |||
| 452 | #[test] | ||
| 453 | fn migrate_clocks_on_zero_clock_events() { | ||
| 454 | // Build a DAG with clock=0 events by writing directly (bypassing DAG functions) | ||
| 455 | let dir = TempDir::new().unwrap(); | ||
| 456 | let repo = init_repo(dir.path()); | ||
| 457 | let sk = test_sk(); | ||
| 458 | |||
| 459 | let event1 = Event { | ||
| 460 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 461 | author: alice(), | ||
| 462 | action: Action::IssueOpen { | ||
| 463 | title: "test".to_string(), | ||
| 464 | body: "body".to_string(), | ||
| 465 | }, | ||
| 466 | clock: 0, | ||
| 467 | }; | ||
| 468 | |||
| 469 | // Write directly with clock=0 (simulating pre-CRDT data) | ||
| 470 | let oid1 = write_raw_event(&repo, &event1, &sk, &[]); | ||
| 471 | let ref_name = "refs/collab/issues/test-migrate-zero"; | ||
| 472 | repo.reference(ref_name, oid1, false, "test").unwrap(); | ||
| 473 | |||
| 474 | let event2 = Event { | ||
| 475 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 476 | author: alice(), | ||
| 477 | action: Action::IssueComment { | ||
| 478 | body: "comment".to_string(), | ||
| 479 | }, | ||
| 480 | clock: 0, | ||
| 481 | }; | ||
| 482 | let parent = repo.find_commit(oid1).unwrap(); | ||
| 483 | let oid2 = write_raw_event(&repo, &event2, &sk, &[&parent]); | ||
| 484 | repo.reference(ref_name, oid2, true, "test append").unwrap(); | ||
| 485 | |||
| 486 | // Verify clocks are 0 | ||
| 487 | assert_eq!(read_event_clock(&repo, oid1), 0); | ||
| 488 | assert_eq!(read_event_clock(&repo, oid2), 0); | ||
| 489 | |||
| 490 | // Migrate | ||
| 491 | dag::migrate_clocks(&repo, ref_name, &sk).unwrap(); | ||
| 492 | |||
| 493 | // After migration, clocks should be sequential (1, 2) | ||
| 494 | let events = dag::walk_events(&repo, ref_name).unwrap(); | ||
| 495 | assert_eq!(events.len(), 2); | ||
| 496 | assert_eq!(events[0].1.clock, 1); | ||
| 497 | assert_eq!(events[1].1.clock, 2); | ||
| 498 | } | ||
| 499 | |||
| 500 | // ── Phase 8: Serialization preservation test ───────────────────── | ||
| 501 | |||
| 502 | #[test] | ||
| 503 | fn clock_field_survives_serialization_round_trip() { | ||
| 504 | let event = Event { | ||
| 505 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 506 | author: alice(), | ||
| 507 | action: Action::IssueOpen { | ||
| 508 | title: "test".to_string(), | ||
| 509 | body: "body".to_string(), | ||
| 510 | }, | ||
| 511 | clock: 42, | ||
| 512 | }; | ||
| 513 | |||
| 514 | let json = serde_json::to_string(&event).unwrap(); | ||
| 515 | assert!(json.contains("\"clock\":42")); | ||
| 516 | |||
| 517 | let deserialized: Event = serde_json::from_str(&json).unwrap(); | ||
| 518 | assert_eq!(deserialized.clock, 42); | ||
| 519 | } | ||
| 520 | |||
| 521 | #[test] | ||
| 522 | fn clock_field_in_dag_round_trip() { | ||
| 523 | let dir = TempDir::new().unwrap(); | ||
| 524 | let repo = init_repo(dir.path()); | ||
| 525 | let sk = test_sk(); | ||
| 526 | |||
| 527 | let event = Event { | ||
| 528 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 529 | author: alice(), | ||
| 530 | action: Action::IssueOpen { | ||
| 531 | title: "test".to_string(), | ||
| 532 | body: "body".to_string(), | ||
| 533 | }, | ||
| 534 | clock: 0, // Caller passes 0 | ||
| 535 | }; | ||
| 536 | |||
| 537 | let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); | ||
| 538 | let ref_name = format!("refs/collab/issues/{}", oid); | ||
| 539 | repo.reference(&ref_name, oid, false, "test").unwrap(); | ||
| 540 | |||
| 541 | // Read back and verify clock was set to 1 and persisted | ||
| 542 | let events = dag::walk_events(&repo, &ref_name).unwrap(); | ||
| 543 | assert_eq!(events.len(), 1); | ||
| 544 | assert_eq!(events[0].1.clock, 1); | ||
| 545 | } | ||
| 546 | |||
| 547 | // ── Helper: write a raw event commit bypassing DAG clock logic ─── | ||
| 548 | |||
| 549 | fn write_raw_event( | ||
| 550 | repo: &Repository, | ||
| 551 | event: &Event, | ||
| 552 | sk: &SigningKey, | ||
| 553 | parents: &[&git2::Commit], | ||
| 554 | ) -> Oid { | ||
| 555 | use git_collab::signing::sign_event; | ||
| 556 | |||
| 557 | let detached = sign_event(event, sk).unwrap(); | ||
| 558 | let json = serde_json::to_vec_pretty(event).unwrap(); | ||
| 559 | let event_blob = repo.blob(&json).unwrap(); | ||
| 560 | let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap(); | ||
| 561 | let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap(); | ||
| 562 | let manifest = br#"{"version":1,"format":"git-collab"}"#; | ||
| 563 | let manifest_blob = repo.blob(manifest).unwrap(); | ||
| 564 | |||
| 565 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 566 | tb.insert("event.json", event_blob, 0o100644).unwrap(); | ||
| 567 | tb.insert("signature", sig_blob, 0o100644).unwrap(); | ||
| 568 | tb.insert("pubkey", pubkey_blob, 0o100644).unwrap(); | ||
| 569 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 570 | let tree_oid = tb.write().unwrap(); | ||
| 571 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 572 | |||
| 573 | let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap(); | ||
| 574 | repo.commit(None, &sig, &sig, "raw event", &tree, parents) | ||
| 575 | .unwrap() | ||
| 576 | } | ||
tests/signing_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | use git_collab::event::{Action, Author, Event}; | 1 | use git_collab::event::{Action, Author, Event}; |
| 2 | use git_collab::signing::{ | 2 | use git_collab::signing::{ |
| 3 | canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event, | 3 | canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event, |
| 4 | verify_signed_event, SignedEvent, VerifyStatus, | 4 | verify_detached, DetachedSignature, VerifyStatus, |
| 5 | }; | 5 | }; |
| 6 | use tempfile::tempdir; | 6 | use tempfile::tempdir; |
| 7 | 7 | ||
| @@ -16,10 +16,11 @@ fn make_event() -> Event { | |||
| 16 | title: "Test issue".to_string(), | 16 | title: "Test issue".to_string(), |
| 17 | body: "This is a test".to_string(), | 17 | body: "This is a test".to_string(), |
| 18 | }, | 18 | }, |
| 19 | clock: 0, | ||
| 19 | } | 20 | } |
| 20 | } | 21 | } |
| 21 | 22 | ||
| 22 | // ── T004: Key generation and storage ── | 23 | // -- T004: Key generation and storage -- |
| 23 | 24 | ||
| 24 | #[test] | 25 | #[test] |
| 25 | fn generate_keypair_creates_key_files() { | 26 | fn generate_keypair_creates_key_files() { |
| @@ -93,7 +94,7 @@ fn load_verifying_key_missing_returns_error() { | |||
| 93 | ); | 94 | ); |
| 94 | } | 95 | } |
| 95 | 96 | ||
| 96 | // ── T005: Sign/verify round-trip ── | 97 | // -- T005: Sign/verify round-trip -- |
| 97 | 98 | ||
| 98 | #[test] | 99 | #[test] |
| 99 | fn sign_event_produces_nonempty_signature_and_pubkey() { | 100 | fn sign_event_produces_nonempty_signature_and_pubkey() { |
| @@ -103,33 +104,33 @@ fn sign_event_produces_nonempty_signature_and_pubkey() { | |||
| 103 | let sk = load_signing_key(&config).unwrap(); | 104 | let sk = load_signing_key(&config).unwrap(); |
| 104 | 105 | ||
| 105 | let event = make_event(); | 106 | let event = make_event(); |
| 106 | let signed = sign_event(&event, &sk).unwrap(); | 107 | let detached = sign_event(&event, &sk).unwrap(); |
| 107 | 108 | ||
| 108 | assert!(!signed.signature.is_empty(), "signature should not be empty"); | 109 | assert!(!detached.signature.is_empty(), "signature should not be empty"); |
| 109 | assert!(!signed.pubkey.is_empty(), "pubkey should not be empty"); | 110 | assert!(!detached.pubkey.is_empty(), "pubkey should not be empty"); |
| 110 | 111 | ||
| 111 | // Verify they are valid base64 | 112 | // Verify they are valid base64 |
| 112 | use base64::engine::general_purpose::STANDARD; | 113 | use base64::engine::general_purpose::STANDARD; |
| 113 | use base64::Engine; | 114 | use base64::Engine; |
| 114 | STANDARD | 115 | STANDARD |
| 115 | .decode(&signed.signature) | 116 | .decode(&detached.signature) |
| 116 | .expect("signature should be valid base64"); | 117 | .expect("signature should be valid base64"); |
| 117 | STANDARD | 118 | STANDARD |
| 118 | .decode(&signed.pubkey) | 119 | .decode(&detached.pubkey) |
| 119 | .expect("pubkey should be valid base64"); | 120 | .expect("pubkey should be valid base64"); |
| 120 | } | 121 | } |
| 121 | 122 | ||
| 122 | #[test] | 123 | #[test] |
| 123 | fn verify_valid_signed_event_returns_valid() { | 124 | fn verify_valid_detached_signature_returns_valid() { |
| 124 | let dir = tempdir().unwrap(); | 125 | let dir = tempdir().unwrap(); |
| 125 | let config = dir.path().join("git-collab"); | 126 | let config = dir.path().join("git-collab"); |
| 126 | generate_keypair(&config).unwrap(); | 127 | generate_keypair(&config).unwrap(); |
| 127 | let sk = load_signing_key(&config).unwrap(); | 128 | let sk = load_signing_key(&config).unwrap(); |
| 128 | 129 | ||
| 129 | let event = make_event(); | 130 | let event = make_event(); |
| 130 | let signed = sign_event(&event, &sk).unwrap(); | 131 | let detached = sign_event(&event, &sk).unwrap(); |
| 131 | 132 | ||
| 132 | let status = verify_signed_event(&signed).unwrap(); | 133 | let status = verify_detached(&event, &detached).unwrap(); |
| 133 | assert_eq!(status, VerifyStatus::Valid); | 134 | assert_eq!(status, VerifyStatus::Valid); |
| 134 | } | 135 | } |
| 135 | 136 | ||
| @@ -141,29 +142,29 @@ fn verify_tampered_event_returns_invalid() { | |||
| 141 | let sk = load_signing_key(&config).unwrap(); | 142 | let sk = load_signing_key(&config).unwrap(); |
| 142 | 143 | ||
| 143 | let event = make_event(); | 144 | let event = make_event(); |
| 144 | let mut signed = sign_event(&event, &sk).unwrap(); | 145 | let detached = sign_event(&event, &sk).unwrap(); |
| 145 | 146 | ||
| 146 | // Tamper with the event | 147 | // Tamper with the event |
| 147 | signed.event.author.name = "Mallory".to_string(); | 148 | let mut tampered = event.clone(); |
| 149 | tampered.author.name = "Mallory".to_string(); | ||
| 148 | 150 | ||
| 149 | let status = verify_signed_event(&signed).unwrap(); | 151 | let status = verify_detached(&tampered, &detached).unwrap(); |
| 150 | assert_eq!(status, VerifyStatus::Invalid); | 152 | assert_eq!(status, VerifyStatus::Invalid); |
| 151 | } | 153 | } |
| 152 | 154 | ||
| 153 | #[test] | 155 | #[test] |
| 154 | fn verify_missing_signature_returns_missing() { | 156 | fn verify_missing_signature_returns_missing() { |
| 155 | let event = make_event(); | 157 | let event = make_event(); |
| 156 | let signed = SignedEvent { | 158 | let detached = DetachedSignature { |
| 157 | event, | ||
| 158 | signature: String::new(), | 159 | signature: String::new(), |
| 159 | pubkey: String::new(), | 160 | pubkey: String::new(), |
| 160 | }; | 161 | }; |
| 161 | 162 | ||
| 162 | let status = verify_signed_event(&signed).unwrap(); | 163 | let status = verify_detached(&event, &detached).unwrap(); |
| 163 | assert_eq!(status, VerifyStatus::Missing); | 164 | assert_eq!(status, VerifyStatus::Missing); |
| 164 | } | 165 | } |
| 165 | 166 | ||
| 166 | // ── T006: Canonical serialization ── | 167 | // -- T006: Canonical serialization -- |
| 167 | 168 | ||
| 168 | #[test] | 169 | #[test] |
| 169 | fn canonical_json_deterministic() { | 170 | fn canonical_json_deterministic() { |
| @@ -176,31 +177,26 @@ fn canonical_json_deterministic() { | |||
| 176 | } | 177 | } |
| 177 | 178 | ||
| 178 | #[test] | 179 | #[test] |
| 179 | fn signed_event_json_contains_all_fields() { | 180 | fn detached_signature_fields_are_valid() { |
| 180 | let dir = tempdir().unwrap(); | 181 | let dir = tempdir().unwrap(); |
| 181 | let config = dir.path().join("git-collab"); | 182 | let config = dir.path().join("git-collab"); |
| 182 | generate_keypair(&config).unwrap(); | 183 | generate_keypair(&config).unwrap(); |
| 183 | let sk = load_signing_key(&config).unwrap(); | 184 | let sk = load_signing_key(&config).unwrap(); |
| 184 | 185 | ||
| 185 | let event = make_event(); | 186 | let event = make_event(); |
| 186 | let signed = sign_event(&event, &sk).unwrap(); | 187 | let detached = sign_event(&event, &sk).unwrap(); |
| 187 | 188 | ||
| 188 | let json = serde_json::to_string(&signed).unwrap(); | 189 | // Verify signature and pubkey are valid base64 strings |
| 189 | let value: serde_json::Value = serde_json::from_str(&json).unwrap(); | 190 | use base64::engine::general_purpose::STANDARD; |
| 190 | let obj = value.as_object().unwrap(); | 191 | use base64::Engine; |
| 191 | 192 | let sig_bytes = STANDARD.decode(&detached.signature).unwrap(); | |
| 192 | // Flattened event fields | 193 | let pk_bytes = STANDARD.decode(&detached.pubkey).unwrap(); |
| 193 | assert!(obj.contains_key("timestamp"), "missing timestamp"); | 194 | assert_eq!(sig_bytes.len(), 64, "Ed25519 signature should be 64 bytes"); |
| 194 | assert!(obj.contains_key("author"), "missing author"); | 195 | assert_eq!(pk_bytes.len(), 32, "Ed25519 public key should be 32 bytes"); |
| 195 | assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type"); | ||
| 196 | |||
| 197 | // Signature fields | ||
| 198 | assert!(obj.contains_key("signature"), "missing signature"); | ||
| 199 | assert!(obj.contains_key("pubkey"), "missing pubkey"); | ||
| 200 | } | 196 | } |
| 201 | 197 | ||
| 202 | #[test] | 198 | #[test] |
| 203 | fn signed_event_flatten_round_trip_with_tagged_enum() { | 199 | fn event_json_uses_namespaced_action_types() { |
| 204 | let event = Event { | 200 | let event = Event { |
| 205 | timestamp: "2026-03-21T12:00:00Z".to_string(), | 201 | timestamp: "2026-03-21T12:00:00Z".to_string(), |
| 206 | author: Author { | 202 | author: Author { |
| @@ -214,25 +210,16 @@ fn signed_event_flatten_round_trip_with_tagged_enum() { | |||
| 214 | branch: "feature/fix-bug".to_string(), | 210 | branch: "feature/fix-bug".to_string(), |
| 215 | fixes: Some("deadbeef".to_string()), | 211 | fixes: Some("deadbeef".to_string()), |
| 216 | }, | 212 | }, |
| 213 | clock: 0, | ||
| 217 | }; | 214 | }; |
| 218 | 215 | ||
| 219 | let signed = SignedEvent { | 216 | let json = serde_json::to_string(&event).unwrap(); |
| 220 | event, | 217 | assert!(json.contains("\"type\":\"patch.create\""), "action type should be namespaced: {}", json); |
| 221 | signature: "dGVzdHNpZw==".to_string(), | ||
| 222 | pubkey: "dGVzdGtleQ==".to_string(), | ||
| 223 | }; | ||
| 224 | 218 | ||
| 225 | let json = serde_json::to_string_pretty(&signed).unwrap(); | 219 | // Round-trip |
| 226 | let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); | 220 | let deserialized: Event = serde_json::from_str(&json).unwrap(); |
| 227 | 221 | match deserialized.action { | |
| 228 | assert_eq!(deserialized.signature, signed.signature); | 222 | Action::PatchCreate { ref title, ref fixes, .. } => { |
| 229 | assert_eq!(deserialized.pubkey, signed.pubkey); | ||
| 230 | match deserialized.event.action { | ||
| 231 | Action::PatchCreate { | ||
| 232 | ref title, | ||
| 233 | ref fixes, | ||
| 234 | .. | ||
| 235 | } => { | ||
| 236 | assert_eq!(title, "Fix bug"); | 223 | assert_eq!(title, "Fix bug"); |
| 237 | assert_eq!(fixes.as_deref(), Some("deadbeef")); | 224 | assert_eq!(fixes.as_deref(), Some("deadbeef")); |
| 238 | } | 225 | } |
tests/sync_lock_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,256 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use std::fs; | ||
| 4 | use std::path::{Path, PathBuf}; | ||
| 5 | |||
| 6 | use tempfile::TempDir; | ||
| 7 | |||
| 8 | use git_collab::error::Error; | ||
| 9 | use git_collab::sync_lock::{SyncLock, SyncLockInfo}; | ||
| 10 | |||
| 11 | // =========================================================================== | ||
| 12 | // Test helpers | ||
| 13 | // =========================================================================== | ||
| 14 | |||
| 15 | /// Return the `.git/collab/` path for a temp repo, creating it if needed. | ||
| 16 | fn collab_dir(repo_path: &Path) -> PathBuf { | ||
| 17 | let dir = repo_path.join(".git").join("collab"); | ||
| 18 | fs::create_dir_all(&dir).unwrap(); | ||
| 19 | dir | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Write a lockfile with the given PID and timestamp. | ||
| 23 | fn write_lockfile(collab_dir: &Path, pid: u32, timestamp: &str) { | ||
| 24 | let info = SyncLockInfo { | ||
| 25 | pid, | ||
| 26 | timestamp: timestamp.to_string(), | ||
| 27 | }; | ||
| 28 | let lock_path = collab_dir.join("sync.lock"); | ||
| 29 | fs::write(&lock_path, info.to_json()).unwrap(); | ||
| 30 | } | ||
| 31 | |||
| 32 | // =========================================================================== | ||
| 33 | // Phase 2: Foundational tests — serialization | ||
| 34 | // =========================================================================== | ||
| 35 | |||
| 36 | #[test] | ||
| 37 | fn test_sync_lock_info_roundtrip() { | ||
| 38 | let info = SyncLockInfo { | ||
| 39 | pid: 12345, | ||
| 40 | timestamp: "2026-03-21T10:30:00+00:00".to_string(), | ||
| 41 | }; | ||
| 42 | let json = info.to_json(); | ||
| 43 | let parsed = SyncLockInfo::from_json(&json).unwrap(); | ||
| 44 | assert_eq!(parsed.pid, 12345); | ||
| 45 | assert_eq!(parsed.timestamp, "2026-03-21T10:30:00+00:00"); | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn test_sync_lock_info_from_invalid_json() { | ||
| 50 | let result = SyncLockInfo::from_json("not json"); | ||
| 51 | assert!(result.is_err()); | ||
| 52 | } | ||
| 53 | |||
| 54 | // =========================================================================== | ||
| 55 | // Phase 3: User Story 1 — Prevent Concurrent Sync Corruption | ||
| 56 | // =========================================================================== | ||
| 57 | |||
| 58 | // T006: Test that SyncLock::acquire creates lockfile with correct PID and timestamp | ||
| 59 | #[test] | ||
| 60 | fn test_acquire_creates_lockfile() { | ||
| 61 | let dir = TempDir::new().unwrap(); | ||
| 62 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 63 | |||
| 64 | let lock = SyncLock::acquire(&repo).unwrap(); | ||
| 65 | |||
| 66 | assert!(lock.lock_path.exists(), "lockfile should exist after acquire"); | ||
| 67 | |||
| 68 | let content = fs::read_to_string(&lock.lock_path).unwrap(); | ||
| 69 | let info = SyncLockInfo::from_json(&content).unwrap(); | ||
| 70 | assert_eq!(info.pid, std::process::id()); | ||
| 71 | // Timestamp should be a valid RFC 3339 string | ||
| 72 | chrono::DateTime::parse_from_rfc3339(&info.timestamp) | ||
| 73 | .expect("timestamp should be valid RFC 3339"); | ||
| 74 | } | ||
| 75 | |||
| 76 | // T007: Test that SyncLock::acquire returns SyncLocked when lockfile exists with live PID | ||
| 77 | #[test] | ||
| 78 | fn test_acquire_returns_sync_locked_when_lock_held() { | ||
| 79 | let dir = TempDir::new().unwrap(); | ||
| 80 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 81 | |||
| 82 | // Write a lockfile with our own PID (guaranteed alive) | ||
| 83 | let collab = collab_dir(dir.path()); | ||
| 84 | let current_pid = std::process::id(); | ||
| 85 | write_lockfile(&collab, current_pid, &chrono::Utc::now().to_rfc3339()); | ||
| 86 | |||
| 87 | let result = SyncLock::acquire(&repo); | ||
| 88 | assert!(result.is_err(), "acquire should fail when lock is held"); | ||
| 89 | |||
| 90 | match result.unwrap_err() { | ||
| 91 | Error::SyncLocked { pid, since } => { | ||
| 92 | assert_eq!(pid, current_pid); | ||
| 93 | assert!(!since.is_empty()); | ||
| 94 | } | ||
| 95 | other => panic!("expected SyncLocked error, got: {}", other), | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | // T008: Test that dropping SyncLock deletes the lockfile | ||
| 100 | #[test] | ||
| 101 | fn test_drop_deletes_lockfile() { | ||
| 102 | let dir = TempDir::new().unwrap(); | ||
| 103 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 104 | |||
| 105 | let lock = SyncLock::acquire(&repo).unwrap(); | ||
| 106 | let lock_path = lock.lock_path.clone(); | ||
| 107 | assert!(lock_path.exists()); | ||
| 108 | |||
| 109 | drop(lock); | ||
| 110 | assert!(!lock_path.exists(), "lockfile should be deleted after drop"); | ||
| 111 | } | ||
| 112 | |||
| 113 | // =========================================================================== | ||
| 114 | // Phase 4: User Story 2 — Clear Error Message | ||
| 115 | // =========================================================================== | ||
| 116 | |||
| 117 | // T015: Test that SyncLocked error message includes PID and human-readable lock age | ||
| 118 | #[test] | ||
| 119 | fn test_sync_locked_error_message_includes_pid_and_age() { | ||
| 120 | let dir = TempDir::new().unwrap(); | ||
| 121 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 122 | |||
| 123 | let collab = collab_dir(dir.path()); | ||
| 124 | let current_pid = std::process::id(); | ||
| 125 | // Lock from 3 seconds ago | ||
| 126 | let ts = (chrono::Utc::now() - chrono::Duration::seconds(3)).to_rfc3339(); | ||
| 127 | write_lockfile(&collab, current_pid, &ts); | ||
| 128 | |||
| 129 | let err = SyncLock::acquire(&repo).unwrap_err(); | ||
| 130 | let msg = err.to_string(); | ||
| 131 | |||
| 132 | assert!( | ||
| 133 | msg.contains(¤t_pid.to_string()), | ||
| 134 | "error should contain PID, got: {}", | ||
| 135 | msg | ||
| 136 | ); | ||
| 137 | // Should contain human-readable age | ||
| 138 | assert!( | ||
| 139 | msg.contains("ago"), | ||
| 140 | "error should contain human-readable age, got: {}", | ||
| 141 | msg | ||
| 142 | ); | ||
| 143 | } | ||
| 144 | |||
| 145 | // T016: Test that error message suggests waiting or checking process | ||
| 146 | #[test] | ||
| 147 | fn test_sync_locked_error_message_suggests_action() { | ||
| 148 | let dir = TempDir::new().unwrap(); | ||
| 149 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 150 | |||
| 151 | let collab = collab_dir(dir.path()); | ||
| 152 | let current_pid = std::process::id(); | ||
| 153 | write_lockfile(&collab, current_pid, &chrono::Utc::now().to_rfc3339()); | ||
| 154 | |||
| 155 | let err = SyncLock::acquire(&repo).unwrap_err(); | ||
| 156 | let msg = err.to_string(); | ||
| 157 | |||
| 158 | assert!( | ||
| 159 | msg.contains("wait") || msg.contains("remove"), | ||
| 160 | "error should suggest action, got: {}", | ||
| 161 | msg | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | // =========================================================================== | ||
| 166 | // Phase 5: User Story 3 — Stale Lock Recovery | ||
| 167 | // =========================================================================== | ||
| 168 | |||
| 169 | // T019: Test that acquire succeeds when lockfile exists with a dead PID | ||
| 170 | #[test] | ||
| 171 | fn test_acquire_succeeds_with_dead_pid() { | ||
| 172 | let dir = TempDir::new().unwrap(); | ||
| 173 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 174 | |||
| 175 | let collab = collab_dir(dir.path()); | ||
| 176 | // PID 999999 is almost certainly not running | ||
| 177 | write_lockfile(&collab, 999999, &chrono::Utc::now().to_rfc3339()); | ||
| 178 | |||
| 179 | let lock = SyncLock::acquire(&repo); | ||
| 180 | assert!(lock.is_ok(), "acquire should succeed with dead PID: {:?}", lock.err()); | ||
| 181 | |||
| 182 | // The new lock should be ours | ||
| 183 | let lock = lock.unwrap(); | ||
| 184 | let content = fs::read_to_string(&lock.lock_path).unwrap(); | ||
| 185 | let info = SyncLockInfo::from_json(&content).unwrap(); | ||
| 186 | assert_eq!(info.pid, std::process::id()); | ||
| 187 | } | ||
| 188 | |||
| 189 | // T020: Test that acquire succeeds when lockfile is older than 10 minutes | ||
| 190 | #[test] | ||
| 191 | fn test_acquire_succeeds_with_old_lock() { | ||
| 192 | let dir = TempDir::new().unwrap(); | ||
| 193 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 194 | |||
| 195 | let collab = collab_dir(dir.path()); | ||
| 196 | // Lock from 11 minutes ago with our own PID (alive but stale by age) | ||
| 197 | let old_ts = (chrono::Utc::now() - chrono::Duration::minutes(11)).to_rfc3339(); | ||
| 198 | write_lockfile(&collab, std::process::id(), &old_ts); | ||
| 199 | |||
| 200 | let lock = SyncLock::acquire(&repo); | ||
| 201 | assert!( | ||
| 202 | lock.is_ok(), | ||
| 203 | "acquire should succeed with stale (old) lock: {:?}", | ||
| 204 | lock.err() | ||
| 205 | ); | ||
| 206 | } | ||
| 207 | |||
| 208 | // T021: Test that acquire succeeds when lockfile contains invalid JSON | ||
| 209 | #[test] | ||
| 210 | fn test_acquire_succeeds_with_corrupted_lockfile() { | ||
| 211 | let dir = TempDir::new().unwrap(); | ||
| 212 | let repo = git2::Repository::init(dir.path()).unwrap(); | ||
| 213 | |||
| 214 | let collab = collab_dir(dir.path()); | ||
| 215 | let lock_path = collab.join("sync.lock"); | ||
| 216 | fs::write(&lock_path, "this is not json at all").unwrap(); | ||
| 217 | |||
| 218 | let lock = SyncLock::acquire(&repo); | ||
| 219 | assert!( | ||
| 220 | lock.is_ok(), | ||
| 221 | "acquire should succeed with corrupted lockfile: {:?}", | ||
| 222 | lock.err() | ||
| 223 | ); | ||
| 224 | } | ||
| 225 | |||
| 226 | // T022: Test format_lock_age helper | ||
| 227 | #[test] | ||
| 228 | fn test_format_lock_age() { | ||
| 229 | use git_collab::sync_lock::format_lock_age; | ||
| 230 | |||
| 231 | let now = chrono::Utc::now(); | ||
| 232 | let three_sec_ago = (now - chrono::Duration::seconds(3)).to_rfc3339(); | ||
| 233 | let result = format_lock_age(&three_sec_ago); | ||
| 234 | assert!(result.contains("second"), "expected 'second' in: {}", result); | ||
| 235 | assert!(result.contains("ago"), "expected 'ago' in: {}", result); | ||
| 236 | |||
| 237 | let two_min_ago = (now - chrono::Duration::minutes(2)).to_rfc3339(); | ||
| 238 | let result = format_lock_age(&two_min_ago); | ||
| 239 | assert!(result.contains("minute"), "expected 'minute' in: {}", result); | ||
| 240 | |||
| 241 | // Invalid timestamp should fall back gracefully | ||
| 242 | let result = format_lock_age("not-a-timestamp"); | ||
| 243 | assert!(!result.is_empty()); | ||
| 244 | } | ||
| 245 | |||
| 246 | // T023: Test is_process_alive | ||
| 247 | #[test] | ||
| 248 | fn test_is_process_alive() { | ||
| 249 | use git_collab::sync_lock::is_process_alive; | ||
| 250 | |||
| 251 | // Our own process should be alive | ||
| 252 | assert!(is_process_alive(std::process::id())); | ||
| 253 | |||
| 254 | // PID 999999 should not be alive (almost certainly) | ||
| 255 | assert!(!is_process_alive(999999)); | ||
| 256 | } | ||
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ use tempfile::TempDir; | |||
| 11 | 11 | ||
| 12 | use git2::Repository; | 12 | use git2::Repository; |
| 13 | use git_collab::dag; | 13 | use git_collab::dag; |
| 14 | use git_collab::error; | ||
| 14 | use git_collab::event::{Action, Event, ReviewVerdict}; | 15 | use git_collab::event::{Action, Event, ReviewVerdict}; |
| 15 | use git_collab::signing; | 16 | use git_collab::signing; |
| 16 | use git_collab::state::{self, IssueState, IssueStatus, PatchState}; | 17 | use git_collab::state::{self, IssueState, IssueStatus, PatchState}; |
| @@ -26,7 +27,7 @@ use common::{ | |||
| 26 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 27 | 28 | ||
| 28 | struct TestCluster { | 29 | struct TestCluster { |
| 29 | _bare_dir: TempDir, | 30 | bare_dir: TempDir, |
| 30 | alice_dir: TempDir, | 31 | alice_dir: TempDir, |
| 31 | bob_dir: TempDir, | 32 | bob_dir: TempDir, |
| 32 | _key_setup: (), // signing key created in default config dir | 33 | _key_setup: (), // signing key created in default config dir |
| @@ -80,7 +81,7 @@ impl TestCluster { | |||
| 80 | } | 81 | } |
| 81 | 82 | ||
| 82 | TestCluster { | 83 | TestCluster { |
| 83 | _bare_dir: bare_dir, | 84 | bare_dir, |
| 84 | alice_dir, | 85 | alice_dir, |
| 85 | bob_dir, | 86 | bob_dir, |
| 86 | _key_setup: (), | 87 | _key_setup: (), |
| @@ -94,6 +95,11 @@ impl TestCluster { | |||
| 94 | fn bob_repo(&self) -> Repository { | 95 | fn bob_repo(&self) -> Repository { |
| 95 | Repository::open(self.bob_dir.path()).unwrap() | 96 | Repository::open(self.bob_dir.path()).unwrap() |
| 96 | } | 97 | } |
| 98 | |||
| 99 | /// Return the path to the bare remote directory. | ||
| 100 | fn bare_dir(&self) -> &std::path::Path { | ||
| 101 | self.bare_dir.path() | ||
| 102 | } | ||
| 97 | } | 103 | } |
| 98 | 104 | ||
| 99 | // --------------------------------------------------------------------------- | 105 | // --------------------------------------------------------------------------- |
| @@ -286,6 +292,7 @@ fn test_patch_review_across_repos() { | |||
| 286 | branch: "feature/x".to_string(), | 292 | branch: "feature/x".to_string(), |
| 287 | fixes: None, | 293 | fixes: None, |
| 288 | }, | 294 | }, |
| 295 | clock: 0, | ||
| 289 | }; | 296 | }; |
| 290 | let sk = test_signing_key(); | 297 | let sk = test_signing_key(); |
| 291 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); | 298 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); |
| @@ -306,6 +313,7 @@ fn test_patch_review_across_repos() { | |||
| 306 | verdict: ReviewVerdict::Approve, | 313 | verdict: ReviewVerdict::Approve, |
| 307 | body: "LGTM!".to_string(), | 314 | body: "LGTM!".to_string(), |
| 308 | }, | 315 | }, |
| 316 | clock: 0, | ||
| 309 | }; | 317 | }; |
| 310 | dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); | 318 | dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); |
| 311 | sync::sync(&bob_repo, "origin").unwrap(); | 319 | sync::sync(&bob_repo, "origin").unwrap(); |
| @@ -333,6 +341,7 @@ fn test_concurrent_review_and_revise() { | |||
| 333 | branch: "feature/wip".to_string(), | 341 | branch: "feature/wip".to_string(), |
| 334 | fixes: None, | 342 | fixes: None, |
| 335 | }, | 343 | }, |
| 344 | clock: 0, | ||
| 336 | }; | 345 | }; |
| 337 | let sk = test_signing_key(); | 346 | let sk = test_signing_key(); |
| 338 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); | 347 | let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); |
| @@ -350,6 +359,7 @@ fn test_concurrent_review_and_revise() { | |||
| 350 | action: Action::PatchRevise { | 359 | action: Action::PatchRevise { |
| 351 | body: Some("Updated description".to_string()), | 360 | body: Some("Updated description".to_string()), |
| 352 | }, | 361 | }, |
| 362 | clock: 0, | ||
| 353 | }; | 363 | }; |
| 354 | dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); | 364 | dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); |
| 355 | 365 | ||
| @@ -361,6 +371,7 @@ fn test_concurrent_review_and_revise() { | |||
| 361 | verdict: ReviewVerdict::RequestChanges, | 371 | verdict: ReviewVerdict::RequestChanges, |
| 362 | body: "Needs work".to_string(), | 372 | body: "Needs work".to_string(), |
| 363 | }, | 373 | }, |
| 374 | clock: 0, | ||
| 364 | }; | 375 | }; |
| 365 | dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); | 376 | dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); |
| 366 | 377 | ||
| @@ -464,6 +475,7 @@ fn test_unsigned_event_sync_rejected() { | |||
| 464 | title: "Unsigned issue".to_string(), | 475 | title: "Unsigned issue".to_string(), |
| 465 | body: "No signature".to_string(), | 476 | body: "No signature".to_string(), |
| 466 | }, | 477 | }, |
| 478 | clock: 0, | ||
| 467 | }; | 479 | }; |
| 468 | let oid = create_unsigned_event(&alice_repo, &event); | 480 | let oid = create_unsigned_event(&alice_repo, &event); |
| 469 | let id = oid.to_string(); | 481 | let id = oid.to_string(); |
| @@ -511,6 +523,7 @@ fn test_tampered_event_sync_rejected() { | |||
| 511 | title: "Tampered issue".to_string(), | 523 | title: "Tampered issue".to_string(), |
| 512 | body: "Will be tampered".to_string(), | 524 | body: "Will be tampered".to_string(), |
| 513 | }, | 525 | }, |
| 526 | clock: 0, | ||
| 514 | }; | 527 | }; |
| 515 | let oid = create_tampered_event(&repo, &event); | 528 | let oid = create_tampered_event(&repo, &event); |
| 516 | let id = oid.to_string(); | 529 | let id = oid.to_string(); |
| @@ -609,25 +622,450 @@ fn test_reconciliation_merge_commit_is_signed() { | |||
| 609 | let commit = alice_repo.find_commit(tip).unwrap(); | 622 | let commit = alice_repo.find_commit(tip).unwrap(); |
| 610 | // The tip should be the merge commit (it's the most recent) | 623 | // The tip should be the merge commit (it's the most recent) |
| 611 | let tree = commit.tree().unwrap(); | 624 | let tree = commit.tree().unwrap(); |
| 612 | let entry = tree.get_name("event.json").unwrap(); | 625 | |
| 613 | let blob = alice_repo.find_blob(entry.id()).unwrap(); | 626 | // Read event.json as a plain Event |
| 614 | let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap(); | 627 | let event_entry = tree.get_name("event.json").unwrap(); |
| 628 | let event_blob = alice_repo.find_blob(event_entry.id()).unwrap(); | ||
| 629 | let event: git_collab::event::Event = serde_json::from_slice(event_blob.content()).unwrap(); | ||
| 615 | 630 | ||
| 616 | assert!( | 631 | assert!( |
| 617 | matches!(signed.event.action, Action::Merge), | 632 | matches!(event.action, Action::Merge), |
| 618 | "Expected tip commit to be a Merge event, got {:?}", | 633 | "Expected tip commit to be a Merge event, got {:?}", |
| 619 | signed.event.action | 634 | event.action |
| 620 | ); | 635 | ); |
| 636 | |||
| 637 | // Read pubkey from separate blob | ||
| 638 | let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist"); | ||
| 639 | let pk_blob = alice_repo.find_blob(pk_entry.id()).unwrap(); | ||
| 640 | let commit_pubkey = std::str::from_utf8(pk_blob.content()).unwrap().trim().to_string(); | ||
| 641 | |||
| 621 | assert_eq!( | 642 | assert_eq!( |
| 622 | signed.pubkey, syncing_pubkey, | 643 | commit_pubkey, syncing_pubkey, |
| 623 | "Merge commit should be signed by the syncing user's key" | 644 | "Merge commit should be signed by the syncing user's key" |
| 624 | ); | 645 | ); |
| 625 | 646 | ||
| 626 | // Verify the signature is cryptographically valid | 647 | // Read signature from separate blob and verify |
| 627 | let status = signing::verify_signed_event(&signed).unwrap(); | 648 | let sig_entry = tree.get_name("signature").expect("signature blob should exist"); |
| 649 | let sig_blob = alice_repo.find_blob(sig_entry.id()).unwrap(); | ||
| 650 | let sig_str = std::str::from_utf8(sig_blob.content()).unwrap().trim().to_string(); | ||
| 651 | |||
| 652 | let detached = signing::DetachedSignature { | ||
| 653 | signature: sig_str, | ||
| 654 | pubkey: commit_pubkey, | ||
| 655 | }; | ||
| 656 | let status = signing::verify_detached(&event, &detached).unwrap(); | ||
| 628 | assert_eq!( | 657 | assert_eq!( |
| 629 | status, | 658 | status, |
| 630 | signing::VerifyStatus::Valid, | 659 | signing::VerifyStatus::Valid, |
| 631 | "Merge commit signature must be valid" | 660 | "Merge commit signature must be valid" |
| 632 | ); | 661 | ); |
| 633 | } | 662 | } |
| 663 | |||
| 664 | // --------------------------------------------------------------------------- | ||
| 665 | // Sync Recovery Tests | ||
| 666 | // --------------------------------------------------------------------------- | ||
| 667 | |||
| 668 | /// Install a pre-receive hook in the bare repo that rejects refs matching a pattern. | ||
| 669 | fn install_reject_hook(bare_dir: &std::path::Path, reject_pattern: &str) { | ||
| 670 | let hooks_dir = bare_dir.join("hooks"); | ||
| 671 | std::fs::create_dir_all(&hooks_dir).unwrap(); | ||
| 672 | let hook_path = hooks_dir.join("pre-receive"); | ||
| 673 | let script = format!( | ||
| 674 | r#"#!/bin/sh | ||
| 675 | while read oldrev newrev refname; do | ||
| 676 | case "$refname" in | ||
| 677 | *{}*) | ||
| 678 | echo "REJECT: $refname matches reject pattern" >&2 | ||
| 679 | exit 1 | ||
| 680 | ;; | ||
| 681 | esac | ||
| 682 | done | ||
| 683 | exit 0 | ||
| 684 | "#, | ||
| 685 | reject_pattern | ||
| 686 | ); | ||
| 687 | std::fs::write(&hook_path, script).unwrap(); | ||
| 688 | #[cfg(unix)] | ||
| 689 | { | ||
| 690 | use std::os::unix::fs::PermissionsExt; | ||
| 691 | std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).unwrap(); | ||
| 692 | } | ||
| 693 | } | ||
| 694 | |||
| 695 | /// Remove the pre-receive hook from the bare repo. | ||
| 696 | fn remove_reject_hook(bare_dir: &std::path::Path) { | ||
| 697 | let hook_path = bare_dir.join("hooks").join("pre-receive"); | ||
| 698 | let _ = std::fs::remove_file(&hook_path); | ||
| 699 | } | ||
| 700 | |||
| 701 | // T005 [US4]: Per-ref push isolates failures | ||
| 702 | #[test] | ||
| 703 | fn test_per_ref_push_isolates_failures() { | ||
| 704 | let cluster = TestCluster::new(); | ||
| 705 | let alice_repo = cluster.alice_repo(); | ||
| 706 | |||
| 707 | // Create two issues | ||
| 708 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue one"); | ||
| 709 | let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Issue two"); | ||
| 710 | |||
| 711 | // Install hook that rejects one specific issue | ||
| 712 | install_reject_hook(cluster.bare_dir(), &id1[..8]); | ||
| 713 | |||
| 714 | // Sync should fail with PartialSync | ||
| 715 | let result = sync::sync(&alice_repo, "origin"); | ||
| 716 | assert!(result.is_err(), "sync should fail when a ref is rejected"); | ||
| 717 | let err = result.unwrap_err(); | ||
| 718 | match &err { | ||
| 719 | git_collab::error::Error::PartialSync { succeeded, total } => { | ||
| 720 | assert_eq!(*succeeded, 1, "one ref should succeed"); | ||
| 721 | assert_eq!(*total, 2, "two refs total"); | ||
| 722 | } | ||
| 723 | other => panic!("expected PartialSync error, got: {:?}", other), | ||
| 724 | } | ||
| 725 | |||
| 726 | // Verify the non-rejected ref was pushed to the bare remote | ||
| 727 | let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap(); | ||
| 728 | let pushed_ref = format!("refs/collab/issues/{}", id2); | ||
| 729 | assert!( | ||
| 730 | bare_repo.refname_to_id(&pushed_ref).is_ok(), | ||
| 731 | "non-rejected ref should be on remote" | ||
| 732 | ); | ||
| 733 | |||
| 734 | // The rejected ref should NOT be on the remote | ||
| 735 | let rejected_ref = format!("refs/collab/issues/{}", id1); | ||
| 736 | assert!( | ||
| 737 | bare_repo.refname_to_id(&rejected_ref).is_err(), | ||
| 738 | "rejected ref should NOT be on remote" | ||
| 739 | ); | ||
| 740 | } | ||
| 741 | |||
| 742 | // T006 [US4]: All refs push successfully — happy path unchanged | ||
| 743 | #[test] | ||
| 744 | fn test_all_refs_push_succeeds_unchanged_behavior() { | ||
| 745 | let cluster = TestCluster::new(); | ||
| 746 | let alice_repo = cluster.alice_repo(); | ||
| 747 | |||
| 748 | let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Happy issue"); | ||
| 749 | |||
| 750 | // Sync should succeed | ||
| 751 | let result = sync::sync(&alice_repo, "origin"); | ||
| 752 | assert!(result.is_ok(), "sync should succeed: {:?}", result.err()); | ||
| 753 | } | ||
| 754 | |||
| 755 | // T010 [US1]: Partial failure reports failed refs | ||
| 756 | #[test] | ||
| 757 | fn test_partial_failure_reports_failed_refs() { | ||
| 758 | let cluster = TestCluster::new(); | ||
| 759 | let alice_repo = cluster.alice_repo(); | ||
| 760 | |||
| 761 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail"); | ||
| 762 | let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed"); | ||
| 763 | |||
| 764 | install_reject_hook(cluster.bare_dir(), &id1[..8]); | ||
| 765 | |||
| 766 | let result = sync::sync(&alice_repo, "origin"); | ||
| 767 | assert!(result.is_err()); | ||
| 768 | |||
| 769 | // Verify sync state was saved with the failed ref | ||
| 770 | let state = sync::SyncState::load(&alice_repo); | ||
| 771 | assert!(state.is_some(), "sync state should be saved"); | ||
| 772 | let state = state.unwrap(); | ||
| 773 | assert_eq!(state.remote, "origin"); | ||
| 774 | assert_eq!(state.pending_refs.len(), 1, "one ref should be pending"); | ||
| 775 | assert!( | ||
| 776 | state.pending_refs[0].0.contains(&id1), | ||
| 777 | "pending ref should be the rejected one" | ||
| 778 | ); | ||
| 779 | assert!( | ||
| 780 | !state.pending_refs[0].1.is_empty(), | ||
| 781 | "error message should be recorded" | ||
| 782 | ); | ||
| 783 | } | ||
| 784 | |||
| 785 | // T011 [US1]: Total failure reports all failed | ||
| 786 | #[test] | ||
| 787 | fn test_total_failure_reports_all_failed() { | ||
| 788 | let cluster = TestCluster::new(); | ||
| 789 | let alice_repo = cluster.alice_repo(); | ||
| 790 | |||
| 791 | let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Fail one"); | ||
| 792 | let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Fail two"); | ||
| 793 | |||
| 794 | // Reject ALL collab refs | ||
| 795 | install_reject_hook(cluster.bare_dir(), "refs/collab/"); | ||
| 796 | |||
| 797 | let result = sync::sync(&alice_repo, "origin"); | ||
| 798 | assert!(result.is_err()); | ||
| 799 | match result.unwrap_err() { | ||
| 800 | git_collab::error::Error::PartialSync { succeeded, total } => { | ||
| 801 | assert_eq!(succeeded, 0, "no refs should succeed"); | ||
| 802 | assert_eq!(total, 2, "two refs total"); | ||
| 803 | } | ||
| 804 | other => panic!("expected PartialSync, got: {:?}", other), | ||
| 805 | } | ||
| 806 | |||
| 807 | let state = sync::SyncState::load(&alice_repo).unwrap(); | ||
| 808 | assert_eq!(state.pending_refs.len(), 2, "all refs should be pending"); | ||
| 809 | } | ||
| 810 | |||
| 811 | // T014 [US2]: Resume retries only failed refs | ||
| 812 | #[test] | ||
| 813 | fn test_resume_retries_only_failed_refs() { | ||
| 814 | let cluster = TestCluster::new(); | ||
| 815 | let alice_repo = cluster.alice_repo(); | ||
| 816 | |||
| 817 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail first time"); | ||
| 818 | let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed first time"); | ||
| 819 | |||
| 820 | // First sync: reject id1 | ||
| 821 | install_reject_hook(cluster.bare_dir(), &id1[..8]); | ||
| 822 | let _ = sync::sync(&alice_repo, "origin"); | ||
| 823 | |||
| 824 | // Verify state was saved | ||
| 825 | let state = sync::SyncState::load(&alice_repo); | ||
| 826 | assert!(state.is_some()); | ||
| 827 | assert_eq!(state.unwrap().pending_refs.len(), 1); | ||
| 828 | |||
| 829 | // Remove the hook so all refs can push | ||
| 830 | remove_reject_hook(cluster.bare_dir()); | ||
| 831 | |||
| 832 | // Resume sync should succeed | ||
| 833 | let result = sync::sync(&alice_repo, "origin"); | ||
| 834 | assert!(result.is_ok(), "resume sync should succeed: {:?}", result.err()); | ||
| 835 | |||
| 836 | // State should be cleared | ||
| 837 | assert!( | ||
| 838 | sync::SyncState::load(&alice_repo).is_none(), | ||
| 839 | "sync state should be cleared after successful resume" | ||
| 840 | ); | ||
| 841 | } | ||
| 842 | |||
| 843 | // T015 [US2]: Resume clears state on full success | ||
| 844 | #[test] | ||
| 845 | fn test_resume_clears_state_on_full_success() { | ||
| 846 | let cluster = TestCluster::new(); | ||
| 847 | let alice_repo = cluster.alice_repo(); | ||
| 848 | |||
| 849 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for resume"); | ||
| 850 | |||
| 851 | // Manually create sync state | ||
| 852 | let state = sync::SyncState { | ||
| 853 | remote: "origin".to_string(), | ||
| 854 | pending_refs: vec![( | ||
| 855 | format!("refs/collab/issues/{}", id1), | ||
| 856 | "previous error".to_string(), | ||
| 857 | )], | ||
| 858 | timestamp: chrono::Utc::now().to_rfc3339(), | ||
| 859 | }; | ||
| 860 | state.save(&alice_repo).unwrap(); | ||
| 861 | |||
| 862 | // Sync in resume mode | ||
| 863 | let result = sync::sync(&alice_repo, "origin"); | ||
| 864 | assert!(result.is_ok(), "resume should succeed: {:?}", result.err()); | ||
| 865 | |||
| 866 | // Verify state file is gone | ||
| 867 | let state_path = alice_repo.path().join("collab").join("sync-state.json"); | ||
| 868 | assert!( | ||
| 869 | !state_path.exists(), | ||
| 870 | "sync-state.json should be deleted after successful resume" | ||
| 871 | ); | ||
| 872 | } | ||
| 873 | |||
| 874 | // T016 [US2]: Resume updates state on continued failure | ||
| 875 | #[test] | ||
| 876 | fn test_resume_updates_state_on_continued_failure() { | ||
| 877 | let cluster = TestCluster::new(); | ||
| 878 | let alice_repo = cluster.alice_repo(); | ||
| 879 | |||
| 880 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will keep failing"); | ||
| 881 | let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Will succeed on retry"); | ||
| 882 | |||
| 883 | // Manually create sync state with both refs pending | ||
| 884 | let state = sync::SyncState { | ||
| 885 | remote: "origin".to_string(), | ||
| 886 | pending_refs: vec![ | ||
| 887 | ( | ||
| 888 | format!("refs/collab/issues/{}", id1), | ||
| 889 | "previous error".to_string(), | ||
| 890 | ), | ||
| 891 | ( | ||
| 892 | format!("refs/collab/issues/{}", id2), | ||
| 893 | "previous error".to_string(), | ||
| 894 | ), | ||
| 895 | ], | ||
| 896 | timestamp: chrono::Utc::now().to_rfc3339(), | ||
| 897 | }; | ||
| 898 | state.save(&alice_repo).unwrap(); | ||
| 899 | |||
| 900 | // Install hook that only rejects id1 | ||
| 901 | install_reject_hook(cluster.bare_dir(), &id1[..8]); | ||
| 902 | |||
| 903 | // Resume should partially fail | ||
| 904 | let result = sync::sync(&alice_repo, "origin"); | ||
| 905 | assert!(result.is_err()); | ||
| 906 | |||
| 907 | // State should be updated with only the still-failing ref | ||
| 908 | let new_state = sync::SyncState::load(&alice_repo).unwrap(); | ||
| 909 | assert_eq!( | ||
| 910 | new_state.pending_refs.len(), | ||
| 911 | 1, | ||
| 912 | "only the still-failing ref should remain" | ||
| 913 | ); | ||
| 914 | assert!( | ||
| 915 | new_state.pending_refs[0].0.contains(&id1), | ||
| 916 | "the still-failing ref should be id1" | ||
| 917 | ); | ||
| 918 | } | ||
| 919 | |||
| 920 | // T017 [US2]: No resume without state file | ||
| 921 | #[test] | ||
| 922 | fn test_no_resume_without_state_file() { | ||
| 923 | let cluster = TestCluster::new(); | ||
| 924 | let alice_repo = cluster.alice_repo(); | ||
| 925 | |||
| 926 | let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Normal sync"); | ||
| 927 | |||
| 928 | // No sync state file — should run full flow | ||
| 929 | let result = sync::sync(&alice_repo, "origin"); | ||
| 930 | assert!(result.is_ok(), "normal sync should succeed: {:?}", result.err()); | ||
| 931 | } | ||
| 932 | |||
| 933 | // T018b [US2]: Resume cleans up stale sync refs | ||
| 934 | #[test] | ||
| 935 | fn test_resume_cleans_up_stale_sync_refs() { | ||
| 936 | let cluster = TestCluster::new(); | ||
| 937 | let alice_repo = cluster.alice_repo(); | ||
| 938 | |||
| 939 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for stale ref test"); | ||
| 940 | |||
| 941 | // Push the issue first (normal sync) | ||
| 942 | sync::sync(&alice_repo, "origin").unwrap(); | ||
| 943 | |||
| 944 | // Now manually create a stale sync ref | ||
| 945 | let tip = alice_repo | ||
| 946 | .refname_to_id(&format!("refs/collab/issues/{}", id1)) | ||
| 947 | .unwrap(); | ||
| 948 | alice_repo | ||
| 949 | .reference( | ||
| 950 | "refs/collab/sync/origin/issues/stale_ref", | ||
| 951 | tip, | ||
| 952 | true, | ||
| 953 | "create stale sync ref", | ||
| 954 | ) | ||
| 955 | .unwrap(); | ||
| 956 | |||
| 957 | // Create sync state to trigger resume mode | ||
| 958 | let state = sync::SyncState { | ||
| 959 | remote: "origin".to_string(), | ||
| 960 | pending_refs: vec![( | ||
| 961 | format!("refs/collab/issues/{}", id1), | ||
| 962 | "previous error".to_string(), | ||
| 963 | )], | ||
| 964 | timestamp: chrono::Utc::now().to_rfc3339(), | ||
| 965 | }; | ||
| 966 | state.save(&alice_repo).unwrap(); | ||
| 967 | |||
| 968 | // Resume sync | ||
| 969 | let result = sync::sync(&alice_repo, "origin"); | ||
| 970 | assert!(result.is_ok(), "resume should succeed: {:?}", result.err()); | ||
| 971 | |||
| 972 | // Verify stale sync ref was cleaned up | ||
| 973 | let stale_ref = alice_repo.refname_to_id("refs/collab/sync/origin/issues/stale_ref"); | ||
| 974 | assert!( | ||
| 975 | stale_ref.is_err(), | ||
| 976 | "stale sync ref should be cleaned up during resume" | ||
| 977 | ); | ||
| 978 | } | ||
| 979 | |||
| 980 | // T021 [US3]: Stale state detected and cleared when refs are already up to date | ||
| 981 | #[test] | ||
| 982 | fn test_stale_state_detected_and_cleared() { | ||
| 983 | let cluster = TestCluster::new(); | ||
| 984 | let alice_repo = cluster.alice_repo(); | ||
| 985 | |||
| 986 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Already pushed issue"); | ||
| 987 | |||
| 988 | // Push the issue normally first | ||
| 989 | sync::sync(&alice_repo, "origin").unwrap(); | ||
| 990 | |||
| 991 | // Now create sync state as if the push had failed — but the refs are actually pushed | ||
| 992 | let state = sync::SyncState { | ||
| 993 | remote: "origin".to_string(), | ||
| 994 | pending_refs: vec![( | ||
| 995 | format!("refs/collab/issues/{}", id1), | ||
| 996 | "connection timed out".to_string(), | ||
| 997 | )], | ||
| 998 | timestamp: chrono::Utc::now().to_rfc3339(), | ||
| 999 | }; | ||
| 1000 | state.save(&alice_repo).unwrap(); | ||
| 1001 | |||
| 1002 | // Resume sync — refs are already up to date | ||
| 1003 | let result = sync::sync(&alice_repo, "origin"); | ||
| 1004 | assert!( | ||
| 1005 | result.is_ok(), | ||
| 1006 | "sync should succeed when refs already up to date: {:?}", | ||
| 1007 | result.err() | ||
| 1008 | ); | ||
| 1009 | |||
| 1010 | // State should be cleared | ||
| 1011 | assert!( | ||
| 1012 | sync::SyncState::load(&alice_repo).is_none(), | ||
| 1013 | "stale sync state should be cleared" | ||
| 1014 | ); | ||
| 1015 | } | ||
| 1016 | |||
| 1017 | // T022 [US3]: State for different remote is ignored | ||
| 1018 | #[test] | ||
| 1019 | fn test_state_for_different_remote_ignored() { | ||
| 1020 | let cluster = TestCluster::new(); | ||
| 1021 | let alice_repo = cluster.alice_repo(); | ||
| 1022 | |||
| 1023 | let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for remote test"); | ||
| 1024 | |||
| 1025 | // Create sync state for a different remote | ||
| 1026 | let state = sync::SyncState { | ||
| 1027 | remote: "upstream".to_string(), | ||
| 1028 | pending_refs: vec![( | ||
| 1029 | format!("refs/collab/issues/{}", id1), | ||
| 1030 | "some error".to_string(), | ||
| 1031 | )], | ||
| 1032 | timestamp: chrono::Utc::now().to_rfc3339(), | ||
| 1033 | }; | ||
| 1034 | state.save(&alice_repo).unwrap(); | ||
| 1035 | |||
| 1036 | // Sync against origin — should run full flow, ignoring the upstream state | ||
| 1037 | let result = sync::sync(&alice_repo, "origin"); | ||
| 1038 | assert!( | ||
| 1039 | result.is_ok(), | ||
| 1040 | "sync to origin should succeed ignoring upstream state: {:?}", | ||
| 1041 | result.err() | ||
| 1042 | ); | ||
| 1043 | } | ||
| 1044 | |||
| 1045 | // T026: Corrupted state file handled gracefully | ||
| 1046 | #[test] | ||
| 1047 | fn test_corrupted_state_file_handled_gracefully() { | ||
| 1048 | let cluster = TestCluster::new(); | ||
| 1049 | let alice_repo = cluster.alice_repo(); | ||
| 1050 | |||
| 1051 | let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Issue for corruption test"); | ||
| 1052 | |||
| 1053 | // Write invalid JSON to sync-state.json | ||
| 1054 | let collab_dir = alice_repo.path().join("collab"); | ||
| 1055 | std::fs::create_dir_all(&collab_dir).unwrap(); | ||
| 1056 | std::fs::write(collab_dir.join("sync-state.json"), "not valid json{{{").unwrap(); | ||
| 1057 | |||
| 1058 | // Sync should proceed normally (treating as no state) | ||
| 1059 | let result = sync::sync(&alice_repo, "origin"); | ||
| 1060 | assert!( | ||
| 1061 | result.is_ok(), | ||
| 1062 | "sync should succeed despite corrupted state: {:?}", | ||
| 1063 | result.err() | ||
| 1064 | ); | ||
| 1065 | |||
| 1066 | // State file should be cleaned up | ||
| 1067 | assert!( | ||
| 1068 | !collab_dir.join("sync-state.json").exists(), | ||
| 1069 | "corrupted state file should be deleted" | ||
| 1070 | ); | ||
| 1071 | } | ||