Skip to content

Commit 1af4fdf

Browse files
authored
feat(node): pin the per-push object delta instead of re-enumerating the whole repo (#90)
* feat(node): add push-delta object enumeration module Compute the per-push object delta (git rev-list --objects <new> --not <old>) for the pin path instead of enumerating the whole repo, with a fully-peeled ref-type guard (cat-file -t '<sha>^{}') that fails closed to a full scan on a non-commit tip, any git error, or the GITLAWB_PIN_FORCE_FULLSCAN kill-switch. The whole-repo lister moves here for the fallback and the reconciliation sweep. Pin-enumeration scope only (N16); the withheld filter is unchanged. * feat(node): pin the per-push object delta instead of the whole repo Wire the push-delta enumeration into the IPFS and Pinata pin paths so pin work scales with push size, not repo size (N16). - U2: both pin_new_objects twins take a candidate OID set instead of scanning the whole repo internally; repo_path is retained for reading object bytes. - U4: extract the anonymous replication withheld gate (caller=None, no caller param) into a shared helper returning (announce, withheld), so the push path and the future reconciliation sweep cannot drift. The per-caller read-serve withheld call in git_upload_pack is deliberately left separate. - U3: the post-receive handler resolves the push delta once (off-thread), falls back to a full scan on a non-commit tip / git error / kill-switch, and passes the candidate set to both pin paths. Logs delta size and fallbacks. The reconciliation sweep (U5) is deferred: it depends on the history-deep blob_paths fix (#84) landing on main first. * fix(review): log degraded pin-candidate paths, extract wiring to push_delta Code review found the push candidate computation collapsed both a failed full-scan fallback and a panicked spawn_blocking task to a silent empty set, diverging from the sibling withheld path that logs and fails closed. Extract resolve_candidates_for_push into git::push_delta (it owns the PinCandidates dispatch and full-scan fallback), logging every degraded path (fallback taken, fallback failed, task panicked) instead of failing silently. Pinning nothing on a hard failure is still safe (the withheld filter always runs; the reconciliation sweep backstops durability) but is now observable. Moving the dispatch out of the HTTP handler also makes it unit-testable. Add tests: tag-of-tag-of-commit peels to a Delta, and resolve_candidates_for_push delta + non-commit-tip full-scan-fallback paths. * fix(node): fail announce closed when withheld walk fails (#90) A failed or panicked withheld_blob_oids walk left announce=true while withheld=None, so a push we could not vet would still gossip ref updates, notify peers, and write a permanent Arweave anchor. Force announce false in that arm so the gate fails closed on both axes.
1 parent 3d880cd commit 1af4fdf

5 files changed

Lines changed: 689 additions & 114 deletions

File tree

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 100 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,75 @@ use crate::state::AppState;
1717
use crate::visibility::{visibility_check, Decision};
1818
use crate::webhooks;
1919

20+
/// The git all-zeros object id — the create/delete sentinel in a ref update.
21+
const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
22+
23+
/// The set of blob OIDs withheld from **anonymous** replication for a repo, or
24+
/// `None` when the repo must not replicate at all (private / mode A /
25+
/// undetermined — fail closed). This is the anonymous replication gate:
26+
/// `caller` is hard-coded to `None` and there is intentionally no caller
27+
/// parameter, which distinguishes it from the per-caller read-serve projection
28+
/// in `git_upload_pack` (which passes the real caller). Both the push pin path
29+
/// and the reconciliation sweep call this helper so the two cannot drift on
30+
/// what is withheld. `rules` is the already-fetched visibility-rule snapshot
31+
/// (callers fetch once and may reuse it, e.g. for encrypt-then-pin).
32+
///
33+
/// Returns `(announce, withheld)`: `announce` is whether the repo may be
34+
/// announced/replicated to the anonymous public at all (also gates gossip and
35+
/// Arweave anchoring downstream), and `withheld` is the anonymous withheld blob
36+
/// set when announceable (`None` when not announceable). A failed/panicked
37+
/// withheld walk fails closed on both axes: `announce` is forced false and
38+
/// `withheld` is `None`, so an unvetted push neither replicates blobs nor
39+
/// announces. Returning both keeps the gate's announce decision a single
40+
/// source rather than recomputing it at each call site.
41+
async fn replication_withheld_set(
42+
rules: Option<Vec<crate::db::VisibilityRule>>,
43+
owner_did: &str,
44+
is_public: bool,
45+
disk_path: std::path::PathBuf,
46+
) -> (bool, Option<std::collections::HashSet<String>>) {
47+
let announce = match &rules {
48+
Some(rules) => visibility_check(rules, is_public, owner_did, None, "/") == Decision::Allow,
49+
None => false,
50+
};
51+
if !announce {
52+
return (false, None);
53+
}
54+
let withheld = match rules {
55+
Some(rules) if rules.is_empty() => Some(std::collections::HashSet::new()),
56+
// withheld_blob_oids walks every ref with blocking `git ls-tree`; keep
57+
// that off the async worker thread.
58+
Some(rules) => {
59+
let owner_did = owner_did.to_string();
60+
tokio::task::spawn_blocking(move || {
61+
crate::git::visibility_pack::withheld_blob_oids(
62+
&disk_path, &rules, is_public, &owner_did, None,
63+
)
64+
})
65+
.await
66+
.map_err(|e| {
67+
tracing::warn!(err = %e, "withheld_blob_oids task panicked; skipping replication")
68+
})
69+
.ok()
70+
.and_then(|r| {
71+
r.map_err(|e| {
72+
tracing::warn!(err = %e, "withheld_blob_oids failed; skipping replication")
73+
})
74+
.ok()
75+
})
76+
}
77+
None => None,
78+
};
79+
// Fail closed on a failed/panicked withheld walk: with `announce` already
80+
// true here, a `None` withheld can only mean the walk errored (rules are
81+
// necessarily `Some`, else we returned above). Suppress the announce too so
82+
// a push we couldn't vet does not gossip, notify peers, or anchor to Arweave.
83+
match withheld {
84+
Some(withheld) => (announce, Some(withheld)),
85+
None => (false, None),
86+
}
87+
}
88+
2089
// ── Request / Response types ───────────────────────────────────────────────
2190

2291
#[derive(Debug, Deserialize)]
@@ -692,49 +761,40 @@ pub async fn git_receive_pack(
692761
// network-facing announcements. Fail closed: a private or undetermined repo
693762
// never leaks.
694763
let rules_opt = state.db.list_visibility_rules(&record.id).await.ok();
695-
let announce = match &rules_opt {
696-
Some(rules) => {
697-
visibility_check(rules, record.is_public, &record.owner_did, None, "/")
698-
== Decision::Allow
699-
}
700-
None => false,
701-
};
702-
let withheld: Option<std::collections::HashSet<String>> = if !announce {
703-
None
764+
let (announce, withheld) = replication_withheld_set(
765+
rules_opt.clone(),
766+
&record.owner_did,
767+
record.is_public,
768+
disk_path.clone(),
769+
)
770+
.await;
771+
772+
// Compute the per-push pin candidate set once, off the async worker (git
773+
// subprocess). On the happy path this is the push delta (objects this push
774+
// introduced); on any non-commit tip or git error it fails closed to a full
775+
// repo scan. Only needed when something will actually replicate. All
776+
// degraded paths log inside the helper rather than failing silently.
777+
let candidates: Vec<String> = if withheld.is_some() {
778+
let new_tips: Vec<String> = ref_updates
779+
.iter()
780+
.map(|u| u.new_sha.clone())
781+
.filter(|s| s != ZERO_SHA)
782+
.collect();
783+
let old_tips: Vec<String> = ref_updates
784+
.iter()
785+
.map(|u| u.old_sha.clone())
786+
.filter(|s| s != ZERO_SHA)
787+
.collect();
788+
crate::git::push_delta::resolve_candidates_for_push(disk_path.clone(), new_tips, old_tips)
789+
.await
704790
} else {
705-
match &rules_opt {
706-
Some(rules) if rules.is_empty() => Some(std::collections::HashSet::new()),
707-
// withheld_blob_oids walks every ref with blocking `git ls-tree`;
708-
// keep that off the async worker thread.
709-
Some(rules) => {
710-
let path = disk_path.clone();
711-
let rules = rules.clone();
712-
let owner_did = record.owner_did.clone();
713-
let is_public = record.is_public;
714-
tokio::task::spawn_blocking(move || {
715-
crate::git::visibility_pack::withheld_blob_oids(
716-
&path, &rules, is_public, &owner_did, None,
717-
)
718-
})
719-
.await
720-
.map_err(|e| {
721-
tracing::warn!(err = %e, "withheld_blob_oids task panicked; skipping replication for this push")
722-
})
723-
.ok()
724-
.and_then(|r| {
725-
r.map_err(|e| {
726-
tracing::warn!(err = %e, "withheld_blob_oids failed; skipping replication for this push")
727-
})
728-
.ok()
729-
})
730-
}
731-
None => None,
732-
}
791+
Vec::new()
733792
};
734793

735794
// Pin new git objects to the local IPFS node (no-op if ipfs_api is empty).
736795
// Skipped entirely when the public cannot read the repo (withheld == None).
737796
if let Some(withheld_ipfs) = withheld.clone() {
797+
let candidates_ipfs = candidates.clone();
738798
let ipfs_api = state.config.ipfs_api.clone();
739799
let repo_path_clone = disk_path.clone();
740800
let db_clone = state.db.clone();
@@ -751,6 +811,7 @@ pub async fn git_receive_pack(
751811
let pinned = crate::ipfs_pin::pin_new_objects(
752812
&ipfs_api,
753813
&repo_path_clone,
814+
candidates_ipfs,
754815
&db_clone,
755816
&withheld_ipfs,
756817
)
@@ -853,6 +914,7 @@ pub async fn git_receive_pack(
853914
let self_public_url = state.config.public_url.clone();
854915
let node_keypair = Arc::clone(&state.node_keypair);
855916
let withheld_pinata = withheld;
917+
let candidates_pinata = candidates;
856918
tokio::spawn(async move {
857919
let pinned = match &withheld_pinata {
858920
Some(withheld) => {
@@ -861,6 +923,7 @@ pub async fn git_receive_pack(
861923
&pinata_upload_url,
862924
&pinata_jwt,
863925
&repo_path_clone,
926+
candidates_pinata,
864927
&db_clone,
865928
withheld,
866929
)

‎crates/gitlawb-node/src/git/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod issues;
2+
pub mod push_delta;
23
pub mod repo_store;
34
pub mod smart_http;
45
pub mod store;

0 commit comments

Comments
 (0)