diff --git a/CHANGELOG.md b/CHANGELOG.md index ce524d9..74fa6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Keep Git uploads that no signed event names out of permanent storage. A push + to `refs/nostr/` whose PR event is not yet known is staged in the + repository that received it, promoted into shared storage when the event is + accepted, and reclaimed after its pending ref expires. Pushes backed by a + signed State or PR event are stored as before. + +- Accept a GRASP-06 `/prs/` PR event whose pushed ref survived a crash that + lost its purgatory placeholder, matching it by the event's service-local + clone URL and exact `refs/nostr/` ref. + +- Remove abandoned normal-endpoint PR refs when their pending event expires, + retaining exact repository scopes across restarts and retrying failed cleanup. + ## [3.0.5] - 2026-09-25 Improve live sync startup, bound retries against unhealthy relays, fix Git push diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 6b633ea..5591e2f 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -113,7 +113,7 @@ runtime: pair in check-only or repair mode - Serve HTTP + WebSocket until a caller-supplied shutdown future resolves, then stop background mutation, persist a final state snapshot - (purgatory and rejected-events cache), and clean up placeholder refs + (purgatory and rejected-events cache), preserving pending PR refs for restart and expiry **Key Dependencies:** @@ -525,7 +525,17 @@ See [`types.rs`](../../src/purgatory/types.rs) for complete definitions: - Creates `Arc` at startup - Passes purgatory to both write policy and git handlers - Passes `RepositoryLifecycle` directly to HTTP git serving and deletion/recovery -- Spawns background cleanup task (60-second interval) +- Spawns background cleanup task (60-second interval). Normal-endpoint PR + placeholders persist every pushed owner/identifier and commit. After 30 minutes + without the event, expiry takes repository lifecycle write locks, rechecks the + placeholder, and compare-and-deletes only those recorded refs. Failed deletions + keep the placeholder for retry. `/prs/` copies retain their separate cleanup. + Old unscoped placeholders cannot safely identify a repository for online cleanup; + the authorization-integrity checker preserves unmatched refs for inspection. +- Spawns the staging maintenance worker. Uploads that no signed event names + are staged in their view; the worker promotes history owed to the family and + reclaims staged objects once their pending ref is gone. See + [Staging of unsigned uploads](git-family-object-storage.md#staging-of-unsigned-uploads). #### Thread Safety diff --git a/docs/explanation/git-family-object-storage.md b/docs/explanation/git-family-object-storage.md index 343407b..8389bbb 100644 --- a/docs/explanation/git-family-object-storage.md +++ b/docs/explanation/git-family-object-storage.md @@ -158,19 +158,159 @@ authorization remain view-specific. For local writes, receive-pack writes its quarantine and final objects into the family object directory while it updates refs in the selected view. Other Git -commands that can create objects, including proactive fetch and archive -restore, must use the same family object directory. Read-only commands can use -the view normally because its alternate resolves the inventory. +commands that can create objects, including proactive fetch, copies between +views and archive restore, must use the same family object directory. Read-only +commands can use the view normally because its alternate resolves the +inventory. The one exception is an upload that no signed event names yet, +described next. + +## Staging of unsigned uploads + +**Added:** 2026-09-29 + +A push to `refs/nostr/` is accepted before its PR event is known. +Until this change its objects went straight into the family, which is never +garbage-collected, so anyone could fill permanent storage without signing +anything. Such an upload is now **staged**: receive-pack writes its objects to +the view's own object directory, and they reach the family only when a signed +event accepts them. + +### What earns family storage + +A signed event earns family storage at push time. Push authorization already +decides, for each pushed ref, whether a signed event names it: + +| Pushed ref | Objects go to | +| ------------------------------------------------------------ | ------------- | +| Branch or tag named by a State, accepted or in purgatory | family | +| `refs/nostr/` whose PR event is accepted or in purgatory | family | +| `refs/nostr/` with no event, or only a placeholder | view staging | + +A push that carries any unsigned ref is staged as a whole, because one push is +one pack. While a view holds staged objects, every push to it is staged too: +the view advertises its pending refs, so a client may omit objects that only +staging holds, and receive-pack could not check such a push against the family +alone. + +Trade-off: a pack from a signed push is stored whole, including any object in +it that the signed tip does not reach. This is accepted because a signer can +make any object reachable from their own tip. Staging defends against uploads +nobody signed for, not against a signer's choice of content. Purgatory sync +fetches and integrity repair fetch history for signed events and keep writing +to the family. + +### Promotion + +Promotion copies a tip's history from the view into the family with +`git fetch`, checks that the family alone holds it, and installs the usual +retained and base roots. The check walks from the tip down to existing retained +roots. Those were complete when installed and the family is append-only, so the +cost follows the new history instead of the whole repository. Detecting damage +behind a root remains the integrity pass's job; a damaged root fails the check +and therefore the promotion. + +Promotion happens at these boundaries: + +- **PR acceptance.** A PR or PR Update event is accepted only after its tip is + promoted. On failure the event is rejected and its placeholder is kept, so + the upload expires normally and the client can send the event again. +- **Signed push into a staged view.** Its signed tips are promoted when + receive-pack finishes, while the push still holds the family lease. + +- **State acceptance and purgatory release.** All concrete branch and tag tips + are promoted before ref alignment and acceptance. A State can adopt an + unsigned upload already in the view without another push. Failure rejects + a directly submitted State or leaves a waiting State in purgatory; its + history must become durable before later ref changes can make it disposable. + +### Owed history + +Rollback after a State deletion depends on history that no current ref names. +The absence of a ref therefore never proves that staged history is disposable. + +Before receive-pack runs, the signed tips of a staged push are recorded as +**owed** in the view's registry record. A tip stays owed until its history is +complete in the family alone. If promotion at the end of the push fails, or +the server stops before it runs, the tip is still owed after restart and +maintenance promotes it by object ID, whether or not a ref still names it. An +owed tip whose object is in neither store belongs to a push that never +completed and is dropped. An empty `/prs/` view is not removed while it owes +history. + +A view that already holds objects when it is first staged has them moved into +the family first. They predate staging and cannot be told apart from accepted +history. + +### Compaction + +Staging is reclaimed by Git itself. For one view, maintenance: + +1. promotes owed tips, and stops if any remain owed; +2. runs `git repack -a -d -l` and `git prune --expire=now`, which keep the + history live refs need and the family lacks, and drop everything else; +3. removes loose objects the family also holds; +4. removes the registry record if nothing is left, and the view itself if it + is an empty `/prs/` view. + +Every live ref is a root, so a pending upload keeps exactly its own history +and an abandoned one disappears once its ref expires. + +Compaction must not overlap a push to the same view. Git can report a +successful push and install a ref whose parent a concurrent repack has just +removed; `tests/git_cruft_concurrency.rs` reproduces this. A grace period +cannot prevent it, because the push may start after the repack has chosen +what to keep. Compaction therefore takes the family write lease, which every +push already holds from before receive-pack starts until its refs and roots +are installed. The lease is fair: maintenance queues behind active writers for +at most 250 ms, then gives way and retries with backoff from one second to five +minutes, so it cannot hold up the pushes behind it. + +Fetches take no lease. A fetch of a pending ref that expires while it is being +served may fail. That ref named an upload nobody signed for and is being +removed deliberately. + +### Registry and maintenance worker + +`.grasp/staging/.json` records each staged view, its owed tips, and +unsigned ref intentions with absolute expiry deadlines. It is written and +fsynced before receive-pack can write an object. One worker +processes maintenance requests, which come from staged pushes, promotions and +ref deletions, including placeholder expiry. At startup it reads the registry, +so interrupted work resumes. Before cleanup, sync and request handling start, +recovery reconstructs missing purgatory placeholders from surviving unsigned +refs and their durable deadlines. Accepted events and signed events already in +purgatory are preserved. Failed pushes with no matching ref create no +placeholder. Legacy records without deadlines receive one grace period that +is persisted before recovery proceeds, so repeated restarts do not extend it. +Unreadable or invalid individual records are logged and skipped without +removing their metadata, refs or objects; healthy records still recover. A +skipped upload with no checkpoint placeholder remains retained for operator +inspection. Failure to enumerate the registry itself still aborts startup. +This closes the window between receive-pack and the periodic purgatory +checkpoint. A view whose remaining staged objects belong to pending refs is +parked until the next request rather than polled. + +### Limits + +- Staging is not a storage quota. It bounds how long an unsigned upload is + kept, not how much one may upload. +- Archiving a repository stores its view directory as it is, staged objects + included. Restoring the archive imports them into the family. +- Staged history exists in one view only. Other views cannot read it until it + is promoted. ## Durability and the success fence The invariant is: > When a client observes a successful push, every Git object needed by the -> accepted ref updates is durable in the configured family backend. +> accepted ref updates is durable in the configured family backend, or in the +> view's staging for an upload that no signed event names yet. The local backend satisfies the fence when Git has atomically installed the -objects in the family object directory and receive-pack has completed. +objects in the family object directory, or in view staging, and receive-pack +has completed. Accepting a PR event additionally requires its history to be +complete in the family. The S3 backend cannot release receive-pack's terminal success immediately. The handler must retain the final protocol status until it has: @@ -224,8 +364,12 @@ Consequences: - disable automatic Git maintenance and pruning for family repositories; - create retained roots for every accepted branch, tag, and PR tip; +- unsigned uploads stay outside this inventory until a signed event accepts + them; - do not use S3 lifecycle deletion on family objects; -- never compact by packing only the current visible ref closure; +- never compact the family by packing only the current visible ref closure + (view staging is compacted this way because it is not part of the + inventory, and only once nothing in it is owed to the family); - if pack-count compaction becomes necessary, repack the union of every object in all selected packs, publish the replacement in addition to the old immutable packs, and leave physical deletion to future GC work. @@ -243,8 +387,10 @@ view whose alternate is the identifier family. Therefore: - receive-pack may advertise family base tips as anonymous `.have` lines; - the first contributor push sends only objects the family does not have; - pushes remain restricted to `refs/nostr/`; +- a push whose PR event is not yet known is staged in the view; - placeholder and expiry cleanup removes view refs or an empty view, never - family objects or retained roots. + family objects or retained roots. An empty view that still owes history to + the family is kept. Mirroring a PR into an owner view becomes a ref update after an object availability check. It no longer copies the object graph. @@ -384,9 +530,10 @@ local objects exist. Operations that mutate one family are serialized by a per-family lock. This covers pushes to different owner or `/prs/` views with the same identifier, -proactive fetches, archive restores, retained/base ref updates, and S3 manifest -publication. Read requests take a hydrated family lease; they do not hold the -writer lock after their pack set has been pinned. +proactive fetches, archive restores, retained/base ref updates, promotion and +compaction of view staging, and S3 manifest publication. Read requests take a +hydrated family lease; they do not hold the writer lock after their pack set +has been pinned. View lifecycle locks remain responsible for “may this path be removed?” The family lock is responsible for “is this object inventory and manifest update @@ -397,8 +544,9 @@ atomic?” Neither lock grants authorization. Storage integrity is defined for an object-format/identifier family, not for one owner path. The storage pass checks the family pack set and object graph, then checks every owner and `/prs/` view with the same identifier for the -correct alternate and for refs whose targets are available in the family. -Multiple independent histories in one identifier are valid, and unreachable +correct alternate and for refs whose targets are available in the family. A +ref of a staged view whose history is complete in that view's staging is a +pending upload, not a fault. Multiple independent histories in one identifier are valid, and unreachable objects are not an error: retained delete-state and rollback data intentionally remain present. diff --git a/docs/explanation/purgatory-design.md b/docs/explanation/purgatory-design.md index 9a671aa..b4b253c 100644 --- a/docs/explanation/purgatory-design.md +++ b/docs/explanation/purgatory-design.md @@ -598,7 +598,7 @@ sequenceDiagram end else No PR event anywhere (git-data-first) GitHandler->>GitProcess: Execute push - accept any commit - GitHandler->>Purgatory: add_pr_placeholder(event_id, commit) + GitHandler->>Purgatory: add_standard_pr_placeholder(event_id, commit, owner, identifier) GitHandler->>GitClient: Push accepted - awaiting PR event end end @@ -606,6 +606,20 @@ sequenceDiagram --- +Normal-endpoint git-first placeholders persist each destination owner, repository +identifier, and pushed commit. The 60-second expiry task acquires those repositories' +lifecycle write locks before checking the record again. If the event is still absent +after 30 minutes, it compare-and-deletes the recorded refs, retaining the placeholder +on failure for retry. A database check protects accepted events whose placeholders +survived in an older checkpoint. Legacy records without destinations remain readable, +but cannot safely drive scoped online ref deletion. + +The objects of a git-first push are staged in the view that received them, not +stored in the identifier family. Expiring the ref lets staging maintenance +reclaim them. Accepting the PR event first promotes its history into the +family; the placeholder is released only after that succeeds. See +[Staging of unsigned uploads](git-family-object-storage.md#staging-of-unsigned-uploads). + ## Background Sync Purgatory includes a background sync system that fetches git data from remote servers when events arrive before git data. diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 3eed02a..ae2a42e 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -76,6 +76,7 @@ pub async fn authorize_push( // Collect all purgatory events that authorize this push let mut purgatory_events = Vec::new(); + let mut unsigned_refs = HashSet::new(); // Handle refs/nostr/ refs - validate and collect PR/PR-update events from purgatory if !nostr_refs.is_empty() { @@ -88,35 +89,45 @@ pub async fn authorize_push( // Standard endpoint passes `None` for prs_url: signer / a-tag // identifier are enforced by the surrounding maintainer-set // authorization, not by the URL. - match pre_validate_refs_nostr_push(database, purgatory, new_oid, ref_name, None).await { - NostrRefPreValidation::Rejected { reason } => { - warn!("refs/nostr/ validation failed: {}", reason); - return Ok(AuthorizationResult::denied(reason)); - } - NostrRefPreValidation::Authorized { - event_from_purgatory, - } => { - if let Some(event) = event_from_purgatory { - debug!("Found matching PR event in purgatory for ref {}", ref_name); - purgatory_events.push(event); - } else { - debug!("Ref {} validated against existing record", ref_name); + let event_id = ref_name + .strip_prefix("refs/nostr/") + .expect("partitioned ref"); + let track_placeholder = + match pre_validate_refs_nostr_push(database, purgatory, new_oid, ref_name, None) + .await + { + NostrRefPreValidation::Rejected { reason } => { + warn!("refs/nostr/ validation failed: {}", reason); + return Ok(AuthorizationResult::denied(reason)); } - } - NostrRefPreValidation::Unknown => { - // No entry in DB or purgatory — create placeholder so - // the 30-minute sweep can clean the ref up if the PR - // event never arrives. Standard-endpoint placeholders - // carry no /prs/ scope. - let event_id_hex = ref_name - .strip_prefix("refs/nostr/") - .expect("shape validated in pre_validate_refs_nostr_push"); - purgatory.add_pr_placeholder(event_id_hex.to_string(), new_oid.clone()); - debug!( - "Created placeholder for {} - awaiting PR event (will expire in 30min if event doesn't arrive)", - event_id_hex - ); - } + NostrRefPreValidation::Authorized { + event_from_purgatory, + signed, + } => { + if !signed { + unsigned_refs.insert(ref_name.clone()); + } + if let Some(event) = event_from_purgatory { + purgatory_events.push(event); + false + } else { + purgatory.find_pr_placeholder(event_id).is_some() + } + } + NostrRefPreValidation::Unknown => { + unsigned_refs.insert(ref_name.clone()); + true + } + }; + if track_placeholder { + // Remember every destination, including subsequent pushes of the + // same pending event, so expiry can delete only those exact refs. + purgatory.add_standard_pr_placeholder( + event_id.to_string(), + new_oid.clone(), + PublicKey::from_hex(selected_pubkey)?, + identifier.to_string(), + ); } } } @@ -180,6 +191,7 @@ pub async fn authorize_push( state: auth_result.state, maintainers: auth_result.maintainers, purgatory_events, + unsigned_refs, }); } @@ -190,6 +202,7 @@ pub async fn authorize_push( state: None, maintainers: vec![], purgatory_events, + unsigned_refs, }) } @@ -665,6 +678,7 @@ pub async fn get_state_authorization_for_selected_repo( state: None, maintainers: authorized.into_iter().collect(), purgatory_events: vec![], + unsigned_refs: HashSet::new(), }); } } @@ -736,6 +750,7 @@ pub async fn get_state_authorization_for_selected_repo( state: Some(state), maintainers: authorized.into_iter().collect(), purgatory_events: vec![latest_authorized.clone()], + unsigned_refs: HashSet::new(), }); } else { warn!( @@ -872,6 +887,9 @@ pub struct AuthorizationResult { pub maintainers: Vec, /// Events from purgatory that authorized this push (state, PR, PR-update events) pub purgatory_events: Vec, + /// Pushed `refs/nostr/` refs that no signed event names yet. Their objects + /// are received into view staging instead of the family. + pub unsigned_refs: HashSet, } impl AuthorizationResult { @@ -883,6 +901,7 @@ impl AuthorizationResult { state: None, maintainers: vec![], purgatory_events: vec![], + unsigned_refs: HashSet::new(), } } } @@ -1245,7 +1264,13 @@ pub enum NostrRefPreValidation { /// `authorize_push`) can collect it into `purgatory_events`. `None` /// means the match came from the DB or from a placeholder-only /// purgatory entry — nothing to collect. - Authorized { event_from_purgatory: Option }, + /// + /// `signed` is false for a placeholder-only match: no signed event names + /// this commit yet, so its objects have not earned family storage. + Authorized { + event_from_purgatory: Option, + signed: bool, + }, /// No event with that id is known to the relay yet. The caller may /// create a placeholder (with or without a `/prs/` scope) so the /// purgatory sweep can clean up the ref if the event never arrives. @@ -1315,6 +1340,7 @@ pub async fn pre_validate_refs_nostr_push( } return NostrRefPreValidation::Authorized { event_from_purgatory: None, + signed: true, }; } Ok(None) => {} @@ -1341,6 +1367,7 @@ pub async fn pre_validate_refs_nostr_push( } return NostrRefPreValidation::Authorized { event_from_purgatory: Some(event), + signed: true, }; } None => { @@ -1364,6 +1391,7 @@ pub async fn pre_validate_refs_nostr_push( } return NostrRefPreValidation::Authorized { event_from_purgatory: None, + signed: false, }; } } diff --git a/src/git/authorization_integrity.rs b/src/git/authorization_integrity.rs index 526b72d..0ebea9a 100644 --- a/src/git/authorization_integrity.rs +++ b/src/git/authorization_integrity.rs @@ -601,6 +601,20 @@ fn build_expectation( for (event_id, entry) in pending_prs { let reference = format!("refs/nostr/{event_id}"); + if entry.event.is_none() { + if let ViewIdentity::Owner { pubkey } = identity { + if let Some(record) = entry + .standard_refs + .iter() + .find(|record| record.owner == *pubkey && record.identifier == identifier) + { + expected + .pending_pr_refs + .insert(reference, record.commit.clone()); + continue; + } + } + } match &entry.event { Some(event) if event_applies_to_view( @@ -620,7 +634,10 @@ fn build_expectation( .pending_pr_refs .insert(reference, entry.commit.clone()); } - None if entry.prs_scope.is_none() && matches!(identity, ViewIdentity::Owner { .. }) => { + None if entry.prs_scope.is_none() + && entry.standard_refs.is_empty() + && matches!(identity, ViewIdentity::Owner { .. }) => + { // Legacy standard-endpoint placeholders did not record their // owner/identifier. Preserve a matching ref, but make the // uncertainty visible rather than treating it as authority. @@ -1574,6 +1591,41 @@ mod tests { assert_eq!(expectation.pr_refs.len(), 1); } + #[test] + fn standard_placeholder_authorizes_only_recorded_owner_and_identifier() { + let owner = Keys::generate().public_key(); + let other = Keys::generate().public_key(); + let purgatory = crate::purgatory::Purgatory::new(PathBuf::new()); + let id = "ab".repeat(32); + let commit = "cd".repeat(20); + purgatory.add_standard_pr_placeholder(id.clone(), commit.clone(), owner, "project".into()); + let pending = vec![(id.clone(), purgatory.find_pr(&id).unwrap())]; + let data = RepositoryData { + announcements: vec![], + states: vec![], + }; + for (pubkey, identifier, matches) in [ + (owner, "project", true), + (other, "project", false), + (owner, "other", false), + ] { + let expectation = build_expectation( + &ViewIdentity::Owner { pubkey }, + identifier, + &data, + &[], + &pending, + &[], + None, + ); + assert_eq!( + expectation.pending_pr_refs.get(&format!("refs/nostr/{id}")), + matches.then_some(&commit) + ); + assert!(expectation.ambiguous_placeholders.is_empty()); + } + } + #[test] fn owner_view_accepts_only_its_exact_standard_clone_endpoint() { let source_owner = Keys::generate(); diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 0d02c92..952ba97 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -710,7 +710,7 @@ pub async fn handle_receive_pack( ); // check push is authorised - let _auth_result = match authorize_push( + let auth_result = match authorize_push( &database, identifier, owner_pubkey, @@ -788,8 +788,16 @@ pub async fn handle_receive_pack( .map(|plan| &plan.forwarded_body) .unwrap_or(&request_body); - // Ref updates remain in the selected view; receive-pack's quarantine and - // final objects are installed directly in the shared family inventory. + let pushed_refs = parse_pushed_refs(&request_body); + let staged = if family_lease.is_some() { + stage_push(&repo_path, &pushed_refs, &auth_result.unsigned_refs).await? + } else { + false + }; + + // Ref updates remain in the selected view. Objects of a push backed by + // signed events are installed directly in the shared family inventory; + // a staged push keeps them in the view until promotion. let mut git = GitSubprocess::spawn_with_object_directory( GitService::ReceivePack, &repo_path, @@ -797,6 +805,7 @@ pub async fn handle_receive_pack( git_protocol, family_lease .as_ref() + .filter(|_| !staged) .map(|lease| lease.family_objects_path.as_path()), ) .map_err(GitError::ProcessSpawnFailed)?; @@ -817,7 +826,6 @@ pub async fn handle_receive_pack( // uploading. Streaming sideband progress keeps libgit2 clients from // hitting their per-recv timeout during that otherwise-silent window. let (tx, rx) = mpsc::channel::, io::Error>>(STREAM_CHANNEL_DEPTH); - let pushed_refs = parse_pushed_refs(&request_body); let new_oids: HashSet = pushed_refs .iter() .filter(|(_, new_oid, _)| new_oid != "0000000000000000000000000000000000000000") @@ -852,6 +860,7 @@ pub async fn handle_receive_pack( family_key, family_lease, pushed_refs, + staged, push_plan, ) .await; @@ -883,6 +892,7 @@ async fn stream_receive_pack_output( family_key: FamilyKey, family_lease: Option, pushed_refs: Vec<(String, String, String)>, + staged: bool, mut push_plan: Option, ) where I: tokio::io::AsyncWrite + Unpin + Send + 'static, @@ -1015,7 +1025,9 @@ async fn stream_receive_pack_output( debug!("Git receive-pack stream completed successfully"); - if family_lease.is_some() { + if staged { + settle_staged_push(&repo_path).await; + } else if family_lease.is_some() { retain_accepted_tips(&storage, &family_key, &repo_path, &pushed_refs); } @@ -1153,6 +1165,62 @@ impl std::fmt::Display for GitError { } } +/// Decide where a push to a thin view stores its objects, before Git runs. +/// +/// A push is staged in the view when it carries a ref that no signed event +/// names, or when the view already holds staged objects: the client may have +/// omitted objects that only staging holds. Signed tips of a staged push are +/// recorded as owed to the family first. The caller holds the family lease. +pub(crate) async fn stage_push( + repo_path: &std::path::Path, + pushed_refs: &[(String, String, String)], + unsigned_refs: &HashSet, +) -> Result { + if unsigned_refs.is_empty() && !super::staging::is_staged(repo_path) { + return Ok(false); + } + let owed: Vec<_> = pushed_refs + .iter() + .filter(|(_, new_oid, name)| { + !unsigned_refs.contains(name) && new_oid.bytes().any(|digit| digit != b'0') + }) + .map(|(_, new_oid, name)| super::staging::Tip::new(name, new_oid)) + .collect(); + let unsigned: Vec<_> = pushed_refs + .iter() + .filter(|(_, _, name)| unsigned_refs.contains(name)) + .map(|(_, oid, name)| super::staging::Tip::new(name, oid)) + .collect(); + let view = repo_path.to_owned(); + tokio::task::spawn_blocking(move || super::staging::stage_upload(&view, &owed, &unsigned)) + .await + .map_err(|error| GitError::Storage(error.to_string()))? + .map_err(|error| GitError::Storage(format!("cannot stage push: {error:#}"))) +} + +/// Promote the signed tips of a staged push while the family lease is held. +/// +/// A failure leaves the tips owed for staging maintenance to retry. The push +/// has already succeeded, so it is not reported to the client. +pub(crate) async fn settle_staged_push(repo_path: &std::path::Path) { + let view = repo_path.to_owned(); + match tokio::task::spawn_blocking(move || super::staging::settle(&view)).await { + Ok(Ok(0)) => {} + Ok(Ok(owed)) => warn!( + repo = %repo_path.display(), + owed, + "Signed history of a staged push is not yet in the family" + ), + Ok(Err(error)) => error!( + repo = %repo_path.display(), + error = %format!("{error:#}"), + "Failed to promote signed history of a staged push" + ), + Err(error) => error!(repo = %repo_path.display(), %error, "Staged push promotion panicked"), + } + super::staging::request_maintenance(repo_path); +} + pub(crate) fn retain_accepted_tips( storage: &LocalGitStorage, family_key: &FamilyKey, diff --git a/src/git/integrity.rs b/src/git/integrity.rs index bc82a2f..f0edb07 100644 --- a/src/git/integrity.rs +++ b/src/git/integrity.rs @@ -629,7 +629,9 @@ pub fn inspect_family(storage: &LocalGitStorage, key: &FamilyKey) -> Result Result<(), String> { } info!("Deleted ref {} from {}", ref_name, repo_path.display()); + staging::request_maintenance(repo_path); Ok(()) } diff --git a/src/git/staging.rs b/src/git/staging.rs new file mode 100644 index 0000000..75cc795 --- /dev/null +++ b/src/git/staging.rs @@ -0,0 +1,683 @@ +//! Unsigned uploads stay in their view until a signed event earns family storage. +//! +//! A push naming a `refs/nostr/` for which no signed event is known +//! writes its objects to the view's own object directory instead of the +//! identifier family. The family is never garbage-collected, so this keeps +//! abandoned uploads reclaimable. Accepting the PR event promotes the tip's +//! history into the family first. +//! +//! While a view holds staged objects every push to it is received into the +//! view, because a client may omit objects that only staging holds. Signed +//! tips of such pushes are recorded as *owed* before Git runs and stay owed +//! until their history is complete in the family alone. Compaction refuses to +//! run while anything is owed: the absence of a ref never proves that history +//! is disposable, since rollback depends on history no current ref names. +//! +//! Every function that reads or changes staged objects or the registry record +//! requires the caller to hold the family write lease. Pushes already hold it +//! for their whole duration, so it also excludes them from compaction. +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use anyhow::{bail, ensure, Context, Result}; +use bitcoin_hashes::{sha256, Hash}; +use serde::{Deserialize, Serialize}; + +use super::storage::{FamilyKey, LocalGitStorage, ObjectFormat}; + +mod recovery; +pub use recovery::recover_placeholders; +mod worker; +pub use worker::{request_maintenance, run_worker}; + +const REGISTRY_DIR: &str = "staging"; +const RETAINED_GLOB: &str = "--glob=refs/grasp/retained/*"; + +/// A signed ref tip whose history the family must hold. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tip { + pub reference: String, + pub oid: String, +} + +impl Tip { + pub fn new(reference: impl Into, oid: impl Into) -> Self { + Self { + reference: reference.into(), + oid: oid.into(), + } + } +} + +/// Persistent registration of one view holding staged objects. +#[derive(Debug, Default, Serialize, Deserialize)] +struct Record { + /// View path relative to the Git data root. + view: PathBuf, + #[serde(default)] + owed: Vec, + /// Unsigned ref intentions persisted before receive-pack, independently of + /// the periodic purgatory checkpoint. Wall-clock deadlines survive restarts. + #[serde(default)] + pending: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PendingUpload { + tip: Tip, + expires_at: std::time::SystemTime, +} + +/// Outcome of one compaction. +#[derive(Debug, PartialEq, Eq)] +pub enum Compaction { + /// No staged objects remain and the view is no longer registered. + Done, + /// Staging holds only history that live refs need and the family lacks. + Pending, +} + +/// A thin view resolved to its family and registry record. +struct View { + path: PathBuf, + storage: LocalGitStorage, + key: FamilyKey, + record: PathBuf, +} + +impl View { + /// `None` for a legacy repository that has no family alternate. + fn resolve(view: &Path) -> Result> { + let path = std::fs::canonicalize(view) + .with_context(|| format!("resolve view {}", view.display()))?; + let text = match std::fs::read_to_string(path.join("objects/info/alternates")) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let objects = PathBuf::from(text.lines().next().context("empty family alternate")?); + let repo = objects.parent().context("family repository")?; + let root = repo.ancestors().nth(4).context("family storage root")?; + let identifier = repo + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".git")) + .context("family identifier")?; + // The family path names its object format; no Git process is needed. + let format = match repo + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + { + Some("sha1") => ObjectFormat::Sha1, + Some("sha256") => ObjectFormat::Sha256, + _ => bail!("unsupported alternate for staging: {}", path.display()), + }; + let storage = LocalGitStorage::new(root); + let key = FamilyKey::new(format, identifier)?; + ensure!( + storage.family_objects_path(&key) == objects, + "unsupported alternate for staging: {}", + path.display() + ); + let root = std::fs::canonicalize(storage.git_data_path())?; + let relative = path + .strip_prefix(&root) + .context("view outside Git storage")?; + let digest = sha256::Hash::hash(relative.as_os_str().as_encoded_bytes()); + let record = registry_dir(&storage).join(format!("{digest}.json")); + Ok(Some(Self { + path, + storage, + key, + record, + })) + } + + fn family(&self) -> PathBuf { + self.storage.family_repo_path(&self.key) + } + + fn relative(&self) -> Result { + let root = std::fs::canonicalize(self.storage.git_data_path())?; + Ok(self.path.strip_prefix(root)?.to_owned()) + } + + fn load(&self) -> Result> { + match std::fs::read(&self.record) { + Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes).with_context(|| { + format!("parse staging record {}", self.record.display()) + })?)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + + fn save(&self, record: &Record) -> Result<()> { + let directory = self.record.parent().context("staging registry")?; + std::fs::create_dir_all(directory)?; + let mut file = tempfile::NamedTempFile::new_in(directory)?; + file.write_all(&serde_json::to_vec(record)?)?; + file.as_file().sync_all()?; + file.persist(&self.record)?; + File::open(directory)?.sync_all()?; + Ok(()) + } + + fn unregister(&self) -> Result<()> { + match std::fs::remove_file(&self.record) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + File::open(self.record.parent().context("staging registry")?)?.sync_all()?; + Ok(()) + } +} + +fn registry_dir(storage: &LocalGitStorage) -> PathBuf { + storage.internal_path().join(REGISTRY_DIR) +} + +fn valid_oid(oid: &str) -> bool { + matches!(oid.len(), 40 | 64) && oid.bytes().all(|c| c.is_ascii_hexdigit()) +} + +fn git(repo: &Path, args: &[&str], input: &[u8]) -> Result { + spawn_git(repo, args, input, Stdio::null()) +} + +/// Run Git and keep its output. Only for commands that print one short line +/// per input line, which the reader thread drains while input is written. +fn git_output(repo: &Path, args: &[&str], input: &[u8]) -> Result { + let output = spawn_git(repo, args, input, Stdio::piped())?; + ensure!( + output.status.success(), + "git {} failed in {}: {}", + args.first().copied().unwrap_or_default(), + repo.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(String::from_utf8(output.stdout)?) +} + +fn spawn_git(repo: &Path, args: &[&str], input: &[u8], stdout: Stdio) -> Result { + let mut child = Command::new("git") + .current_dir(repo) + .args(args) + .stdin(Stdio::piped()) + .stdout(stdout) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("spawn git {}", args.first().copied().unwrap_or_default()))?; + let mut stdin = child.stdin.take().expect("piped stdin"); + let input = input.to_owned(); + let writer = std::thread::spawn(move || stdin.write_all(&input)); + let output = child.wait_with_output()?; + match writer.join() { + Ok(Err(error)) if error.kind() != std::io::ErrorKind::BrokenPipe => { + return Err(error.into()) + } + Ok(_) => {} + Err(_) => bail!("git input writer panicked"), + } + Ok(output) +} + +fn run(repo: &Path, args: &[&str]) -> Result<()> { + let output = git(repo, args, b"")?; + ensure!( + output.status.success(), + "git {} failed in {}: {}", + args.first().copied().unwrap_or_default(), + repo.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +fn object_exists(repo: &Path, oid: &str) -> Result { + Ok(git(repo, &["cat-file", "-e", oid], b"")?.status.success()) +} + +/// Whether `oids` and their history are present in the family alone. +/// +/// The walk stops at retained roots. Those were complete when installed and +/// the family is append-only, so the cost follows the new history rather than +/// the whole repository. Detecting damage behind a root is the integrity +/// worker's job; a damaged root fails this check and therefore the promotion. +fn complete_in_family(family: &Path, oids: &[&str]) -> Result { + let mut input = Vec::new(); + for oid in oids { + input.extend_from_slice(oid.as_bytes()); + input.push(b'\n'); + } + input.extend_from_slice(format!("--not\n{RETAINED_GLOB}\n").as_bytes()); + let output = git( + family, + &["rev-list", "--objects", "--missing=error", "--stdin"], + &input, + )?; + Ok(output.status.success()) +} + +/// Whether the view is registered as holding staged objects. +/// +/// Reads one directory entry; safe without the family lease as a hint. A +/// decision that depends on it must be made while holding the lease. +pub fn is_staged(view: &Path) -> bool { + View::resolve(view) + .ok() + .flatten() + .is_some_and(|view| view.record.is_file()) +} + +/// Whether a staged view holds `oid` with all history the family lacks. +/// +/// The walk stops at the family base tips the view advertises, so its cost +/// follows the staged history. +pub fn holds_history(view: &Path, oid: &str) -> bool { + valid_oid(oid) + && is_staged(view) + && git( + view, + &[ + "rev-list", + "--objects", + "--missing=error", + oid, + "--not", + "--alternate-refs", + ], + b"", + ) + .is_ok_and(|output| output.status.success()) +} + +/// Whether signed history is still owed to the family from this view. +/// +/// A view that owes history must not be removed, even with no refs left. +pub fn owes_history(view: &Path) -> bool { + match View::resolve(view).and_then(|view| view.map(|view| view.load()).transpose()) { + Ok(record) => record + .flatten() + .is_some_and(|record| !record.owed.is_empty()), + // Unreadable ownership metadata is never permission to delete. + Err(_) => true, + } +} + +/// Register the view and record `owed` tips before Git can write any object. +/// +/// Returns `false` for a legacy repository without a family, which keeps its +/// own objects and is never staged. +pub fn stage(view: &Path, owed: &[Tip]) -> Result { + stage_upload(view, owed, &[]) +} + +/// Persist unsigned ref intentions and their expiry before receiving objects. +pub fn stage_upload(view: &Path, owed: &[Tip], unsigned: &[Tip]) -> Result { + let Some(view) = View::resolve(view)? else { + return Ok(false); + }; + ensure!( + owed.iter().all(|tip| valid_oid(&tip.oid)), + "invalid owed object ID" + ); + let mut record = match view.load()? { + Some(record) => record, + None => { + // Whatever the view holds before it is first staged predates + // staging and cannot be told apart from accepted history. + absorb_local_objects(&view)?; + Record::default() + } + }; + record.view = view.relative()?; + let before = record.owed.len(); + for tip in owed { + if !record.owed.contains(tip) { + record.owed.push(tip.clone()); + } + } + for tip in unsigned { + ensure!(valid_oid(&tip.oid), "invalid pending object ID"); + let id = tip + .reference + .strip_prefix("refs/nostr/") + .context("invalid pending ref")?; + ensure!( + nostr_sdk::prelude::EventId::from_hex(id).is_ok(), + "invalid pending event ID" + ); + record + .pending + .retain(|pending| pending.tip.reference != tip.reference); + record.pending.push(PendingUpload { + tip: tip.clone(), + expires_at: std::time::SystemTime::now() + crate::purgatory::DEFAULT_EXPIRY, + }); + } + if before != record.owed.len() || !unsigned.is_empty() || !view.record.is_file() { + view.save(&record)?; + } + Ok(true) +} + +/// Move every object file of the view into the family. +/// +/// Files are linked into the family before they are removed from the view, so +/// a concurrent reader of the view always finds each object in one of them. +fn absorb_local_objects(view: &View) -> Result<()> { + let source = view.path.join("objects"); + let target = view.storage.family_objects_path(&view.key); + let mut moved = Vec::new(); + for entry in std::fs::read_dir(&source)? { + let entry = entry?; + let name = entry.file_name(); + if name == "info" || !entry.file_type()?.is_dir() { + continue; + } + let directory = target.join(&name); + let mut files: Vec<_> = std::fs::read_dir(entry.path())? + .map(|file| file.map(|file| file.path())) + .collect::>()?; + // Git finds a pack through its index, so the index arrives last. + files.sort_by_key(|file| file.extension().is_some_and(|ext| ext == "idx")); + for file in files { + if !file.is_file() { + continue; + } + std::fs::create_dir_all(&directory)?; + let destination = directory.join(file.file_name().context("object file name")?); + match std::fs::hard_link(&file, &destination) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + moved.push(file); + } + File::open(&directory)?.sync_all()?; + } + if moved.is_empty() { + return Ok(()); + } + File::open(&target)?.sync_all()?; + tracing::info!( + view = %view.path.display(), + files = moved.len(), + "Moved existing view objects into the family before staging" + ); + // Remove indexes first, the reverse of the order they were installed in. + moved.sort_by_key(|file| file.extension().is_none_or(|ext| ext != "idx")); + for file in moved { + std::fs::remove_file(file)?; + } + Ok(()) +} + +/// Copy the history of `tips` into the family and retain it there. +/// +/// Succeeds only when every tip is complete in the family alone, which is the +/// condition for accepting the event that names it. +pub fn promote(view: &Path, tips: &[Tip]) -> Result<()> { + let Some(view) = View::resolve(view)? else { + return Ok(()); + }; + promote_resolved(&view, tips) +} + +fn promote_resolved(view: &View, tips: &[Tip]) -> Result<()> { + if tips.is_empty() { + return Ok(()); + } + ensure!( + tips.iter().all(|tip| valid_oid(&tip.oid)), + "invalid staged object ID" + ); + let family = view.family(); + let mut oids: Vec<&str> = tips.iter().map(|tip| tip.oid.as_str()).collect(); + oids.sort_unstable(); + oids.dedup(); + + if !complete_in_family(&family, &oids)? { + let source = view.path.to_str().context("non-UTF-8 view path")?; + let mut args = vec![ + "-c", + "uploadpack.allowAnySHA1InWant=true", + "fetch", + "--no-tags", + "--no-write-fetch-head", + "--no-auto-maintenance", + "--no-recurse-submodules", + source, + ]; + args.extend_from_slice(&oids); + run(&family, &args).context("copy staged history into the family")?; + ensure!( + complete_in_family(&family, &oids)?, + "promoted history is incomplete in the family" + ); + } + for tip in tips { + view.storage + .retain_tip(&view.key, &tip.reference, &tip.oid)?; + view.storage + .advertise_base_tip(&view.key, &tip.reference, &tip.oid)?; + } + Ok(()) +} + +/// Promote every owed tip and return how many remain owed. +/// +/// A tip whose object is in neither store belongs to a push that never +/// completed and is dropped. Failures leave their tips owed for a retry. +pub fn settle(view: &Path) -> Result { + let Some(view) = View::resolve(view)? else { + return Ok(0); + }; + settle_resolved(&view) +} + +fn settle_resolved(view: &View) -> Result { + let Some(mut record) = view.load()? else { + return Ok(0); + }; + if record.owed.is_empty() { + return Ok(0); + } + let mut present = Vec::new(); + for tip in std::mem::take(&mut record.owed) { + if object_exists(&view.path, &tip.oid)? { + present.push(tip); + } else { + tracing::debug!( + view = %view.path.display(), + reference = %tip.reference, + oid = %tip.oid, + "Dropping owed tip of a push that did not complete" + ); + } + } + // One fetch covers the usual case. After a failure retry each tip, so one + // unavailable tip cannot keep independent history out of the family. + if let Err(error) = promote_resolved(view, &present) { + let batch = present.len() > 1; + for tip in present { + let failed = if batch { + promote_resolved(view, std::slice::from_ref(&tip)).err() + } else { + None + }; + if !batch || failed.is_some() { + tracing::warn!( + view = %view.path.display(), + reference = %tip.reference, + oid = %tip.oid, + error = %failed.as_ref().unwrap_or(&error), + "Signed history remains staged; promotion will retry" + ); + record.owed.push(tip); + } + } + } + view.save(&record)?; + Ok(record.owed.len()) +} + +/// Shrink staging to the history that live refs need and the family lacks. +/// +/// Refuses to run while any tip is owed. Every live ref is a root, so a +/// pending upload keeps exactly its own history. +pub fn compact(view: &Path) -> Result { + let Some(view) = View::resolve(view)? else { + return Ok(Compaction::Done); + }; + if !view.record.is_file() { + return Ok(Compaction::Done); + } + // Bound the journal to surviving refs. Failed or expired uploads must not + // accumulate metadata while a sibling keeps the view staged indefinitely. + if let Some(mut record) = view.load()? { + let refs: std::collections::HashMap<_, _> = super::list_refs(&view.path) + .map_err(anyhow::Error::msg)? + .into_iter() + .collect(); + let before = record.pending.len(); + record + .pending + .retain(|pending| refs.get(&pending.tip.reference) == Some(&pending.tip.oid)); + if record.pending.len() != before { + view.save(&record)?; + } + } + let owed = settle_resolved(&view)?; + if owed > 0 { + bail!("{owed} signed tips are still owed to the family"); + } + run( + &view.path, + &["repack", "-a", "-d", "-l", "-q", "--no-write-bitmap-index"], + )?; + run(&view.path, &["prune", "--expire=now"])?; + remove_loose_copies(&view)?; + if has_local_objects(&view.path)? { + return Ok(Compaction::Pending); + } + view.unregister()?; + Ok(Compaction::Done) +} + +/// Remove loose objects that the family also holds. +/// +/// Repacking leaves them behind: they are reachable, so pruning keeps them, +/// and `--local` keeps them out of the new pack. +fn remove_loose_copies(view: &View) -> Result<()> { + let objects = view.path.join("objects"); + let mut loose = Vec::new(); + for entry in std::fs::read_dir(&objects)? { + let entry = entry?; + let prefix = entry.file_name(); + let Some(prefix) = prefix.to_str().filter(|name| name.len() == 2) else { + continue; + }; + for object in std::fs::read_dir(entry.path())? { + let object = object?; + if let Some(rest) = object.file_name().to_str() { + let oid = format!("{prefix}{rest}"); + if valid_oid(&oid) { + loose.push((oid, object.path())); + } + } + } + } + if loose.is_empty() { + return Ok(()); + } + let mut input = Vec::new(); + for (oid, _) in &loose { + input.extend_from_slice(oid.as_bytes()); + input.push(b'\n'); + } + let report = git_output( + &view.family(), + &["cat-file", "--batch-check=%(objectname)"], + &input, + )?; + ensure!( + report.lines().count() == loose.len(), + "unexpected object report from the family" + ); + for ((oid, path), line) in loose.iter().zip(report.lines()) { + if line == oid { + std::fs::remove_file(path)?; + } + } + Ok(()) +} + +fn has_local_objects(view: &Path) -> Result { + for entry in std::fs::read_dir(view.join("objects"))? { + let entry = entry?; + let name = entry.file_name(); + if name == "info" || !entry.file_type()?.is_dir() { + continue; + } + if std::fs::read_dir(entry.path())?.next().is_some() { + return Ok(true); + } + } + Ok(false) +} + +/// Promote an accepted tip out of staging, taking the family lease. +/// +/// A view without staged objects received its history into the family, so +/// there is nothing to copy and no lease is taken. +pub async fn promote_accepted(view: &Path, tip: Tip) -> Result<()> { + promote_accepted_tips(view, vec![tip]).await +} + +/// Promote every concrete State tip before accepting or releasing the event. +/// A State can adopt an unsigned upload without a subsequent Git push. +pub async fn promote_state( + view: &Path, + state: &crate::nostr::events::RepositoryState, +) -> Result<()> { + let tips = state + .branches + .iter() + .map(|branch| Tip::new(format!("refs/heads/{}", branch.name), &branch.commit)) + .chain( + state + .tags + .iter() + .map(|tag| Tip::new(format!("refs/tags/{}", tag.name), &tag.commit)), + ) + .filter(|tip| !tip.oid.starts_with("ref: ")) + .collect(); + promote_accepted_tips(view, tips).await +} + +async fn promote_accepted_tips(view: &Path, tips: Vec) -> Result<()> { + if !is_staged(view) { + return Ok(()); + } + let Some(resolved) = View::resolve(view)? else { + return Ok(()); + }; + let lease = resolved.storage.write_lease(&resolved.key).await?; + let path = resolved.path.clone(); + tokio::task::spawn_blocking(move || { + let _lease = lease; + promote(&path, &tips) + }) + .await??; + request_maintenance(view); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/git/staging/recovery.rs b/src/git/staging/recovery.rs new file mode 100644 index 0000000..62d921c --- /dev/null +++ b/src/git/staging/recovery.rs @@ -0,0 +1,323 @@ +//! Restore unsigned-upload expiry independently of the purgatory checkpoint. +use std::path::Path; +use std::time::SystemTime; + +use anyhow::{ensure, Context, Result}; +use nostr_sdk::prelude::{EventId, FromBech32, PublicKey}; + +use super::{registry_dir, PendingUpload, Record, Tip, View}; +use crate::git::storage::LocalGitStorage; +use crate::nostr::SharedDatabase; +use crate::purgatory::Purgatory; + +/// Called during startup before background writers and request handling. +/// Only surviving refs without a known signed event become placeholders. +/// Legacy records get one persisted grace period, never a fresh one per boot. +pub async fn recover_placeholders( + storage: &LocalGitStorage, + purgatory: &Purgatory, + database: &SharedDatabase, +) -> Result<()> { + let entries = match std::fs::read_dir(registry_dir(storage)) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(error) => { + tracing::error!(%error, "Cannot read staging registry entry; skipping expiry recovery for it"); + continue; + } + }; + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + if let Err(error) = recover_record(&path, storage, purgatory, database).await { + tracing::error!(record = %path.display(), error = %format!("{error:#}"), + "Cannot recover staged upload expiry; leaving record and data for inspection"); + } + } + Ok(()) +} + +// Failure is local to this record: without a recovered placeholder, its live +// refs remain protected by compaction. Do not delete or rename suspect metadata. +async fn recover_record( + path: &Path, + storage: &LocalGitStorage, + purgatory: &Purgatory, + database: &SharedDatabase, +) -> Result<()> { + let mut record: Record = serde_json::from_slice(&std::fs::read(path)?)?; + ensure!( + !record.view.as_os_str().is_empty() + && record + .view + .components() + .all(|part| matches!(part, std::path::Component::Normal(_))), + "invalid staging view path" + ); + let view_path = storage.git_data_path().join(&record.view); + if !view_path.try_exists()? { + return Ok(()); + } + let view = View::resolve(&view_path)?.context("staged view has no family")?; + ensure!( + view.record == std::fs::canonicalize(path)?, + "staging registry path does not match view" + ); + let parts: Vec<_> = record + .view + .iter() + .map(|part| part.to_str().context("non-UTF-8 view path")) + .collect::>()?; + let (owner, prs) = match parts.as_slice() { + [owner, _] => (PublicKey::from_bech32(owner)?, false), + ["prs", owner, _] => (PublicKey::from_hex(owner)?, true), + _ => anyhow::bail!("unsupported staged view path"), + }; + let refs = crate::git::list_refs(&view.path).map_err(anyhow::Error::msg)?; + for (reference, oid) in refs { + let Some(id) = reference.strip_prefix("refs/nostr/") else { + continue; + }; + let Ok(event_id) = EventId::from_hex(id) else { + continue; + }; + if database.event_by_id(&event_id).await?.is_some() + || purgatory + .find_pr(id) + .is_some_and(|entry| entry.event.is_some()) + { + continue; + } + let tip = Tip::new(&reference, &oid); + let expires_at = + if let Some(pending) = record.pending.iter().find(|pending| pending.tip == tip) { + pending.expires_at + } else { + // Older registry versions recorded only the view and owed tips. + let expires_at = SystemTime::now() + crate::purgatory::DEFAULT_EXPIRY; + record + .pending + .retain(|pending| pending.tip.reference != reference); + record.pending.push(PendingUpload { tip, expires_at }); + view.save(&record)?; + expires_at + }; + purgatory.recover_upload_placeholder( + id.to_owned(), + oid, + (owner, view.key.identifier.clone()), + prs, + expires_at, + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::tests::{commit, reference, Fixture, PENDING}; + use super::super::{compact, object_exists, stage, stage_upload}; + use super::*; + use nostr_sdk::prelude::*; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + fn database() -> SharedDatabase { + Arc::new(nostr_memory::MemoryDatabase::unbounded()) + } + + fn fixture(prs: bool) -> (Fixture, PublicKey, String) { + let owner = Keys::generate().public_key(); + let parent = if prs { + format!("prs/{}", owner.to_hex()) + } else { + owner.to_bech32().unwrap() + }; + let fixture = Fixture::with_view(&parent); + stage(&fixture.view, &[]).unwrap(); + let oid = commit(&fixture.view, "pending", None); + stage_upload(&fixture.view, &[], &[Tip::new(PENDING, &oid)]).unwrap(); + reference(&fixture.view, PENDING, &oid); + (fixture, owner, oid) + } + + #[tokio::test] + async fn bad_records_do_not_block_healthy_recovery_or_delete_data() { + for corrupt in [true, false] { + let (fixture, _, _) = fixture(false); + let bad_path = fixture + .storage + .git_data_path() + .join("unexpected/nested/owner/repo.git"); + std::fs::create_dir_all(bad_path.parent().unwrap()).unwrap(); + fixture + .storage + .create_thin_view(&fixture.key, &bad_path) + .unwrap(); + stage(&bad_path, &[]).unwrap(); + let oid = commit(&bad_path, "bad record upload", None); + let bad_ref = super::super::tests::ABANDONED; + stage_upload(&bad_path, &[], &[Tip::new(bad_ref, &oid)]).unwrap(); + reference(&bad_path, bad_ref, &oid); + let record_path = View::resolve(&bad_path).unwrap().unwrap().record; + if corrupt { + std::fs::write(&record_path, b"{broken json").unwrap(); + } + let before = std::fs::read(&record_path).unwrap(); + let purgatory = Purgatory::new(fixture.storage.git_data_path()); + recover_placeholders(&fixture.storage, &purgatory, &database()) + .await + .unwrap(); + assert!(purgatory + .find_pr(PENDING.trim_start_matches("refs/nostr/")) + .is_some()); + assert!(purgatory + .find_pr(bad_ref.trim_start_matches("refs/nostr/")) + .is_none()); + assert_eq!( + crate::git::get_ref_commit(&bad_path, bad_ref), + Some(oid.clone()) + ); + assert!(object_exists(&bad_path, &oid).unwrap()); + assert_eq!(std::fs::read(&record_path).unwrap(), before); + } + } + + #[tokio::test] + async fn uncheckpointed_uploads_recover_original_expiry_and_are_reclaimed() { + for prs in [false, true] { + let (fixture, owner, oid) = fixture(prs); + let view = View::resolve(&fixture.view).unwrap().unwrap(); + let mut record = view.load().unwrap().unwrap(); + // Inject an elapsed deadline rather than waiting thirty minutes. + record.pending[0].expires_at = SystemTime::UNIX_EPOCH; + view.save(&record).unwrap(); + let db = database(); + // Repeated recovery must not refresh the durable deadline. + for _ in 0..2 { + let purgatory = Purgatory::new(fixture.storage.git_data_path()); + recover_placeholders(&fixture.storage, &purgatory, &db) + .await + .unwrap(); + let entry = purgatory + .find_pr(PENDING.trim_start_matches("refs/nostr/")) + .unwrap(); + assert!(entry.expires_at <= Instant::now()); + assert_eq!(entry.commit, oid); + if prs { + assert_eq!(entry.prs_scope.unwrap().submitter, owner); + } else { + assert_eq!(entry.standard_refs[0].owner, owner); + } + } + let purgatory = Purgatory::new(fixture.storage.git_data_path()); + purgatory.set_prs_cleanup_ctx(crate::purgatory::PrsCleanupCtx { + git_data_path: fixture.storage.git_data_path().to_owned(), + repo_init_locks: Default::default(), + }); + recover_placeholders(&fixture.storage, &purgatory, &db) + .await + .unwrap(); + purgatory + .cleanup_standard_pr_refs( + &crate::nostr::lifecycle::RepositoryLifecycle::in_memory(), + &db, + ) + .await; + purgatory.cleanup(); + if prs { + assert!(!fixture.view.exists()); + } else { + compact(&fixture.view).unwrap(); + assert!(!object_exists(&fixture.view, &oid).unwrap()); + } + } + } + + #[tokio::test] + async fn legacy_registry_gets_only_one_persisted_grace_period() { + let (fixture, _, _) = fixture(false); + let view = View::resolve(&fixture.view).unwrap().unwrap(); + let mut record = view.load().unwrap().unwrap(); + record.pending.clear(); + view.save(&record).unwrap(); + let db = database(); + let purgatory = Purgatory::new(fixture.storage.git_data_path()); + recover_placeholders(&fixture.storage, &purgatory, &db) + .await + .unwrap(); + let deadline = view.load().unwrap().unwrap().pending[0].expires_at; + assert!(deadline > SystemTime::now()); + let restarted = Purgatory::new(fixture.storage.git_data_path()); + recover_placeholders(&fixture.storage, &restarted, &db) + .await + .unwrap(); + assert_eq!( + view.load().unwrap().unwrap().pending[0].expires_at, + deadline + ); + } + + #[tokio::test] + async fn recovery_preserves_signed_events_and_ignores_failed_ref_updates() { + let (fixture, _, oid) = fixture(false); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::GitPullRequest, "signed") + .tags([Tag::custom("c", [&oid])]) + .finalize(&keys) + .unwrap(); + let reference_name = format!("refs/nostr/{}", event.id); + stage_upload(&fixture.view, &[], &[Tip::new(&reference_name, &oid)]).unwrap(); + reference(&fixture.view, &reference_name, &oid); + let db = database(); + let purgatory = Purgatory::new(fixture.storage.git_data_path()); + purgatory.add_pr(event.clone(), event.id.to_hex(), oid.clone(), false); + recover_placeholders(&fixture.storage, &purgatory, &db) + .await + .unwrap(); + assert_eq!( + purgatory.find_pr(&event.id.to_hex()).unwrap().event, + Some(event.clone()) + ); + db.save_event(&event).await.unwrap(); + let restarted = Purgatory::new(fixture.storage.git_data_path()); + recover_placeholders(&fixture.storage, &restarted, &db) + .await + .unwrap(); + assert!(restarted.find_pr(&event.id.to_hex()).is_none()); + assert!(object_exists(&fixture.view, &oid).unwrap()); + // Intent was persisted but receive-pack never installed this ref. + let failed = format!("refs/nostr/{}", "ab".repeat(32)); + stage_upload(&fixture.view, &[], &[Tip::new(&failed, &oid)]).unwrap(); + recover_placeholders(&fixture.storage, &restarted, &db) + .await + .unwrap(); + assert!(restarted + .find_pr(failed.trim_start_matches("refs/nostr/")) + .is_none()); + } + + #[tokio::test] + async fn recovery_preserves_a_newer_checkpoint_deadline() { + let (fixture, owner, oid) = fixture(false); + let purgatory = Purgatory::new(fixture.storage.git_data_path()); + let id = PENDING.trim_start_matches("refs/nostr/"); + purgatory.recover_upload_placeholder( + id.into(), + oid, + (owner, "repo".into()), + false, + SystemTime::now() + Duration::from_secs(3600), + ); + let deadline = purgatory.find_pr(id).unwrap().expires_at; + recover_placeholders(&fixture.storage, &purgatory, &database()) + .await + .unwrap(); + assert_eq!(purgatory.find_pr(id).unwrap().expires_at, deadline); + } +} diff --git a/src/git/staging/tests.rs b/src/git/staging/tests.rs new file mode 100644 index 0000000..aaf33da --- /dev/null +++ b/src/git/staging/tests.rs @@ -0,0 +1,307 @@ +use super::*; +use std::process::Command; + +pub(super) struct Fixture { + _root: tempfile::TempDir, + pub storage: LocalGitStorage, + pub key: FamilyKey, + pub view: PathBuf, +} + +impl Fixture { + pub fn new() -> Self { + Self::with_view("owner") + } + + /// A view at `/repo.git` below the Git data root. + pub fn with_view(parent: &str) -> Self { + let root = tempfile::tempdir().unwrap(); + let storage = LocalGitStorage::new(root.path().canonicalize().unwrap()); + let key = FamilyKey::sha1("repo").unwrap(); + storage.ensure_family(&key).unwrap(); + let view = storage.git_data_path().join(parent).join("repo.git"); + std::fs::create_dir_all(view.parent().unwrap()).unwrap(); + storage.create_thin_view(&key, &view).unwrap(); + Self { + _root: root, + storage, + key, + view, + } + } + + pub fn family(&self) -> PathBuf { + self.storage.family_repo_path(&self.key) + } + + /// Whether the family holds `oid` and its whole history by itself. + fn family_holds(&self, oid: &str) -> bool { + Command::new("git") + .current_dir(self.family()) + .args(["rev-list", "--objects", "--missing=error", oid]) + .output() + .unwrap() + .status + .success() + } +} + +fn output(repo: &Path, args: &[&str], input: &[u8]) -> String { + let mut child = Command::new("git") + .current_dir(repo) + .args(args) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +/// Write a commit with one unique blob into `repo`'s own object directory. +pub(super) fn commit(repo: &Path, content: &str, parent: Option<&str>) -> String { + let blob = output(repo, &["hash-object", "-w", "--stdin"], content.as_bytes()); + let tree = output( + repo, + &["mktree"], + format!("100644 blob {blob}\tfile\n").as_bytes(), + ); + let mut args = vec!["commit-tree", tree.as_str(), "-m", content]; + if let Some(parent) = parent { + args.extend(["-p", parent]); + } + output(repo, &args, b"") +} + +pub(super) fn reference(repo: &Path, name: &str, oid: &str) { + output(repo, &["update-ref", name, oid], b""); +} + +pub(super) fn delete_reference(repo: &Path, name: &str) { + output(repo, &["update-ref", "-d", name], b""); +} + +fn family_objects(fixture: &Fixture) -> String { + output( + &fixture.family(), + &["cat-file", "--batch-all-objects", "--batch-check"], + b"", + ) +} + +pub(super) const PENDING: &str = + "refs/nostr/1111111111111111111111111111111111111111111111111111111111111111"; +pub(super) const ABANDONED: &str = + "refs/nostr/2222222222222222222222222222222222222222222222222222222222222222"; + +#[test] +fn abandoned_upload_is_reclaimed_without_touching_the_family() { + let fixture = Fixture::new(); + let before = family_objects(&fixture); + assert!(stage(&fixture.view, &[]).unwrap()); + let tip = commit(&fixture.view, "abandoned", None); + reference(&fixture.view, ABANDONED, &tip); + + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Pending); + assert!(object_exists(&fixture.view, &tip).unwrap()); + assert!(is_staged(&fixture.view)); + + delete_reference(&fixture.view, ABANDONED); + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Done); + assert!(!object_exists(&fixture.view, &tip).unwrap()); + assert!(!is_staged(&fixture.view)); + assert_eq!(family_objects(&fixture), before); +} + +#[test] +fn pending_upload_keeps_its_history_when_a_sibling_is_reclaimed() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let parent = commit(&fixture.view, "pending parent", None); + let pending = commit(&fixture.view, "pending", Some(&parent)); + let abandoned = commit(&fixture.view, "abandoned", None); + reference(&fixture.view, PENDING, &pending); + reference(&fixture.view, ABANDONED, &abandoned); + // Pack both uploads together so reclaiming one must rewrite the pack. + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Pending); + + delete_reference(&fixture.view, ABANDONED); + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Pending); + + assert!(!object_exists(&fixture.view, &abandoned).unwrap()); + output( + &fixture.view, + &["rev-list", "--objects", "--missing=error", &pending], + b"", + ); + assert!(!fixture.family_holds(&pending)); +} + +#[test] +fn promotion_makes_history_complete_in_the_family_alone() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let parent = commit(&fixture.view, "parent", None); + let tip = commit(&fixture.view, "tip", Some(&parent)); + let unrelated = commit(&fixture.view, "unrelated", None); + reference(&fixture.view, PENDING, &tip); + reference(&fixture.view, ABANDONED, &unrelated); + + promote(&fixture.view, &[Tip::new(PENDING, &tip)]).unwrap(); + + assert!(fixture.family_holds(&tip)); + assert!( + !object_exists(&fixture.family(), &unrelated).unwrap(), + "promotion copies only the accepted history" + ); + delete_reference(&fixture.view, ABANDONED); + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Done); + // The view still serves the promoted ref, now through the family. + output( + &fixture.view, + &["rev-list", "--objects", "--missing=error", &tip], + b"", + ); +} + +#[test] +fn promotion_refuses_incomplete_history() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let parent = commit(&fixture.view, "parent", None); + let tip = commit(&fixture.view, "tip", Some(&parent)); + reference(&fixture.view, PENDING, &tip); + std::fs::remove_file( + fixture + .view + .join("objects") + .join(&parent[..2]) + .join(&parent[2..]), + ) + .unwrap(); + + assert!(promote(&fixture.view, &[Tip::new(PENDING, &tip)]).is_err()); + assert!(!fixture.family_holds(&tip)); + let retained = output( + &fixture.family(), + &["for-each-ref", "refs/grasp/retained/"], + b"", + ); + assert_eq!(retained, "", "no retention root for incomplete history"); +} + +#[test] +fn owed_history_survives_ref_deletion_until_the_family_holds_it() { + let fixture = Fixture::new(); + let tip_oid = { + // Record the signed tip before its objects exist, as a push does. + let probe = Fixture::new(); + commit(&probe.view, "signed", None) + }; + let owed = Tip::new("refs/heads/main", &tip_oid); + stage(&fixture.view, std::slice::from_ref(&owed)).unwrap(); + assert_eq!(commit(&fixture.view, "signed", None), tip_oid); + reference(&fixture.view, "refs/heads/main", &tip_oid); + + // Promotion cannot install its retention root while this lock exists. + let digest = sha256::Hash::hash(format!("refs/heads/main\0{tip_oid}").as_bytes()); + let lock = fixture + .family() + .join(format!("refs/grasp/retained/{digest}.lock")); + std::fs::create_dir_all(lock.parent().unwrap()).unwrap(); + std::fs::write(&lock, b"").unwrap(); + assert_eq!(settle(&fixture.view).unwrap(), 1); + + // A State deletion removes the ref. Rollback still needs the history. + delete_reference(&fixture.view, "refs/heads/main"); + assert!( + compact(&fixture.view).is_err(), + "owed tips block compaction" + ); + assert!(object_exists(&fixture.view, &tip_oid).unwrap()); + assert!(is_staged(&fixture.view)); + + std::fs::remove_file(&lock).unwrap(); + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Done); + assert!(fixture.family_holds(&tip_oid)); +} + +#[test] +fn owed_tip_of_a_push_that_never_completed_is_dropped() { + let fixture = Fixture::new(); + let absent = "0123456789012345678901234567890123456789"; + stage(&fixture.view, &[Tip::new("refs/heads/main", absent)]).unwrap(); + + assert_eq!(settle(&fixture.view).unwrap(), 0); + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Done); +} + +#[test] +fn one_unavailable_owed_tip_does_not_hold_back_another() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let parent = commit(&fixture.view, "broken parent", None); + let broken = commit(&fixture.view, "broken", Some(&parent)); + let sound = commit(&fixture.view, "sound", None); + stage( + &fixture.view, + &[ + Tip::new("refs/heads/broken", &broken), + Tip::new("refs/heads/sound", &sound), + ], + ) + .unwrap(); + std::fs::remove_file( + fixture + .view + .join("objects") + .join(&parent[..2]) + .join(&parent[2..]), + ) + .unwrap(); + + assert_eq!(settle(&fixture.view).unwrap(), 1); + assert!(fixture.family_holds(&sound)); +} + +#[test] +fn legacy_repository_without_a_family_is_never_staged() { + let root = tempfile::tempdir().unwrap(); + output(root.path(), &["init", "--bare", "legacy.git"], b""); + let legacy = root.path().join("legacy.git"); + + assert!(!stage(&legacy, &[]).unwrap()); + assert!(!is_staged(&legacy)); + assert_eq!(compact(&legacy).unwrap(), Compaction::Done); +} + +#[test] +fn objects_held_before_first_staging_move_to_the_family() { + let fixture = Fixture::new(); + let packed = commit(&fixture.view, "packed before staging", None); + reference(&fixture.view, "refs/heads/packed", &packed); + output(&fixture.view, &["repack", "-a", "-d", "-l", "-q"], b""); + delete_reference(&fixture.view, "refs/heads/packed"); + let loose = commit(&fixture.view, "loose before staging", None); + + stage(&fixture.view, &[]).unwrap(); + + assert!(fixture.family_holds(&packed)); + assert!(fixture.family_holds(&loose)); + assert!(!has_local_objects(&fixture.view).unwrap()); + // Neither commit has a ref, yet compaction cannot reach them any more. + assert_eq!(compact(&fixture.view).unwrap(), Compaction::Done); + assert!(fixture.family_holds(&packed)); + assert!(fixture.family_holds(&loose)); +} diff --git a/src/git/staging/worker.rs b/src/git/staging/worker.rs new file mode 100644 index 0000000..b186419 --- /dev/null +++ b/src/git/staging/worker.rs @@ -0,0 +1,266 @@ +//! Event-driven staging maintenance. +//! +//! Pushes, promotions and ref deletions request maintenance for their view. +//! The persistent registry restores those requests after a restart. A view +//! whose remaining staged objects belong to pending refs is parked until the +//! next request rather than polled. +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tokio::sync::Notify; +use tokio::time::Instant; + +use super::{compact, is_staged, registry_dir, Compaction, Record, View}; +use crate::git::storage::LocalGitStorage; +use crate::grasp06::receive::{remove_prs_repo_if_empty, RepoInitLocks}; + +/// How long maintenance queues behind active writers of the family before it +/// gives way. The lease is fair, so waiting longer would hold up later pushes. +const LEASE_WAIT: Duration = Duration::from_millis(250); +const FIRST_RETRY: Duration = Duration::from_secs(1); +const LAST_RETRY: Duration = Duration::from_secs(300); + +struct Work { + due: Instant, + /// Delay before the attempt after next; doubles on each failure. + retry: Duration, +} + +static QUEUE: LazyLock>> = LazyLock::new(Mutex::default); +static WAKE: Notify = Notify::const_new(); + +fn queue() -> std::sync::MutexGuard<'static, HashMap> { + QUEUE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Ask the worker to settle and compact a view. Cheap for unstaged views. +pub fn request_maintenance(view: &Path) { + if !is_staged(view) { + return; + } + let Ok(view) = std::fs::canonicalize(view) else { + return; + }; + queue().insert( + view, + Work { + due: Instant::now(), + retry: FIRST_RETRY, + }, + ); + WAKE.notify_waiters(); +} + +/// Queue every registered view and drop records of views that no longer exist. +fn recover(storage: &LocalGitStorage) -> Result<()> { + let entries = match std::fs::read_dir(registry_dir(storage)) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let path = entry?.path(); + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + let result = (|| -> Result<()> { + let record: Record = serde_json::from_slice(&std::fs::read(&path)?)?; + anyhow::ensure!( + !record.view.as_os_str().is_empty() + && record + .view + .components() + .all(|part| matches!(part, std::path::Component::Normal(_))), + "invalid view path" + ); + let view = storage.git_data_path().join(&record.view); + if view.try_exists()? { + request_maintenance(&view); + } else { + std::fs::remove_file(&path)?; + } + Ok(()) + })(); + if let Err(error) = result { + tracing::warn!(record = %path.display(), %error, "Cannot recover staged Git view"); + } + } + Ok(()) +} + +/// One maintenance attempt. An error means the attempt should be retried. +pub(super) async fn maintain(view: &Path, prs_locks: &RepoInitLocks) -> Result { + let Some(resolved) = View::resolve(view)? else { + return Ok(Compaction::Done); + }; + let lease = tokio::time::timeout(LEASE_WAIT, resolved.storage.write_lease(&resolved.key)) + .await + .context("family is busy")??; + let path = resolved.path.clone(); + let outcome = tokio::task::spawn_blocking(move || { + let _lease = lease; + compact(&path) + }) + .await??; + if outcome == Compaction::Done { + remove_prs_repo_if_empty(prs_locks, resolved.storage.git_data_path(), &resolved.path); + } + Ok(outcome) +} + +/// Run staging maintenance until the task is aborted. +pub async fn run_worker(storage: LocalGitStorage, prs_locks: RepoInitLocks) { + let root = match std::fs::canonicalize(storage.git_data_path()) { + Ok(root) => root, + Err(error) => { + tracing::warn!(%error, "Cannot start staging maintenance"); + return; + } + }; + let recovery = storage.clone(); + match tokio::task::spawn_blocking(move || recover(&recovery)).await { + Ok(Ok(())) => {} + result => tracing::warn!(?result, "Staging registry recovery failed"), + } + loop { + // Register for wake-ups before reading the queue, so a request made + // between the read and the wait is not missed. + let woken = WAKE.notified(); + tokio::pin!(woken); + woken.as_mut().enable(); + let now = Instant::now(); + let next = { + let mut queue = queue(); + let next = queue + .iter() + .filter(|(view, _)| view.starts_with(&root)) + .min_by_key(|(_, work)| work.due) + .map(|(view, work)| (view.clone(), work.due, work.retry)); + if let Some((view, due, _)) = &next { + if *due <= now { + queue.remove(view); + } + } + next + }; + match next { + Some((view, due, retry)) if due <= now => { + if !view.exists() { + continue; + } + if let Err(error) = maintain(&view, &prs_locks).await { + tracing::debug!(view = %view.display(), %error, "Staging maintenance will retry"); + // A request that arrived meanwhile is newer than this failure. + queue().entry(view).or_insert(Work { + due: Instant::now() + retry, + retry: (retry * 2).min(LAST_RETRY), + }); + } + } + Some((_, due, _)) => { + tokio::select! { + _ = tokio::time::sleep_until(due) => {} + _ = woken => {} + } + } + None => woken.await, + } + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::{commit, delete_reference, reference, Fixture, ABANDONED, PENDING}; + use super::super::{object_exists, stage}; + use super::*; + use crate::grasp06::receive::new_repo_init_locks; + + const DEADLINE: Duration = Duration::from_secs(10); + + async fn wait_until(description: &str, condition: impl Fn() -> bool) { + tokio::time::timeout(DEADLINE, async { + while !condition() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")); + } + + #[tokio::test] + async fn ref_deletion_wakes_the_worker_to_reclaim_an_abandoned_upload() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let pending = commit(&fixture.view, "pending", None); + let abandoned = commit(&fixture.view, "abandoned", None); + reference(&fixture.view, PENDING, &pending); + reference(&fixture.view, ABANDONED, &abandoned); + let worker = tokio::spawn(run_worker(fixture.storage.clone(), new_repo_init_locks())); + + crate::git::delete_ref(&fixture.view, ABANDONED).unwrap(); + + let view = fixture.view.clone(); + wait_until("the abandoned upload to be reclaimed", || { + !object_exists(&view, &abandoned).unwrap() + }) + .await; + assert!(object_exists(&fixture.view, &pending).unwrap()); + assert!(is_staged(&fixture.view), "a pending upload stays staged"); + worker.abort(); + } + + #[tokio::test] + async fn restart_recovers_a_view_registered_before_it() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let abandoned = commit(&fixture.view, "abandoned", None); + reference(&fixture.view, ABANDONED, &abandoned); + // The ref expired without a maintenance request, as after a crash. + delete_reference(&fixture.view, ABANDONED); + + let worker = tokio::spawn(run_worker(fixture.storage.clone(), new_repo_init_locks())); + + let view = fixture.view.clone(); + wait_until("the recovered view to be reclaimed", || !is_staged(&view)).await; + assert!(!object_exists(&fixture.view, &abandoned).unwrap()); + worker.abort(); + } + + #[tokio::test] + async fn maintenance_gives_way_to_a_busy_family_and_succeeds_later() { + let fixture = Fixture::new(); + stage(&fixture.view, &[]).unwrap(); + let abandoned = commit(&fixture.view, "abandoned", None); + let locks = new_repo_init_locks(); + + // A push holds the family lease for as long as it runs. + let push = fixture.storage.write_lease(&fixture.key).await.unwrap(); + assert!(maintain(&fixture.view, &locks).await.is_err()); + assert!(object_exists(&fixture.view, &abandoned).unwrap()); + + drop(push); + assert_eq!( + maintain(&fixture.view, &locks).await.unwrap(), + Compaction::Done + ); + assert!(!object_exists(&fixture.view, &abandoned).unwrap()); + } + + #[tokio::test] + async fn empty_prs_repository_is_removed_once_its_staging_is_reclaimed() { + let submitter = "a".repeat(64); + let fixture = Fixture::with_view(&format!("prs/{submitter}")); + stage(&fixture.view, &[]).unwrap(); + commit(&fixture.view, "abandoned", None); + + let outcome = maintain(&fixture.view, &new_repo_init_locks()).await; + + assert_eq!(outcome.unwrap(), Compaction::Done); + assert!(!fixture.view.exists()); + } +} diff --git a/src/git/storage.rs b/src/git/storage.rs index 72e188b..665dd6d 100644 --- a/src/git/storage.rs +++ b/src/git/storage.rs @@ -361,6 +361,20 @@ impl LocalGitStorage { command.env("GIT_OBJECT_DIRECTORY", self.family_objects_path(key)); } + /// Make a Git command run in `view` write objects to the view's family. + /// + /// A legacy repository without a family alternate keeps its own objects. + pub fn write_to_family_of(view: &Path, command: &mut Command) { + let alternates = std::fs::read_to_string(view.join("objects/info/alternates")); + if let Some(objects) = alternates + .ok() + .as_deref() + .and_then(|text| text.lines().next()) + { + command.env("GIT_OBJECT_DIRECTORY", objects); + } + } + /// Keep an accepted object tip reachable without exposing it as a view ref. pub fn retain_tip(&self, key: &FamilyKey, source_ref: &str, oid: &str) -> Result { self.update_internal_tip(key, RETAINED_REFS_PREFIX, source_ref, oid) diff --git a/src/git/subprocess.rs b/src/git/subprocess.rs index 54f9f9e..caa0d94 100644 --- a/src/git/subprocess.rs +++ b/src/git/subprocess.rs @@ -12,7 +12,8 @@ use super::protocol::GitService; /// Git subprocess wrapper pub struct GitSubprocess { - child: Child, + /// `None` once the child has been reaped. + child: Option, } impl GitSubprocess { @@ -74,6 +75,7 @@ impl GitSubprocess { cmd.arg("--stateless-rpc"); cmd.arg(repo_path); + cmd.kill_on_drop(true); cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); @@ -89,47 +91,68 @@ impl GitSubprocess { let child = cmd.spawn()?; - Ok(Self { child }) + Ok(Self { child: Some(child) }) } /// Get a mutable reference to stdin pub fn stdin(&mut self) -> Option<&mut (impl AsyncWrite + Unpin)> { - self.child.stdin.as_mut() + self.child.as_mut().expect("live child").stdin.as_mut() } /// Get a mutable reference to stdout pub fn stdout(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> { - self.child.stdout.as_mut() + self.child.as_mut().expect("live child").stdout.as_mut() } /// Get a mutable reference to stderr pub fn stderr(&mut self) -> Option<&mut (impl AsyncRead + Unpin)> { - self.child.stderr.as_mut() + self.child.as_mut().expect("live child").stderr.as_mut() } /// Take ownership of stdin pub fn take_stdin(&mut self) -> Option { - self.child.stdin.take() + self.child.as_mut().expect("live child").stdin.take() } /// Take ownership of stdout pub fn take_stdout(&mut self) -> Option { - self.child.stdout.take() + self.child.as_mut().expect("live child").stdout.take() } /// Take ownership of stderr pub fn take_stderr(&mut self) -> Option { - self.child.stderr.take() + self.child.as_mut().expect("live child").stderr.take() } /// Wait for the subprocess to complete pub async fn wait(mut self) -> std::io::Result { - self.child.wait().await + let result = self.child.as_mut().expect("live child").wait().await; + if result.is_ok() { + self.child.take(); + } + result } /// Kill the subprocess pub async fn kill(&mut self) -> std::io::Result<()> { - self.child.kill().await + self.child.as_mut().expect("live child").kill().await + } +} + +impl Drop for GitSubprocess { + fn drop(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + let _ = child.start_kill(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + // Blocking tasks cannot be aborted by task cancellation or runtime + // shutdown, so the killed child is always reaped. + let reaper = runtime.clone(); + runtime.spawn_blocking(move || { + let _ = reaper.block_on(child.wait()); + }); + } } } @@ -150,6 +173,25 @@ mod tests { dir } + #[tokio::test] + async fn dropped_subprocess_is_killed_and_reaped() { + let repo = create_bare_repo(); + let child = + GitSubprocess::spawn(GitService::ReceivePack, repo.path(), false, None).unwrap(); + let pid = child.child.as_ref().unwrap().id().unwrap(); + let process = std::path::PathBuf::from(format!("/proc/{pid}")); + assert!(process.exists()); + drop(child); + // A killed but unreaped child remains visible as a zombie. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while process.exists() { + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped child was not reaped"); + } + #[tokio::test] async fn test_spawn_upload_pack_advertise() { let repo = create_bare_repo(); diff --git a/src/git/sync.rs b/src/git/sync.rs index 2bcb260..a7c72f2 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs @@ -333,13 +333,18 @@ pub(crate) fn copy_single_commit_between_repos( target_repo.display() ); - let output = Command::new("git") + let mut fetch = Command::new("git"); + fetch .args([ "fetch", source_repo.to_str().ok_or("Invalid source path")?, commit_hash, ]) - .current_dir(target_repo) + .current_dir(target_repo); + // Copied history is accepted history: it belongs to the family, never to + // the target view's own object directory. + crate::git::storage::LocalGitStorage::write_to_family_of(target_repo, &mut fetch); + let output = fetch .output() .map_err(|e| format!("Failed to execute git fetch: {}", e))?; @@ -545,13 +550,16 @@ pub fn copy_missing_oids_between_repos( // Fetch each missing commit from source to target for commit in &missing_commits { - let output = Command::new("git") + let mut fetch = Command::new("git"); + fetch .args([ "fetch", source_repo.to_str().ok_or("Invalid source path")?, commit, ]) - .current_dir(target_repo) + .current_dir(target_repo); + crate::git::storage::LocalGitStorage::write_to_family_of(target_repo, &mut fetch); + let output = fetch .output() .map_err(|e| format!("Failed to execute git fetch: {}", e))?; @@ -1161,6 +1169,16 @@ async fn process_purgatory_state_events( "State event author authorized via maintainer set" ); + // An unsigned upload can satisfy a waiting State. Do not release it + // from purgatory until its history is durable independently of staging. + if let Err(error) = crate::git::staging::promote_state(source_repo_path, &state).await { + result.errors.push(format!( + "State {} history could not be stored durably: {error:#}", + entry.event.id + )); + continue; + } + // Use unified processing function let process_result = crate::git::process::process_state_with_git_data( &state, @@ -1501,6 +1519,16 @@ async fn process_purgatory_pr_events( let owner_pubkey = extract_owner_from_repo_path(source_repo_path, git_data_path).unwrap_or_default(); + // The event may only be accepted once the family holds its history. + let tip = crate::git::staging::Tip::new(format!("refs/nostr/{}", event.id), &entry.commit); + if let Err(error) = crate::git::staging::promote_accepted(source_repo_path, tip).await { + result.errors.push(format!( + "PR {} history could not be stored durably: {error:#}", + event.id + )); + continue; + } + // Use unified processing function let process_result = crate::git::process::process_pr_with_git_data( event, @@ -2411,4 +2439,67 @@ mod tests { let result = is_latest_authorized_state(&state, &maintainers, std::slice::from_ref(&state)); assert!(result); } + + /// Commit written into `repo`'s own object directory. + fn local_commit(repo: &Path, content: &str) -> String { + let run = |args: &[&str], input: &str| { + use std::io::Write; + let mut child = Command::new("git") + .current_dir(repo) + .args(args) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "git {args:?}"); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + }; + let blob = run(&["hash-object", "-w", "--stdin"], content); + let tree = run(&["mktree"], &format!("100644 blob {blob}\tfile\n")); + run(&["commit-tree", &tree, "-m", content], "") + } + + #[test] + fn copied_commit_is_written_to_the_family_not_the_target_view() { + use crate::git::storage::{FamilyKey, LocalGitStorage}; + + let root = tempfile::tempdir().unwrap(); + let storage = LocalGitStorage::new(root.path().canonicalize().unwrap()); + let key = FamilyKey::sha1("repo").unwrap(); + storage.ensure_family(&key).unwrap(); + let view = |owner: &str| { + let path = storage.git_data_path().join(owner).join("repo.git"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + storage.create_thin_view(&key, &path).unwrap(); + path + }; + let (source, target) = (view("source"), view("target")); + let commit = local_commit(&source, "only in the source view"); + let family = storage.family_repo_path(&key); + assert!(!oid_exists(&family, &commit)); + + copy_single_commit_between_repos(&source, &target, &commit).unwrap(); + + assert!(oid_exists(&family, &commit)); + let local: Vec<_> = std::fs::read_dir(target.join("objects")) + .unwrap() + .map(|entry| entry.unwrap()) + .filter(|entry| entry.file_name() != "info" && entry.file_name() != "pack") + .collect(); + assert!(local.is_empty(), "target view holds objects: {local:?}"); + assert!(std::fs::read_dir(target.join("objects/pack")) + .map(|mut packs| packs.next().is_none()) + .unwrap_or(true)); + } } diff --git a/src/grasp06/cleanup.rs b/src/grasp06/cleanup.rs index 52d85f1..5552c1b 100644 --- a/src/grasp06/cleanup.rs +++ b/src/grasp06/cleanup.rs @@ -110,6 +110,8 @@ pub fn scan_on_startup(git_data_path: &Path) -> (usize, usize) { } match list_refs(&repo_path) { + // Signed history still owed to the family outlives its refs. + Ok(refs) if refs.is_empty() && crate::git::staging::owes_history(&repo_path) => {} Ok(refs) if refs.is_empty() => { if let Err(e) = std::fs::remove_dir_all(&repo_path) { warn!( diff --git a/src/grasp06/receive.rs b/src/grasp06/receive.rs index bea8c49..231d102 100644 --- a/src/grasp06/receive.rs +++ b/src/grasp06/receive.rs @@ -79,8 +79,8 @@ use crate::git::authorization::{ use crate::git::handlers::{ build_git_protocol_error_response, err_pktline_frame, is_git_protocol_error, pump_receive_pack_stdout_to_channel, read_stderr_to_end, record_git_operation, - retain_accepted_tips, send_body_bytes, streaming_response, GitError, PumpResult, - STREAM_CHANNEL_DEPTH, + retain_accepted_tips, send_body_bytes, settle_staged_push, stage_push, streaming_response, + GitError, PumpResult, STREAM_CHANNEL_DEPTH, }; use crate::git::protocol::GitService; use crate::git::storage::{FamilyKey, FamilyWriteLease, LocalGitStorage}; @@ -231,6 +231,7 @@ pub async fn handle_prs_receive_pack( identifier: &prs.identifier, domain, }; + let mut unsigned_refs = HashSet::new(); for (_, new_oid, ref_name) in &pushed_refs { match pre_validate_refs_nostr_push( &database, @@ -255,7 +256,11 @@ pub async fn handle_prs_receive_pack( Some(&request_body), )); } - NostrRefPreValidation::Authorized { .. } | NostrRefPreValidation::Unknown => {} + NostrRefPreValidation::Authorized { signed: true, .. } => {} + NostrRefPreValidation::Authorized { signed: false, .. } + | NostrRefPreValidation::Unknown => { + unsigned_refs.insert(ref_name.clone()); + } } } @@ -301,12 +306,24 @@ pub async fn handle_prs_receive_pack( // write stdin here, then hand stdout/stderr and all follow-up state to a // detached task that feeds the response body channel. let use_family = storage.is_thin_view(&family_key, &repo_path); + let staged = if use_family { + match stage_push(&repo_path, &pushed_refs, &unsigned_refs).await { + Ok(staged) => staged, + Err(e) => { + finish_prs_receive_pack(&state, &repo_path); + record_git_operation(&metrics, "push", "error"); + return Err(e); + } + } + } else { + false + }; let mut git = match GitSubprocess::spawn_with_object_directory( GitService::ReceivePack, &repo_path, false, git_protocol, - use_family.then_some(&family_lease.family_objects_path), + (use_family && !staged).then_some(&family_lease.family_objects_path), ) .map_err(GitError::ProcessSpawnFailed) { @@ -370,6 +387,7 @@ pub async fn handle_prs_receive_pack( storage, family_key, use_family.then_some(family_lease), + staged, )); Ok(streaming_response(GitService::ReceivePack, rx)) @@ -436,26 +454,52 @@ fn ensure_repo_initialised( fn finish_prs_receive_pack(state: &PrsPathState, repo_path: &Path) { let _g = state.mu.lock().expect("prs path mutex poisoned"); state.in_flight.fetch_sub(1, Ordering::Relaxed); - if state.in_flight.load(Ordering::Relaxed) == 0 { - if let Ok(refs) = list_refs(repo_path) { - if refs.is_empty() { - if let Err(e) = std::fs::remove_dir_all(repo_path) { - warn!( - "/prs/ receive-pack: failed to clean up empty repo {}: {}", - repo_path.display(), - e - ); - } else { - debug!( - "/prs/ receive-pack: removed empty repo {} (no refs after push)", - repo_path.display() - ); - } - } + remove_idle_empty_repo(state, repo_path); +} + +/// Remove a `/prs/` repository that has no refs and no push in flight, so +/// abandoned repositories do not accumulate. Returns whether it was removed. +/// +/// The caller holds `state.mu`. That mutex also gates `in_flight` updates, so +/// a repository is never removed while a push is being received. +pub(crate) fn remove_idle_empty_repo(state: &PrsPathState, repo_path: &Path) -> bool { + if state.in_flight.load(Ordering::Relaxed) != 0 + || !matches!(list_refs(repo_path), Ok(refs) if refs.is_empty()) + || crate::git::staging::owes_history(repo_path) + { + return false; + } + match std::fs::remove_dir_all(repo_path) { + Ok(()) => { + debug!(repo = %repo_path.display(), "Removed zero-ref /prs/ repository"); + true + } + Err(error) => { + warn!( + repo = %repo_path.display(), + %error, + "Failed to remove zero-ref /prs/ repository" + ); + false } } } +/// [`remove_idle_empty_repo`] for callers that do not already hold the path +/// mutex. Paths outside `/prs/` are left alone. +pub fn remove_prs_repo_if_empty( + locks: &RepoInitLocks, + git_data_path: &Path, + repo_path: &Path, +) -> bool { + if !crate::grasp06::paths::is_prs_repo_path(repo_path, git_data_path) { + return false; + } + let state = path_state(locks, repo_path); + let _g = state.mu.lock().expect("prs path mutex poisoned"); + remove_idle_empty_repo(&state, repo_path) +} + /// Own the live `/prs/` receive-pack after the request handler has returned its /// streaming response. /// @@ -493,6 +537,7 @@ async fn stream_prs_receive_pack_output( storage: LocalGitStorage, family_key: FamilyKey, family_lease: Option, + staged: bool, ) where S: tokio::io::AsyncRead + Unpin + Send + 'static, E: tokio::io::AsyncRead + Unpin + Send + 'static, @@ -612,10 +657,16 @@ async fn stream_prs_receive_pack_output( .await; } - if family_lease.is_some() { + if staged { + settle_staged_push(&repo_path).await; + } else if family_lease.is_some() { retain_accepted_tips(&storage, &family_key, &repo_path, &pushed_refs); } + // Release the family writer before post-push processing: promoting a + // waiting event may need to re-enter this same identifier family. + drop(family_lease); + finish_prs_receive_pack(&state, &repo_path); // Drive the standard purgatory-release pipeline so PR events already diff --git a/src/nostr/lifecycle/deletion/pr_refs.rs b/src/nostr/lifecycle/deletion/pr_refs.rs index 84ca06e..cfba696 100644 --- a/src/nostr/lifecycle/deletion/pr_refs.rs +++ b/src/nostr/lifecycle/deletion/pr_refs.rs @@ -93,34 +93,16 @@ impl DeletionPolicy { .collect() } - fn cleanup_zero_ref_grasp06_repo_if_idle(&self, repo_path: &PathBuf) { - if !crate::grasp06::paths::is_prs_repo_path(repo_path, &self.ctx.git_data_path) { - return; - } - - let state = crate::grasp06::receive::path_state(&self.ctx.repo_init_locks, repo_path); - let _guard = state.mu.lock().expect("prs path mutex poisoned"); - - if state.in_flight.load(std::sync::atomic::Ordering::Relaxed) != 0 { - return; - } - - let is_zero_ref = matches!(git::list_refs(repo_path), Ok(refs) if refs.is_empty()); - if !is_zero_ref { - return; - } - - if let Err(e) = std::fs::remove_dir_all(repo_path) { - tracing::warn!( - repo = %repo_path.display(), - error = %e, - "Failed to remove zero-ref /prs/ repo while processing deletion request" - ); - return; - } - - if let Some(parent) = repo_path.parent() { - let _ = std::fs::remove_dir(parent); + fn cleanup_zero_ref_grasp06_repo_if_idle(&self, repo_path: &std::path::Path) { + let removed = crate::grasp06::receive::remove_prs_repo_if_empty( + &self.ctx.repo_init_locks, + &self.ctx.git_data_path, + repo_path, + ); + if removed { + if let Some(parent) = repo_path.parent() { + let _ = std::fs::remove_dir(parent); + } } } diff --git a/src/nostr/lifecycle/deletion/recovery.rs b/src/nostr/lifecycle/deletion/recovery.rs index e140ecd..2f88de7 100644 --- a/src/nostr/lifecycle/deletion/recovery.rs +++ b/src/nostr/lifecycle/deletion/recovery.rs @@ -188,9 +188,12 @@ impl DeletionService { }; if !crate::git::oid_exists(target_repo, oid) { - let fetch_output = Command::new("git") + let mut fetch = Command::new("git"); + fetch .args(["fetch", source_repo_str, oid]) - .current_dir(target_repo) + .current_dir(target_repo); + LocalGitStorage::write_to_family_of(target_repo, &mut fetch); + let fetch_output = fetch .output() .map_err(|e| anyhow::anyhow!("failed to fetch archived OID {oid}: {e}"))?; if !fetch_output.status.success() { diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs index 449b85d..67b8808 100644 --- a/src/nostr/policy/pr_event.rs +++ b/src/nostr/policy/pr_event.rs @@ -65,16 +65,52 @@ impl PrEventPolicy { } }; - // Check for placeholder first (git-data-first scenario) - if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) { - // Read the full entry so we can inspect any GRASP-06 scope - // recorded when the placeholder was created from a /prs/ push. - let prs_scope = self - .ctx - .purgatory - .find_pr(&event_id) - .and_then(|entry| entry.prs_scope); - + // A standard-endpoint placeholder is released only once the event's + // history is durable or no local upload serves the event. + let mut standard_placeholder = false; + // A crash may happen after Git installs the ref but before the + // purgatory checkpoint. Recover the /prs/ scope only from a signed, + // service-local clone URL for this signer and an exact event-id/OID ref. + let mut placeholder = self + .ctx + .purgatory + .find_pr_placeholder(&event_id) + .map(|commit| { + let scope = self + .ctx + .purgatory + .find_pr(&event_id) + .and_then(|entry| entry.prs_scope); + (commit, scope) + }); + if placeholder.is_none() && self.ctx.config.grasp06_enable { + for identifier in crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags( + event, + &self.ctx.config.service_address(), + ) { + if !git::validate_repository_identifier(&identifier) { + continue; + } + let path = crate::grasp06::paths::prs_repo_path( + &self.ctx.git_data_path, + &event.pubkey.to_hex(), + &identifier, + ); + if git::get_ref_commit(&path, &format!("refs/nostr/{event_id}")).as_deref() + == Some(commit.as_str()) + { + placeholder = Some(( + commit.clone(), + Some(crate::purgatory::PrsPlaceholderScope { + submitter: event.pubkey, + identifier, + }), + )); + break; + } + } + } + if let Some((placeholder_commit, prs_scope)) = placeholder { if let Some(scope) = prs_scope { // The placeholder was created by a /prs//.git // push (06.md line 12). The arriving event MUST be signed by @@ -146,29 +182,8 @@ impl PrEventPolicy { error = %e, "Failed to delete /prs/ ref while discarding scoped placeholder", ); - } else if state.in_flight.load(std::sync::atomic::Ordering::Relaxed) == 0 - && matches!( - crate::git::list_refs(&prs_repo), - Ok(refs) if refs.is_empty() - ) - { - // No refs left and no push in flight — the - // repo is now an empty husk. Drop the bare - // dir so /prs/ doesn't accumulate abandoned - // repos. Equivalent to the cleanup the - // receive handler does on push completion. - if let Err(e) = std::fs::remove_dir_all(&prs_repo) { - tracing::warn!( - repo = %prs_repo.display(), - error = %e, - "Failed to remove zero-ref /prs/ repo after discarding scoped placeholder", - ); - } else { - tracing::debug!( - repo = %prs_repo.display(), - "Removed zero-ref /prs/ repo after discarding scoped placeholder", - ); - } + } else { + crate::grasp06::receive::remove_idle_empty_repo(&state, &prs_repo); } } self.ctx.purgatory.remove_pr(&event_id); @@ -211,6 +226,11 @@ impl PrEventPolicy { ) .await?; + // The placeholder stays until the history is durable, so a + // failure here leaves the upload to expire normally. + self.promote_staged_history(&prs_repo, &event_id, &commit) + .await?; + let process_result = crate::git::process::process_pr_with_git_data( event, &commit, @@ -267,7 +287,7 @@ impl PrEventPolicy { event_id, commit ); - self.ctx.purgatory.remove_pr(&event_id); + standard_placeholder = true; } else { // Standard endpoint placeholder, mismatched commit — original // behaviour: incoming event supersedes. @@ -277,7 +297,7 @@ impl PrEventPolicy { commit, placeholder_commit ); - self.ctx.purgatory.remove_pr(&event_id); + standard_placeholder = true; // Delete incorrect git data (refs/nostr/) will be handled below } } @@ -285,6 +305,9 @@ impl PrEventPolicy { let repo_paths = self.find_relevant_repo_paths(event).await?; if repo_paths.is_empty() { + if standard_placeholder { + self.ctx.purgatory.remove_pr(&event_id); + } tracing::debug!("No repository paths found for PR event {}", event_id); return Ok(false); } @@ -345,6 +368,14 @@ impl PrEventPolicy { ) .unwrap_or_default(); + // The placeholder stays until the history is durable, so a + // failure here leaves the upload to expire normally. + self.promote_staged_history(&source_repo, &event_id, &commit) + .await?; + if standard_placeholder { + self.ctx.purgatory.remove_pr(&event_id); + } + // Use unified processing function let result = crate::git::process::process_pr_with_git_data( event, @@ -376,6 +407,9 @@ impl PrEventPolicy { Ok(true) } else { + if standard_placeholder { + self.ctx.purgatory.remove_pr(&event_id); + } tracing::debug!( "No git data found for PR event {} with commit {}", event_id, @@ -385,6 +419,31 @@ impl PrEventPolicy { } } + /// Make the history of an accepted PR tip durable in the family. + /// + /// An upload received before its event was known is staged in its view. + /// The event may only be accepted once the family alone holds the history. + async fn promote_staged_history( + &self, + repo: &std::path::Path, + event_id: &str, + commit: &str, + ) -> Result<()> { + let tip = git::staging::Tip::new(format!("refs/nostr/{event_id}"), commit); + git::staging::promote_accepted(repo, tip) + .await + .map_err(|error| { + tracing::error!( + event_id = %event_id, + commit = %commit, + repo = %repo.display(), + error = %format!("{error:#}"), + "Cannot store PR history durably; event not accepted" + ); + anyhow::anyhow!("PR history could not be stored durably") + }) + } + async fn find_relevant_repo_paths(&self, event: &Event) -> Result> { // Extract ALL `a` tags (repository references) from the PR event let repo_refs: Vec = event diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index f856d55..826624c 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs @@ -218,6 +218,12 @@ impl StatePolicy { event.id, ); + // Ref alignment can consume staged history without another push. + // Make it durable before accepting the State or moving any refs. + git::staging::promote_state(&repo_with_git_data, &state) + .await + .context("State history could not be stored durably")?; + // Use unified processing function let result = crate::git::process::process_state_with_git_data( &state, diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs index 1671d45..19a9525 100644 --- a/src/purgatory/mod.rs +++ b/src/purgatory/mod.rs @@ -40,7 +40,7 @@ use std::time::{Duration, Instant, SystemTime}; pub use sync::SyncQueueEntry; /// Default expiry duration for purgatory entries (30 minutes) -const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); +pub(crate) const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); /// Extended expiry for soft-expired announcements (24 hours). /// @@ -101,6 +101,8 @@ struct SerializablePrPurgatoryEntry { /// deserialisable. #[serde(default)] prs_scope: Option, + #[serde(default)] + standard_refs: Vec, } /// Serializable wrapper for `AnnouncementPurgatoryEntry` with time offsets. @@ -200,7 +202,7 @@ pub struct Purgatory { /// Stored as EventId (hex string) for efficient lookup. expired_events: Arc>, - _git_data_path: PathBuf, + git_data_path: PathBuf, /// Set once at startup by [`Purgatory::set_prs_cleanup_ctx`] to give /// [`Purgatory::cleanup`] enough information to remove abandoned @@ -234,7 +236,7 @@ impl Purgatory { pr_events: Arc::new(DashMap::new()), sync_queue: Arc::new(DashMap::new()), expired_events: Arc::new(DashMap::new()), - _git_data_path: git_data_path.into(), + git_data_path: git_data_path.into(), prs_cleanup_ctx: std::sync::OnceLock::new(), } } @@ -493,6 +495,7 @@ impl Purgatory { created_at: now, expires_at: now + DEFAULT_EXPIRY, source, + standard_refs: Vec::new(), prs_scope: None, }; @@ -521,12 +524,185 @@ impl Purgatory { created_at: now, expires_at: now + DEFAULT_EXPIRY, source: types::EventSource::Direct, // Git pushes are direct user actions + standard_refs: Vec::new(), prs_scope: None, }; self.pr_events.insert(event_id, entry); } + /// Record a normal-endpoint push without forgetting other copies of the ref. + pub fn add_standard_pr_placeholder( + &self, + event_id: String, + commit: String, + owner: PublicKey, + identifier: String, + ) { + let now = Instant::now(); + let mut entry = self + .pr_events + .entry(event_id) + .or_insert_with(|| PrPurgatoryEntry { + event: None, + commit: commit.clone(), + created_at: now, + expires_at: now + DEFAULT_EXPIRY, + source: types::EventSource::Direct, + prs_scope: None, + standard_refs: Vec::new(), + }); + if entry.event.is_some() { + return; + } + entry + .standard_refs + .retain(|r| r.owner != owner || r.identifier != identifier); + entry.standard_refs.push(types::StandardPrRef { + owner, + identifier, + commit, + }); + entry.expires_at = now + DEFAULT_EXPIRY; + } + + /// Restore a staged upload before serving requests. Never replace a signed + /// purgatory event or shorten a newer checkpoint's placeholder lifetime. + pub(crate) fn recover_upload_placeholder( + &self, + event_id: String, + commit: String, + destination: (PublicKey, String), + prs: bool, + expires_at: SystemTime, + ) { + let (owner, identifier) = destination; + let existing = self.find_pr(&event_id); + if existing.as_ref().is_some_and(|entry| entry.event.is_some()) { + return; + } + let deadline = Instant::now() + + expires_at + .duration_since(SystemTime::now()) + .unwrap_or_default(); + let deadline = existing.map_or(deadline, |entry| deadline.max(entry.expires_at)); + if prs { + self.add_prs_pr_placeholder(event_id.clone(), commit, owner, identifier); + } else { + self.add_standard_pr_placeholder(event_id.clone(), commit, owner, identifier); + } + if let Some(mut entry) = self.pr_events.get_mut(&event_id) { + entry.expires_at = deadline; + } + } + + /// Expire normal-endpoint placeholders under the same lifecycle locks as pushes. + /// Recheck the entry after waiting: an event or a new push may have arrived. + /// Failed Git operations retain the record for the next sweep. + pub async fn cleanup_standard_pr_refs( + &self, + lifecycle: &crate::nostr::lifecycle::RepositoryLifecycle, + database: &crate::nostr::SharedDatabase, + ) -> usize { + let candidates: Vec<_> = self + .pr_events + .iter() + .filter(|e| { + e.event.is_none() && !e.standard_refs.is_empty() && e.expires_at <= Instant::now() + }) + .map(|e| (e.key().clone(), e.standard_refs.clone())) + .collect(); + let mut removed = 0; + for (id, refs) in candidates { + let _guards = lifecycle + .write_repositories(refs.iter().map(|r| crate::nostr::lifecycle::RecoveryScope { + owner_pubkey_hex: r.owner.to_hex(), + identifier: r.identifier.clone(), + })) + .await; + // A checkpoint can retain a placeholder after its event was accepted. + // Missing metadata must never cause us to delete a now-authorized ref. + let accepted = match EventId::from_hex(&id) { + Ok(event_id) => match database.event_by_id(&event_id).await { + Ok(event) => event.is_some(), + Err(error) => { + tracing::warn!(%id, %error, "Cannot check PR acceptance; deferring ref expiry"); + continue; + } + }, + Err(_) => continue, + }; + // Holding the map entry serializes event arrival/removal and placeholder refresh. + let dashmap::mapref::entry::Entry::Occupied(mut slot) = + self.pr_events.entry(id.clone()) + else { + continue; + }; + let entry = slot.get_mut(); + let should_remove = (|| { + if entry.event.is_some() + || entry.expires_at > Instant::now() + || entry.standard_refs != refs + { + return false; + } + if accepted { + return true; + } + let mut ok = true; + for r in &refs { + let path = self + .git_data_path + .join(r.owner.to_bech32().expect("public key")) + .join(format!("{}.git", r.identifier)); + match path.try_exists() { + Ok(false) => continue, + Err(error) => { + tracing::warn!(repo = %path.display(), %error, "Cannot inspect PR repository; deferring expiry"); + ok = false; + continue; + } + Ok(true) => {} + } + let reference = format!("refs/nostr/{id}"); + // A previous partial sweep or a rejected push may leave no ref. + match crate::git::list_refs(&path) { + Ok(refs) if !refs.iter().any(|(name, _)| name == &reference) => continue, + Err(error) => { + tracing::warn!(repo = %path.display(), %error, "Cannot inspect PR refs; deferring expiry"); + ok = false; + continue; + } + Ok(_) => {} + } + // Compare-and-delete prevents expiry from removing a replaced ref. + let result = std::process::Command::new("git") + .args(["update-ref", "-d", &reference, &r.commit]) + .current_dir(&path) + .output(); + if !matches!(result, Ok(ref output) if output.status.success()) { + tracing::warn!(repo = %path.display(), %reference, + "Failed to expire normal PR ref; retaining placeholder for retry"); + ok = false; + } else { + // The abandoned upload's staged objects can now be reclaimed. + crate::git::staging::request_maintenance(&path); + } + } + if ok && entry.prs_scope.is_some() { + entry.standard_refs.clear(); + return false; // The synchronous sweep still owns the /prs/ ref. + } + ok + })(); + if should_remove { + slot.remove(); + removed += 1; + } + } + removed + } + /// Add a PR placeholder created by a push to the GRASP-06 `/prs/` /// endpoint (06.md line 12). /// @@ -552,19 +728,31 @@ impl Purgatory { identifier: String, ) { let now = Instant::now(); - let entry = PrPurgatoryEntry { + let mut slot = self + .pr_events + .entry(event_id) + .or_insert_with(|| PrPurgatoryEntry { + event: None, + commit: commit.clone(), + created_at: now, + expires_at: now + DEFAULT_EXPIRY, + source: types::EventSource::Direct, + prs_scope: None, + standard_refs: Vec::new(), + }); + let standard_refs = std::mem::take(&mut slot.standard_refs); + *slot = PrPurgatoryEntry { event: None, commit, created_at: now, expires_at: now + DEFAULT_EXPIRY, source: types::EventSource::Direct, + standard_refs, prs_scope: Some(types::PrsPlaceholderScope { submitter, identifier, }), }; - - self.pr_events.insert(event_id, entry); } /// Find state events waiting for a specific repository identifier. @@ -977,7 +1165,7 @@ impl Purgatory { if let Some((repo_path, was_soft_expired)) = revival_info { if was_soft_expired { if !repo_path.exists() { - let storage = crate::git::storage::LocalGitStorage::new(&self._git_data_path); + let storage = crate::git::storage::LocalGitStorage::new(&self.git_data_path); let result = crate::git::storage::FamilyKey::sha1(identifier) .and_then(|family| storage.create_thin_view(&family, &repo_path)); match result { @@ -1301,7 +1489,8 @@ impl Purgatory { .iter() .filter(|entry| { let value = entry.value(); - value.expires_at <= now + value.standard_refs.is_empty() + && value.expires_at <= now && !value .event .as_ref() @@ -1433,24 +1622,8 @@ impl Purgatory { error = %e, "Failed to delete dangling /prs/ ref during purgatory expiry", ); - } else if state.in_flight.load(std::sync::atomic::Ordering::Relaxed) == 0 - && matches!( - crate::git::list_refs(&repo_path), - Ok(refs) if refs.is_empty() - ) - { - if let Err(e) = std::fs::remove_dir_all(&repo_path) { - tracing::warn!( - repo = %repo_path.display(), - error = %e, - "Failed to remove zero-ref /prs/ repo during purgatory expiry", - ); - } else { - tracing::debug!( - repo = %repo_path.display(), - "Removed zero-ref /prs/ repo during purgatory expiry", - ); - } + } else { + crate::grasp06::receive::remove_idle_empty_repo(&state, &repo_path); } } } @@ -1658,6 +1831,7 @@ impl Purgatory { expires_at_offset_secs: expires_offset.as_secs(), source: e.source, prs_scope: e.prs_scope.clone(), + standard_refs: e.standard_refs.clone(), }; pr_events.insert(event_id, serializable); } @@ -1830,6 +2004,7 @@ impl Purgatory { expires_at, source: e.source, prs_scope: e.prs_scope, + standard_refs: e.standard_refs, }; self.pr_events.insert(event_id, entry); @@ -3547,3 +3722,6 @@ fn add_prs_pr_placeholder_does_not_overwrite_existing_scoped_placeholder() { assert_eq!(scope.submitter, submitter_b.public_key()); assert_eq!(scope.identifier, "repo-b"); } + +#[cfg(test)] +mod standard_ref_tests; diff --git a/src/purgatory/standard_ref_tests.rs b/src/purgatory/standard_ref_tests.rs new file mode 100644 index 0000000..2ae0a9f --- /dev/null +++ b/src/purgatory/standard_ref_tests.rs @@ -0,0 +1,281 @@ +use super::*; +use crate::nostr::lifecycle::RepositoryLifecycle; +fn database() -> crate::nostr::SharedDatabase { + Arc::new(nostr_memory::MemoryDatabase::unbounded()) +} +fn refs(path: &Path) -> Result, String> { + crate::git::list_refs(path).map(|refs| refs.into_iter().collect()) +} + +fn git(path: &Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .current_dir(path) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn repository(root: &Path, owner: PublicKey, name: &str) -> (PathBuf, String) { + let path = root + .join(owner.to_bech32().unwrap()) + .join(format!("{name}.git")); + std::fs::create_dir_all(&path).unwrap(); + git(&path, &["init", "--bare"]); + let tree = git(&path, &["mktree"]); + let commit = git(&path, &["commit-tree", &tree, "-m", "test"]); + (path, commit) +} + +fn expire(p: &Purgatory, id: &str) { + p.pr_events.get_mut(id).unwrap().expires_at = Instant::now() - Duration::from_secs(1); +} + +#[tokio::test] +async fn standard_refs_expire_after_restart_without_touching_other_refs() { + let root = tempfile::tempdir().unwrap(); + let owner = Keys::generate().public_key(); + let p = Purgatory::new(root.path()); + let id = "ab".repeat(32); + let reference = format!("refs/nostr/{id}"); + let mut paths = Vec::new(); + for name in ["one", "two"] { + let (path, commit) = repository(root.path(), owner, name); + git(&path, &["update-ref", &reference, &commit]); + git(&path, &["update-ref", "refs/heads/main", &commit]); + p.add_standard_pr_placeholder(id.clone(), commit, owner, name.into()); + paths.push(path); + } + let (unrelated, commit) = repository(root.path(), owner, "unrelated"); + git(&unrelated, &["update-ref", &reference, &commit]); + let state = root.path().join("purgatory.json"); + p.save_to_disk(&state).unwrap(); + let restored = Purgatory::new(root.path()); + restored.restore_from_disk(&state).unwrap(); + assert_eq!(restored.find_pr(&id).unwrap().standard_refs.len(), 2); + expire(&restored, &id); + assert_eq!( + restored.cleanup().2, + 0, + "sync sweep must retain cleanup metadata" + ); + assert_eq!( + restored + .cleanup_standard_pr_refs(&RepositoryLifecycle::in_memory(), &database()) + .await, + 1 + ); + assert!(restored.find_pr(&id).is_none()); + for path in paths { + let refs = refs(&path).unwrap(); + assert!(!refs.contains_key(&reference)); + assert!(refs.contains_key("refs/heads/main")); + } + assert!(refs(&unrelated).unwrap().contains_key(&reference)); +} + +#[tokio::test] +async fn standard_ref_expiry_retries_lock_failure_and_preserves_replaced_ref() { + let root = tempfile::tempdir().unwrap(); + let owner = Keys::generate().public_key(); + let (path, commit) = repository(root.path(), owner, "one"); + let p = Purgatory::new(root.path()); + let id = "cd".repeat(32); + let reference = format!("refs/nostr/{id}"); + git(&path, &["update-ref", &reference, &commit]); + p.add_standard_pr_placeholder(id.clone(), commit.clone(), owner, "one".into()); + expire(&p, &id); + let lifecycle = RepositoryLifecycle::in_memory(); + let db = database(); + let lock = path.join(format!("{reference}.lock")); + std::fs::write(&lock, b"").unwrap(); + assert_eq!(p.cleanup_standard_pr_refs(&lifecycle, &db).await, 0); + assert!(p.find_pr(&id).is_some()); + std::fs::remove_file(lock).unwrap(); + let tree = git(&path, &["mktree"]); + let replacement = git(&path, &["commit-tree", &tree, "-m", "replacement"]); + git(&path, &["update-ref", &reference, &replacement]); + assert_eq!(p.cleanup_standard_pr_refs(&lifecycle, &db).await, 0); + assert_eq!(refs(&path).unwrap()[&reference], replacement); + git(&path, &["update-ref", &reference, &commit]); + assert_eq!(p.cleanup_standard_pr_refs(&lifecycle, &db).await, 1); +} + +#[tokio::test] +async fn standard_ref_expiry_rechecks_refresh_and_event_arrival_after_push_lock() { + let root = tempfile::tempdir().unwrap(); + let owner = Keys::generate().public_key(); + let (path, commit) = repository(root.path(), owner, "one"); + let p = Purgatory::new(root.path()); + let id = "ef".repeat(32); + let reference = format!("refs/nostr/{id}"); + git(&path, &["update-ref", &reference, &commit]); + let lifecycle = RepositoryLifecycle::in_memory(); + let db = database(); + for event_arrives in [false, true] { + p.add_standard_pr_placeholder(id.clone(), commit.clone(), owner, "one".into()); + expire(&p, &id); + let guard = lifecycle.read_repository(&owner.to_hex(), "one").await; + let cleanup = p.cleanup_standard_pr_refs(&lifecycle, &db); + tokio::pin!(cleanup); + assert!(futures_util::poll!(&mut cleanup).is_pending()); + if event_arrives { + p.remove_pr(&id); + } else { + p.add_standard_pr_placeholder(id.clone(), commit.clone(), owner, "one".into()); + } + drop(guard); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), cleanup) + .await + .unwrap(), + 0 + ); + assert!(refs(&path).unwrap().contains_key(&reference)); + } +} + +#[tokio::test] +async fn standard_push_authorization_records_each_repository_and_legacy_state_still_loads() { + let root = tempfile::tempdir().unwrap(); + let owner = Keys::generate().public_key(); + let database: crate::nostr::SharedDatabase = + Arc::new(nostr_memory::MemoryDatabase::unbounded()); + let p = Arc::new(Purgatory::new(root.path())); + let id = "12".repeat(32); + let commit = "34".repeat(20); + let line = format!( + "{} {commit} refs/nostr/{id}\0report-status\n", + "0".repeat(40) + ); + let body = hyper::body::Bytes::from(format!("{:04x}{line}0000", line.len() + 4)); + for name in ["one", "two", "one"] { + let result = crate::git::authorization::authorize_push( + &database, + name, + &owner.to_hex(), + &body, + &p, + root.path(), + ) + .await + .unwrap(); + assert!(result.authorized); + } + assert_eq!(p.find_pr(&id).unwrap().standard_refs.len(), 2); + let state = root.path().join("state.json"); + p.save_to_disk(&state).unwrap(); + let mut json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state).unwrap()).unwrap(); + for entry in json["pr_events"].as_object_mut().unwrap().values_mut() { + entry.as_object_mut().unwrap().remove("standard_refs"); + } + std::fs::write(&state, serde_json::to_vec(&json).unwrap()).unwrap(); + let restored = Purgatory::new(root.path()); + restored.restore_from_disk(&state).unwrap(); + assert!(restored.find_pr(&id).unwrap().standard_refs.is_empty()); +} + +#[tokio::test] +async fn standard_and_prs_copies_both_expire() { + let root = tempfile::tempdir().unwrap(); + let owner = Keys::generate().public_key(); + let (path, commit) = repository(root.path(), owner, "one"); + let p = Purgatory::new(root.path()); + p.set_prs_cleanup_ctx(PrsCleanupCtx { + git_data_path: root.path().to_path_buf(), + repo_init_locks: Default::default(), + }); + let id = "56".repeat(32); + let reference = format!("refs/nostr/{id}"); + git(&path, &["update-ref", &reference, &commit]); + p.add_standard_pr_placeholder(id.clone(), commit.clone(), owner, "one".into()); + p.add_prs_pr_placeholder(id.clone(), commit.clone(), owner, "one".into()); + let prs = crate::grasp06::paths::prs_repo_path(root.path(), &owner.to_hex(), "one"); + std::fs::create_dir_all(prs.parent().unwrap()).unwrap(); + git( + root.path(), + &[ + "clone", + "--bare", + path.to_str().unwrap(), + prs.to_str().unwrap(), + ], + ); + git(&prs, &["update-ref", &reference, &commit]); + expire(&p, &id); + assert_eq!( + p.cleanup_standard_pr_refs(&RepositoryLifecycle::in_memory(), &database()) + .await, + 0 + ); + assert!(!refs(&path).unwrap().contains_key(&reference)); + assert_eq!(p.cleanup().2, 1); + assert!(!prs.exists()); + assert!(p.find_pr(&id).is_none()); +} + +#[tokio::test] +async fn standard_ref_expiry_preserves_event_accepted_after_last_checkpoint() { + let root = tempfile::tempdir().unwrap(); + let keys = Keys::generate(); + let owner = keys.public_key(); + let (path, commit) = repository(root.path(), owner, "one"); + let event = EventBuilder::new(Kind::GitPullRequest, "") + .tags([Tag::custom("c", [commit.clone()])]) + .finalize(&keys) + .unwrap(); + let id = event.id.to_hex(); + let reference = format!("refs/nostr/{id}"); + git(&path, &["update-ref", &reference, &commit]); + let p = Purgatory::new(root.path()); + p.add_standard_pr_placeholder(id.clone(), commit, owner, "one".into()); + expire(&p, &id); + let db = database(); + db.save_event(&event).await.unwrap(); + assert_eq!( + p.cleanup_standard_pr_refs(&RepositoryLifecycle::in_memory(), &db) + .await, + 1 + ); + assert!(p.find_pr(&id).is_none()); + assert!(refs(&path).unwrap().contains_key(&reference)); +} + +#[tokio::test] +async fn standard_ref_expiry_retries_after_partial_cleanup() { + let root = tempfile::tempdir().unwrap(); + let owner = Keys::generate().public_key(); + let p = Purgatory::new(root.path()); + let id = "78".repeat(32); + let reference = format!("refs/nostr/{id}"); + let mut paths = Vec::new(); + for name in ["one", "two", "rejected-push"] { + let (path, commit) = repository(root.path(), owner, name); + if name != "rejected-push" { + git(&path, &["update-ref", &reference, &commit]); + } + p.add_standard_pr_placeholder(id.clone(), commit, owner, name.into()); + paths.push(path); + } + expire(&p, &id); + let lock = paths[1].join(format!("{reference}.lock")); + std::fs::write(&lock, b"").unwrap(); + let lifecycle = RepositoryLifecycle::in_memory(); + let db = database(); + assert_eq!(p.cleanup_standard_pr_refs(&lifecycle, &db).await, 0); + assert!(!refs(&paths[0]).unwrap().contains_key(&reference)); + assert!(refs(&paths[1]).unwrap().contains_key(&reference)); + std::fs::remove_file(lock).unwrap(); + assert_eq!(p.cleanup_standard_pr_refs(&lifecycle, &db).await, 1); + assert!(p.find_pr(&id).is_none()); +} diff --git a/src/purgatory/types.rs b/src/purgatory/types.rs index dd19d59..ff15b6b 100644 --- a/src/purgatory/types.rs +++ b/src/purgatory/types.rs @@ -145,6 +145,10 @@ pub struct PrPurgatoryEntry { #[serde(default)] pub source: EventSource, + /// Exact normal-endpoint refs awaiting this event; persisted for expiry cleanup. + #[serde(default)] + pub standard_refs: Vec, + /// If set, this placeholder was created by a push to the GRASP-06 /// `/prs//.git` endpoint (06.md line 12). When /// the corresponding PR event arrives, its signer MUST equal @@ -205,3 +209,11 @@ pub struct AnnouncementPurgatoryEntry { /// Whether the bare repo has been deleted (soft expiry) pub soft_expired: bool, } + +/// A pushed normal-endpoint ref, including the value cleanup may safely delete. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StandardPrRef { + pub owner: PublicKey, + pub identifier: String, + pub commit: String, +} diff --git a/src/server.rs b/src/server.rs index f1ab589..ae7b028 100644 --- a/src/server.rs +++ b/src/server.rs @@ -249,6 +249,15 @@ impl RelayServer { .await .context("failed deletion lifecycle startup reconciliation")?; + // Recover upload expiry before cleanup, integrity, sync or HTTP can run. + git::staging::recover_placeholders( + &git::storage::LocalGitStorage::new(config.effective_git_data_path()), + &purgatory, + &relay_runtime.stores.database, + ) + .await + .context("failed to recover staged upload expiry")?; + // Make the operator identity discoverable on this relay without // overwriting identity events the owner already published — locally // or on the configured user-index relays. Runs after deletion startup @@ -386,13 +395,22 @@ impl RelayServer { "Crash-safe sync-state checkpoint task started" ); + background_tasks.push(tokio::spawn(git::staging::run_worker( + git::storage::LocalGitStorage::new(config.effective_git_data_path()), + repo_init_locks.clone(), + ))); + // Spawn background cleanup task for purgatory entries (60s interval) let cleanup_purgatory = purgatory.clone(); + let cleanup_lifecycle = relay_runtime.lifecycle.clone(); + let cleanup_database = relay_runtime.stores.database.clone(); background_tasks.push(tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); loop { interval.tick().await; + let standard_removed = cleanup_purgatory.cleanup_standard_pr_refs(&cleanup_lifecycle, &cleanup_database).await; let (announcement_removed, state_removed, pr_removed) = cleanup_purgatory.cleanup(); + let pr_removed = pr_removed + standard_removed; if announcement_removed > 0 || state_removed > 0 || pr_removed > 0 { info!( "Purgatory cleanup: removed {} announcements, {} state events, {} PR events", @@ -575,15 +593,8 @@ impl RelayServer { info!("Rejected events cache saved to disk"); } - // Cleanup placeholder refs on shutdown - let placeholder_ids = self.purgatory.get_placeholder_event_ids(); - if !placeholder_ids.is_empty() { - info!( - "Cleaning up {} placeholder refs/nostr/ refs on shutdown", - placeholder_ids.len() - ); - git::cleanup_placeholder_refs(&self.git_data_path, &placeholder_ids); - } + // Pending refs and their staged objects must survive shutdown together + // with the checkpoint. Expiry owns their removal after restart. result } diff --git a/tests/common/relay.rs b/tests/common/relay.rs index 0bc517a..13caed2 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -451,6 +451,24 @@ impl TestRelay { .await } + /// Persistent GRASP-06 storage for crash/restart tests. + pub async fn start_with_grasp_06_paths( + git_data_path: PathBuf, + relay_data_path: PathBuf, + ) -> Self { + Self::start_internal( + port::reserve_port(), + RelayOptions { + grasp06_enable: true, + lmdb_backend: true, + git_data_path: Some(git_data_path), + relay_data_path: Some(relay_data_path), + ..RelayOptions::default() + }, + ) + .await + } + /// Start a relay on a port that the caller has already reserved. /// /// Use this when the test needs the port number *before* the relay @@ -1102,6 +1120,23 @@ impl TestRelay { } /// Stop the relay + /// Stop with SIGTERM so the relay runs its shutdown path, then reap it. + pub async fn stop_gracefully(mut self) { + let status = std::process::Command::new("kill") + .args(["-TERM", &self.process.id().to_string()]) + .status() + .expect("send SIGTERM to relay"); + assert!(status.success(), "SIGTERM delivery failed"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while self.process.try_wait().expect("poll relay").is_none() { + assert!( + std::time::Instant::now() < deadline, + "relay did not exit after SIGTERM" + ); + tokio::task::yield_now().await; + } + } + pub async fn stop(mut self) { // kill() sends SIGKILL; reap directly instead of guessing a grace period. let _ = self.process.kill(); diff --git a/tests/git_cruft_concurrency.rs b/tests/git_cruft_concurrency.rs new file mode 100644 index 0000000..22f6fda --- /dev/null +++ b/tests/git_cruft_concurrency.rs @@ -0,0 +1,449 @@ +//! Git-level probes for concurrent staging expiry. No relay is started here. +//! Hooks impose scheduling boundaries without changing Git's commands or results. + +use std::{ + fs::{self, FileTimes}, + io::{Read, Write}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::{Output, Stdio}, + time::{Duration, SystemTime}, +}; +use tempfile::TempDir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + process::{Child, Command}, + time::timeout, +}; + +const DEADLINE: Duration = Duration::from_secs(20); +const EXPIRED: &str = "refs/nostr/1111111111111111111111111111111111111111111111111111111111111111"; +const LIVE: &str = "refs/nostr/2222222222222222222222222222222222222222222222222222222222222222"; + +fn command(repo: &Path, args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("GIT_") { + cmd.env_remove(key); + } + } + cmd.env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .current_dir(repo) + .args(args) + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd +} + +async fn output(cmd: &mut Command) -> Output { + timeout(DEADLINE, cmd.output()).await.unwrap().unwrap() +} + +async fn git(repo: &Path, args: &[&str]) -> String { + let result = output(&mut command(repo, args)).await; + assert!(result.status.success(), "{args:?}: {result:?}"); + String::from_utf8(result.stdout).unwrap().trim().to_owned() +} + +struct Fixture { + _dir: TempDir, + view: PathBuf, + family: PathBuf, + client: PathBuf, + base: String, + live: String, + family_objects: String, +} + +impl Fixture { + async fn new(old_objects: bool) -> Self { + let dir = tempfile::tempdir().unwrap(); + let view = dir.path().join("view"); + let family = dir.path().join("family"); + let client = dir.path().join("client"); + for repo in [&view, &family, &client] { + fs::create_dir(repo).unwrap(); + git( + repo, + &["init", "--bare", "--quiet", "--initial-branch=main"], + ) + .await; + git(repo, &["config", "gc.auto", "0"]).await; + } + let family_tree = git(&family, &["mktree"]).await; + let family_tip = git(&family, &["commit-tree", &family_tree, "-m", "family"]).await; + git(&family, &["update-ref", "refs/heads/main", &family_tip]).await; + fs::write( + view.join("objects/info/alternates"), + format!("{}\n", family.join("objects").display()), + ) + .unwrap(); + let tree = git(&view, &["mktree"]).await; + let base = git(&view, &["commit-tree", &tree, "-m", "expired pending"]).await; + let live = git(&view, &["commit-tree", &tree, "-m", "live pending"]).await; + git(&view, &["update-ref", EXPIRED, &base]).await; + git(&view, &["update-ref", LIVE, &live]).await; + let family_objects = Self::objects(&family).await; + let fixture = Self { + _dir: dir, + view, + family, + client, + base, + live, + family_objects, + }; + fixture.repack().await; + if old_objects { + // Model elapsed object age without sleeping. The first cruft pack + // derives these objects' ages from their previous pack's mtime. + let old = SystemTime::now() - Duration::from_secs(7200); + let mut packs = 0; + for entry in fs::read_dir(fixture.view.join("objects/pack")).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|ext| ext == "pack") { + fs::File::open(path) + .unwrap() + .set_times(FileTimes::new().set_modified(old)) + .unwrap(); + packs += 1; + } + } + assert!(packs > 0); + } + fixture + } + + async fn objects(repo: &Path) -> String { + git( + repo, + &[ + "cat-file", + "--batch-all-objects", + "--batch-check=%(objectname)", + ], + ) + .await + } + + async fn repack(&self) { + git( + &self.view, + &[ + "repack", + "-d", + "-l", + "--cruft", + "--cruft-expiration=30.minutes.ago", + ], + ) + .await; + } + + async fn expire(&self) { + git(&self.view, &["update-ref", "-d", EXPIRED, &self.base]).await; + } + + async fn assert_live_and_family_intact(&self) { + assert_eq!(git(&self.view, &["rev-parse", LIVE]).await, self.live); + git( + &self.view, + &["rev-list", "--objects", "--missing=error", LIVE], + ) + .await; + assert_eq!(Self::objects(&self.family).await, self.family_objects); + git(&self.family, &["fsck", "--full"]).await; + } +} + +struct Gate { + listener: TcpListener, + hook: PathBuf, +} + +impl Gate { + async fn new(repo: &Path, name: &str, exec_args: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let hook = repo.join("hooks").join(name); + fs::write(&hook, format!( + "#!/bin/sh\n\"$GRASP_CRUFT_TEST_BINARY\" --exact git_hook_barrier --nocapture >&2 || exit 1\n{}\n", + if exec_args { "exec \"$@\"" } else { "exit 0" }, + )).unwrap(); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap(); + Self { listener, hook } + } + + fn spawn(&self, cmd: &mut Command) -> Child { + cmd.env("GRASP_CRUFT_TEST_BINARY", std::env::current_exe().unwrap()) + .env( + "GRASP_CRUFT_GATE", + self.listener.local_addr().unwrap().to_string(), + ) + .spawn() + .unwrap() + } + + async fn reached(&self) -> TcpStream { + timeout(DEADLINE, async { + let (mut stream, _) = self.listener.accept().await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), b'R'); + stream + }) + .await + .expect("Git did not reach the hook barrier") + } +} + +// The hook runs this same test binary in a child process. No Python or global +// environment mutation is needed. Outside a hook invocation this is a no-op. +#[test] +fn git_hook_barrier() { + let Ok(address) = std::env::var("GRASP_CRUFT_GATE") else { + return; + }; + let mut stream = + std::net::TcpStream::connect_timeout(&address.parse().unwrap(), DEADLINE).unwrap(); + stream.set_read_timeout(Some(DEADLINE)).unwrap(); + stream.set_write_timeout(Some(DEADLINE)).unwrap(); + stream.write_all(b"R").unwrap(); + let mut release = [0]; + stream.read_exact(&mut release).unwrap(); + assert_eq!(release, [b'G']); +} + +async fn upload_race(expire: bool, old: bool) -> Output { + let f = Fixture::new(old).await; + let gate = Gate::new(&f.view, "pack-gate", true).await; + let upload = format!( + "git -c uploadpack.packObjectsHook={} upload-pack", + gate.hook.display() + ); + let mut child = gate.spawn(&mut command( + &f.client, + &[ + "-c", + "protocol.version=0", + "fetch", + "--no-tags", + &format!("--upload-pack={upload}"), + f.view.to_str().unwrap(), + EXPIRED, + ], + )); + let mut release = gate.reached().await; + assert!(child.try_wait().unwrap().is_none()); + if expire { + f.expire().await; + } + f.repack().await; + f.assert_live_and_family_intact().await; + release.write_all(b"G").await.unwrap(); + let result = timeout(DEADLINE, child.wait_with_output()) + .await + .unwrap() + .unwrap(); + if result.status.success() { + assert_eq!(git(&f.client, &["rev-parse", "FETCH_HEAD"]).await, f.base); + git(&f.client, &["fsck", "--full"]).await; + } + // A second fetch demonstrates that failure is confined to the expired ref. + git(&f.client, &["fetch", f.view.to_str().unwrap(), LIVE]).await; + git(&f.client, &["fsck", "--full"]).await; + result +} + +#[tokio::test] +async fn retained_ref_survives_repack_during_upload() { + let result = upload_race(false, true).await; + assert!(result.status.success(), "{result:?}"); +} + +#[tokio::test] +async fn recent_objects_survive_repack_after_ref_deletion() { + let result = upload_race(true, false).await; + assert!(result.status.success(), "{result:?}"); +} + +#[tokio::test] +async fn expired_ref_upload_may_fail_without_harming_live_ref() { + let result = upload_race(true, true).await; + let stderr = String::from_utf8_lossy(&result.stderr); + assert_eq!(result.status.code(), Some(128), "{result:?}"); + assert!(stderr.contains("bad object"), "{stderr}"); + assert!(stderr.contains("bad pack header"), "{stderr}"); +} + +#[tokio::test] +// This characterizes a failure of revised gate property 2, not an acceptable +// server outcome. Keep it explicit until a revised design prevents this race. +async fn concurrent_repack_can_leave_a_successful_push_with_missing_parent() { + const NEW: &str = "refs/nostr/3333333333333333333333333333333333333333333333333333333333333333"; + let f = Fixture::new(true).await; + git( + &f.client, + &[ + "fetch", + f.view.to_str().unwrap(), + &format!("{EXPIRED}:refs/heads/base"), + ], + ) + .await; + let advertisement = git(&f.view, &["receive-pack", "--advertise-refs", "."]).await; + assert!(advertisement.contains(&format!("{} {EXPIRED}", f.base))); + let tree = git(&f.client, &["rev-parse", "refs/heads/base^{tree}"]).await; + let new = git( + &f.client, + &["commit-tree", &tree, "-p", &f.base, "-m", "new pending"], + ) + .await; + + git( + &f.client, + &["rev-list", "--objects", "--missing=error", &new], + ) + .await; + + // Build the pack a client may send using that advertisement: only the new + // commit, omitting its previously advertised parent. No malformed objects. + let mut pack = command(&f.client, &["pack-objects", "--stdout", "--revs"]) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + pack.stdin + .take() + .unwrap() + .write_all(format!("{new}\n^{}\n", f.base).as_bytes()) + .await + .unwrap(); + let pack = timeout(DEADLINE, pack.wait_with_output()) + .await + .unwrap() + .unwrap(); + assert!(pack.status.success(), "{pack:?}"); + let update = format!("{} {new} {NEW}\0report-status\n", "0".repeat(40)); + let mut request = format!("{:04x}{update}0000", update.len() + 4).into_bytes(); + request.extend(pack.stdout); + + // Expiry precedes repack, rather than racing its initial ref snapshot. + // The old pack models an idle view eligible for compaction. Any quiet wait + // between deletion and repack leaves the following interleaving unchanged: + // the new receive-pack begins only AFTER repack has selected its survivors. + f.expire().await; + let repack_gate = Gate::new(&f.view, "repack-gate", false).await; + let wrappers = f._dir.path().join("git-wrappers"); + fs::create_dir(&wrappers).unwrap(); + let wrapper = wrappers.join("git"); + fs::write( + &wrapper, + r#"#!/bin/sh +case " $* " in + *" --cruft "*) + "$GRASP_CRUFT_REAL_GIT" "$@" || exit $? + exec "$GRASP_CRUFT_REPACK_GATE" + ;; + *) exec "$GRASP_CRUFT_REAL_GIT" "$@" ;; +esac +"#, + ) + .unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).unwrap(); + let real_git = std::env::split_paths(&std::env::var_os("PATH").unwrap()) + .map(|dir| dir.join("git")) + .find(|path| path.is_file()) + .unwrap(); + let mut repack = repack_gate.spawn( + command( + &f.view, + &[ + "repack", + "-d", + "-l", + "--cruft", + "--cruft-expiration=30.minutes.ago", + ], + ) + .env("GIT_EXEC_PATH", &wrappers) + .env("GRASP_CRUFT_REAL_GIT", real_git) + .env("GRASP_CRUFT_REPACK_GATE", &repack_gate.hook), + ); + let mut finish_repack = repack_gate.reached().await; + assert!(repack.try_wait().unwrap().is_none()); + // Git has finished preparing its cruft pack, but not deleted the old pack. + git(&f.view, &["cat-file", "-e", &f.base]).await; + + let receive_gate = Gate::new(&f.view, "pre-receive", false).await; + let mut receive = receive_gate.spawn( + command( + &f.view, + &[ + "-c", + &format!("core.hooksPath={}", f.view.join("hooks").display()), + "receive-pack", + "--stateless-rpc", + ".", + ], + ) + .stdin(Stdio::piped()), + ); + receive + .stdin + .take() + .unwrap() + .write_all(&request) + .await + .unwrap(); + let mut finish_receive = receive_gate.reached().await; + assert!(receive.try_wait().unwrap().is_none()); + // receive-pack has now checked connectivity against the old parent. + finish_repack.write_all(b"G").await.unwrap(); + let repack = timeout(DEADLINE, repack.wait_with_output()) + .await + .unwrap() + .unwrap(); + assert!(repack.status.success(), "{repack:?}"); + f.assert_live_and_family_intact().await; + let parent = output(&mut command(&f.view, &["cat-file", "-e", &f.base])).await; + assert!(!parent.status.success()); + finish_receive.write_all(b"G").await.unwrap(); + let result = timeout(DEADLINE, receive.wait_with_output()) + .await + .unwrap() + .unwrap(); + let installed = output(&mut command(&f.view, &["rev-parse", "--verify", NEW])).await; + let closure = output(&mut command( + &f.view, + &["rev-list", "--objects", "--missing=error", NEW], + )) + .await; + eprintln!( + "receive status: {}\nreport: {}\ninstalled ref: {}\nclosure status: {}\nclosure stderr: {}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&installed.stdout), + closure.status, + String::from_utf8_lossy(&closure.stderr) + ); + assert!(result.status.success(), "{result:?}"); + let report = String::from_utf8_lossy(&result.stdout); + assert!(report.contains("unpack ok"), "{report}"); + assert!(report.contains(&format!("ok {NEW}")), "{report}"); + assert!(installed.status.success(), "{installed:?}"); + assert_eq!(String::from_utf8_lossy(&installed.stdout).trim(), new); + assert!( + !closure.status.success(), + "the counterexample no longer reproduces" + ); + assert!( + String::from_utf8_lossy(&closure.stderr).contains(&f.base), + "{closure:?}" + ); + f.assert_live_and_family_intact().await; +} diff --git a/tests/git_push_promotion_race.rs b/tests/git_push_promotion_race.rs index 13c497e..10c3d1f 100644 --- a/tests/git_push_promotion_race.rs +++ b/tests/git_push_promotion_race.rs @@ -279,6 +279,9 @@ async fn mixed_deletion_push(atomic: bool, reject_branch: bool, include_deletion let bad = oid.clone(); if reject_branch { use std::os::unix::fs::PermissionsExt; + // The server inherits ambient Git config, including CI's hostile + // hooksPath. Explicitly select this fixture's intentional rejection hook. + git(&repo, &["config", "--local", "core.hooksPath", "hooks"]); let hook = repo.join("hooks/update"); std::fs::write(&hook, b"#!/bin/sh\n[ \"$1\" != refs/heads/unavailable ]\n").unwrap(); std::fs::set_permissions(hook, std::fs::Permissions::from_mode(0o755)).unwrap(); diff --git a/tests/grasp06_pr_hosting.rs b/tests/grasp06_pr_hosting.rs index 4f71fb0..d9c758b 100644 --- a/tests/grasp06_pr_hosting.rs +++ b/tests/grasp06_pr_hosting.rs @@ -369,3 +369,54 @@ isolated_test_with_grasp_06!( test_commit_mismatch_deletes_ref_and_blocks_promotion_with_grasp_06, PushValidationTests::test_commit_mismatch_deletes_ref_and_blocks_promotion ); + +/// A crash can lose the purgatory checkpoint after a `/prs/` push installed +/// its ref. The arriving PR event must still be matched to that push, using +/// only its signed, service-local clone URL and the exact event-id ref. +#[tokio::test] +async fn pr_event_after_crash_recovers_prs_placeholder_scope() { + use nostr_sdk::prelude::*; + let persistent = tempfile::tempdir().unwrap(); + let relay = TestRelay::start_with_grasp_06_paths( + persistent.path().join("git"), + persistent.path().join("relay"), + ) + .await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + let keys = client.keys().clone(); + let npub = keys.public_key().to_bech32().unwrap(); + let identifier = "crash-recovered-pr"; + let local = tempfile::tempdir().unwrap(); + let commit = + common::create_test_repo_with_commit(local.path(), common::CommitVariant::PrTest).unwrap(); + let clone_url = format!("http://{}/prs/{npub}/{identifier}.git", relay.domain()); + let event = common::create_pr_event_with_clone( + &keys, + &format!("30617:{}:{identifier}", keys.public_key().to_hex()), + &commit, + "Crash-recovered PR", + &[&clone_url], + ) + .unwrap(); + common::push_ref_to_relay( + local.path(), + &relay.domain(), + &format!("prs/{npub}"), + identifier, + &commit, + &format!("refs/nostr/{}", event.id), + ) + .unwrap(); + // Killing the relay loses any placeholder not yet checkpointed. + let relay = relay.restart().await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + client.send_event(event.clone()).await.unwrap(); + common::wait_for_event_served(relay.url(), &event.id, std::time::Duration::from_secs(10)) + .await + .unwrap(); + relay.stop().await; +} diff --git a/tests/pending_upload_staging.rs b/tests/pending_upload_staging.rs new file mode 100644 index 0000000..ac13021 --- /dev/null +++ b/tests/pending_upload_staging.rs @@ -0,0 +1,433 @@ +//! Unsigned uploads are staged in their view and earn family storage only +//! when a signed event names them. +//! +//! ```bash +//! cargo test --test pending_upload_staging +//! ``` + +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use common::{CommitVariant, TestRelay}; +use grasp_audit::{AuditClient, AuditConfig}; +use ngit_grasp::git::staging; +use nostr_sdk::prelude::*; + +const DEADLINE: Duration = Duration::from_secs(20); + +fn git(repo: &Path, args: &[&str]) -> std::process::Output { + grasp_audit::git_command() + .current_dir(repo) + .args(args) + .output() + .expect("spawn git") +} + +/// Every object in the family, which has no alternates of its own. +fn family_objects(family: &Path) -> String { + let output = git( + family, + &[ + "cat-file", + "--batch-all-objects", + "--batch-check=%(objectname)", + ], + ); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() +} + +/// Object files in the view's own object directory, which is its staging. +fn staged_files(view: &Path) -> Vec { + let mut files = Vec::new(); + for entry in std::fs::read_dir(view.join("objects")).unwrap() { + let entry = entry.unwrap(); + if entry.file_name() == "info" || !entry.file_type().unwrap().is_dir() { + continue; + } + for file in std::fs::read_dir(entry.path()).unwrap() { + files.push(file.unwrap().path()); + } + } + files +} + +/// Whether `repo` holds `tip` and its whole history without any other store. +fn holds_history(repo: &Path, tip: &str) -> bool { + git(repo, &["rev-list", "--objects", "--missing=error", tip]) + .status + .success() +} + +struct Served { + _persistent: tempfile::TempDir, + relay: TestRelay, + client: AuditClient, + announcement: Event, + state: Event, + identifier: String, + npub: String, + view: PathBuf, + family: PathBuf, +} + +impl Served { + /// A relay serving one repository whose maintainer push has completed. + async fn start(name: &str) -> Self { + let persistent = tempfile::tempdir().unwrap(); + let git_data = persistent.path().join("git"); + let relay = TestRelay::start_with_existing_lmdb_paths( + git_data.clone(), + persistent.path().join("relay"), + None, + false, + ) + .await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + let (announcement, identifier, state) = + common::publish_served_audit_repo_with_state(&client, name).await; + let npub = client.public_key().to_bech32().unwrap(); + let view = git_data.join(&npub).join(format!("{identifier}.git")); + let family = git_data + .join(".grasp/families/sha1") + .join(format!("{identifier}.git")); + Self { + _persistent: persistent, + relay, + client, + announcement, + state, + identifier, + npub, + view, + family, + } + } + + fn pr_event(&self, tip: &str, title: &str) -> Event { + common::create_pr_event( + self.client.keys(), + &common::announcement_coordinate(&self.announcement, &self.identifier), + tip, + title, + ) + .unwrap() + } + + fn push(&self, local: &Path, tip: &str, event: &Event) { + common::push_ref_to_relay( + local, + &self.relay.domain(), + &self.npub, + &self.identifier, + tip, + &format!("refs/nostr/{}", event.id), + ) + .unwrap(); + } + + async fn accept(&self, event: &Event) { + let client = AuditClient::new(self.relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + client.send_event(event.clone()).await.unwrap(); + common::wait_for_event_served(self.relay.url(), &event.id, DEADLINE) + .await + .unwrap(); + } + + async fn wait_until_unstaged(&self) { + common::wait_for("staging to be reclaimed", DEADLINE, || async { + !staging::is_staged(&self.view) && staged_files(&self.view).is_empty() + }) + .await; + } +} + +#[tokio::test] +async fn signed_push_is_received_into_the_family_without_staging() { + let served = Served::start("staging-signed-push").await; + + assert!(!staging::is_staged(&served.view)); + assert_eq!(staged_files(&served.view), Vec::::new()); + let main = ngit_grasp::git::get_ref_commit(&served.view, "refs/heads/main").unwrap(); + assert!(holds_history(&served.family, &main)); + + served.relay.stop().await; +} + +#[tokio::test] +async fn unsigned_upload_is_staged_across_a_crash_and_promoted_on_acceptance() { + let mut served = Served::start("staging-unsigned-upload").await; + let family_before = family_objects(&served.family); + let local = tempfile::tempdir().unwrap(); + let tip = common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let event = served.pr_event(&tip, "staged until signed"); + + served.push(local.path(), &tip, &event); + + assert!(staging::is_staged(&served.view)); + assert!(ngit_grasp::git::oid_exists(&served.view, &tip)); + assert_eq!( + family_objects(&served.family), + family_before, + "an unsigned upload reached the family" + ); + + // Killing the relay loses any purgatory placeholder not yet checkpointed; + // the staged ref and objects must not depend on it. + served.relay = served.relay.restart().await; + assert!(staging::is_staged(&served.view)); + assert!(holds_history(&served.view, &tip)); + assert_eq!(family_objects(&served.family), family_before); + + served.accept(&event).await; + + assert!( + holds_history(&served.family, &tip), + "an accepted PR must be complete in the family alone" + ); + served.wait_until_unstaged().await; + assert!(holds_history(&served.view, &tip)); + + served.relay.stop().await; +} + +#[tokio::test] +async fn signed_push_onto_staged_history_is_complete_in_the_family() { + let served = Served::start("staging-signed-onto-staged").await; + let local = tempfile::tempdir().unwrap(); + let pending_tip = + common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let pending = served.pr_event(&pending_tip, "never signed"); + served.push(local.path(), &pending_tip, &pending); + assert!(staging::is_staged(&served.view)); + + // The view advertises the pending tip, so this push omits its objects. + let signed_tip = common::add_commit_to_repo(local.path(), CommitVariant::SecondCommit).unwrap(); + let signed = served.pr_event(&signed_tip, "signed before its push"); + let client = AuditClient::new(served.relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + client + .send_event_and_note_purgatory(signed.clone()) + .await + .unwrap(); + served.push(local.path(), &signed_tip, &signed); + + common::wait_for_event_served(served.relay.url(), &signed.id, DEADLINE) + .await + .unwrap(); + assert!( + holds_history(&served.family, &signed_tip), + "signed history depends on objects that only staging holds" + ); + // The pending ref is untouched. Its history is now an ancestor of signed + // history, so the family holds it and nothing is left to stage. + assert!(holds_history(&served.view, &pending_tip)); + served.wait_until_unstaged().await; + + served.relay.stop().await; +} + +#[tokio::test] +async fn maintainer_push_into_a_staged_view_reaches_the_family() { + let served = Served::start("staging-maintainer-push").await; + let local = tempfile::tempdir().unwrap(); + let pending_tip = + common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let pending = served.pr_event(&pending_tip, "never signed"); + served.push(local.path(), &pending_tip, &pending); + assert!(staging::is_staged(&served.view)); + + let clone = + grasp_audit::clone_repo(&served.relay.domain(), &served.npub, &served.identifier).unwrap(); + std::fs::write(clone.join("next.txt"), "next state").unwrap(); + assert!(git(&clone, &["add", "."]).status.success()); + assert!(git(&clone, &["commit", "-m", "next state"]) + .status + .success()); + let next = String::from_utf8(git(&clone, &["rev-parse", "HEAD"]).stdout) + .unwrap() + .trim() + .to_owned(); + let state = common::event_ordering::event_after( + common::create_state_event( + served.client.keys(), + &served.identifier, + &[("main", &next)], + &[], + &[], + &[], + ) + .unwrap(), + served.client.keys(), + served.state.created_at, + ); + served + .client + .send_event_and_note_purgatory(state.clone()) + .await + .unwrap(); + assert!(grasp_audit::try_push(&clone).unwrap()); + let _ = std::fs::remove_dir_all(&clone); + + common::wait_for_event_served(served.relay.url(), &state.id, DEADLINE) + .await + .unwrap(); + // Rollback after a State deletion depends on this history, whatever + // becomes of the view's refs. + assert!(holds_history(&served.family, &next)); + assert!(!staging::owes_history(&served.view)); + + served.relay.stop().await; +} + +async fn state_adopts_unsigned_upload(state_first: bool) { + let served = Served::start("state-adopts-unsigned").await; + let original = ngit_grasp::git::get_ref_commit(&served.view, "refs/heads/main").unwrap(); + let local = tempfile::tempdir().unwrap(); + let tip = common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let pending = served.pr_event(&tip, "never published"); + let state = common::event_ordering::event_after( + common::create_state_event( + served.client.keys(), + &served.identifier, + &[("main", &tip)], + &[("staged-tag", &tip)], + &[], + &[], + ) + .unwrap(), + served.client.keys(), + served.state.created_at, + ); + if state_first { + served + .client + .send_event_and_note_purgatory(state.clone()) + .await + .unwrap(); + } + served.push(local.path(), &tip, &pending); + if !state_first { + served.accept(&state).await; + } + common::wait_for_event_served(served.relay.url(), &state.id, DEADLINE) + .await + .unwrap(); + assert_eq!( + ngit_grasp::git::get_ref_commit(&served.view, "refs/heads/main"), + Some(tip.clone()) + ); + assert!( + holds_history(&served.family, &tip), + "accepted State history must leave staging" + ); + + let next = common::event_ordering::event_after( + common::create_state_event( + served.client.keys(), + &served.identifier, + &[("main", &original)], + &[], + &[], + &[], + ) + .unwrap(), + served.client.keys(), + state.created_at, + ); + served.accept(&next).await; + // Stop the writer before simulating expiry and compaction directly. + served.relay.stop().await; + ngit_grasp::git::delete_ref(&served.view, &format!("refs/nostr/{}", pending.id)).unwrap(); + staging::compact(&served.view).unwrap(); + assert!( + holds_history(&served.family, &tip), + "rollback history survives ref removal" + ); +} + +#[tokio::test] +async fn arriving_state_promotes_an_existing_unsigned_upload() { + state_adopts_unsigned_upload(false).await; +} + +#[tokio::test] +async fn waiting_state_promotes_an_unsigned_upload_before_release() { + state_adopts_unsigned_upload(true).await; +} + +#[tokio::test] +async fn crash_without_purgatory_checkpoint_still_expires_unsigned_upload() { + let served = Served::start("crash-expiry").await; + let local = tempfile::tempdir().unwrap(); + let tip = common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let pending = served.pr_event(&tip, "never published"); + served.push(local.path(), &tip, &pending); + let family_before = family_objects(&served.family); + let git_data = served._persistent.path().join("git"); + let relay_data = served._persistent.path().join("relay"); + served.relay.stop().await; + // Reproduce the crash window deterministically: no checkpoint knows this + // upload, and its durable deadline has elapsed while the relay was down. + let checkpoint = git_data.join("purgatory-state.json"); + if checkpoint.exists() { + std::fs::remove_file(checkpoint).unwrap(); + } + for entry in std::fs::read_dir(git_data.join(".grasp/staging")).unwrap() { + let path = entry.unwrap().path(); + let mut record: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + for upload in record["pending"].as_array_mut().unwrap() { + upload["expires_at"] = serde_json::to_value(std::time::SystemTime::UNIX_EPOCH).unwrap(); + } + std::fs::write(path, serde_json::to_vec(&record).unwrap()).unwrap(); + } + let relay = TestRelay::start_with_existing_lmdb_paths(git_data, relay_data, None, false).await; + common::wait_for( + "recovered expired upload to be reclaimed", + DEADLINE, + || async { !ngit_grasp::git::oid_exists(&served.view, &tip) }, + ) + .await; + assert!( + ngit_grasp::git::get_ref_commit(&served.view, &format!("refs/nostr/{}", pending.id)) + .is_none() + ); + assert_eq!(family_objects(&served.family), family_before); + relay.stop().await; +} + +#[tokio::test] +async fn corrupt_staging_record_does_not_prevent_relay_restart() { + let mut served = Served::start("corrupt-staging-record").await; + let local = tempfile::tempdir().unwrap(); + let tip = common::create_test_repo_with_commit(local.path(), CommitVariant::PrTest).unwrap(); + let pending = served.pr_event(&tip, "pending with damaged metadata"); + served.push(local.path(), &tip, &pending); + let registry = served._persistent.path().join("git/.grasp/staging"); + let records: Vec<_> = std::fs::read_dir(®istry) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(records.len(), 1); + let damaged = b"{broken json"; + std::fs::write(&records[0], damaged).unwrap(); + + // restart() kills the process and waits for the new relay to be ready. + served.relay = served.relay.restart().await; + assert_eq!(std::fs::read(&records[0]).unwrap(), damaged); + assert_eq!( + ngit_grasp::git::get_ref_commit(&served.view, &format!("refs/nostr/{}", pending.id)), + Some(tip.clone()) + ); + assert!(holds_history(&served.view, &tip)); + assert!(!holds_history(&served.family, &tip)); + served.relay.stop().await; +} diff --git a/tests/purgatory.rs b/tests/purgatory.rs index 73f85ca..21edc03 100644 --- a/tests/purgatory.rs +++ b/tests/purgatory.rs @@ -87,3 +87,56 @@ isolated_purgatory_test!(test_state_event_served_after_git_push); isolated_purgatory_test!(test_pr_event_accepted_into_purgatory_and_isnt_served); isolated_purgatory_test!(test_pr_event_in_purgatory_git_push_accepted); isolated_purgatory_test!(test_pr_event_served_after_git_push); + +#[tokio::test] +async fn graceful_shutdown_keeps_pending_pr_refs() { + use nostr_sdk::prelude::*; + let persistent = tempfile::tempdir().unwrap(); + let git_data = persistent.path().join("git"); + let relay = TestRelay::start_with_existing_lmdb_paths( + git_data.clone(), + persistent.path().join("relay"), + None, + false, + ) + .await; + let client = AuditClient::new(relay.url(), AuditConfig::isolated()) + .await + .unwrap(); + let (announcement, identifier) = + common::publish_served_repo(&client, "shutdown-pending-pr").await; + let local = tempfile::tempdir().unwrap(); + let tip = + common::create_test_repo_with_commit(local.path(), common::CommitVariant::PrTest).unwrap(); + // The event is never sent, so the pushed ref stays a pending placeholder. + let event = common::create_pr_event( + client.keys(), + &common::announcement_coordinate(&announcement, &identifier), + &tip, + "pending across shutdown", + ) + .unwrap(); + let npub = client.public_key().to_bech32().unwrap(); + let reference = format!("refs/nostr/{}", event.id); + common::push_ref_to_relay( + local.path(), + &relay.domain(), + &npub, + &identifier, + &tip, + &reference, + ) + .unwrap(); + let view = git_data.join(&npub).join(format!("{identifier}.git")); + assert_eq!( + ngit_grasp::git::get_ref_commit(&view, &reference).as_deref(), + Some(tip.as_str()) + ); + relay.stop_gracefully().await; + assert_eq!( + ngit_grasp::git::get_ref_commit(&view, &reference).as_deref(), + Some(tip.as_str()), + "shutdown must keep the pending ref for expiry after restart" + ); + assert!(ngit_grasp::git::oid_exists(&view, &tip)); +}