Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
65ba137
feat(node,git): cap concurrent served git ops with a 503 load-shed (#62)
Jul 10, 2026
db4de84
feat(node,config): add write-pool and per-caller read-cap knobs (#62)
Jul 10, 2026
2434dde
fix(node,git): isolate authed pushes in a dedicated write pool (#62)
Jul 10, 2026
fc4c206
fix(node,git): per-caller concurrency sub-cap on the read pool (#62)
Jul 10, 2026
2c264e0
fix(node,git): bound and reap the info/refs advertisement (#62)
Jul 10, 2026
57c6e01
fix(node,git): bound and reap the withheld-blob pack build (#62)
Jul 10, 2026
6336657
docs(node,config): reconcile the concurrency-cap comments with the fi…
Jul 10, 2026
5069cd1
fix(node,concurrency): resolve #174 code-review findings
Jul 10, 2026
3b1fa54
docs(config): add GITLAWB_MAX_CONCURRENT_GIT_OPS to .env.example (#174)
Jul 10, 2026
362ee34
fix(node,git): bound the rev-list stage under drive_git_child so a hu…
Jul 11, 2026
4983b85
fix(node): key the per-caller read cap on source IP, not the signed D…
Jul 12, 2026
e444bc0
fix(node): draw the receive-pack advertisement from the write pool (#…
Jul 12, 2026
71389e6
fix(node): bound the withheld-blob walk at the shared blob_paths seam…
Jul 12, 2026
ab0ea24
docs(node): correct the git-service-timeout coverage note (#174)
Jul 12, 2026
87a7e40
fix(review): close the withheld-walk watchdog reused-pgid race (#174)
Jul 12, 2026
e750c30
fix(review): cap the receive-pack advertisement per source so anon ca…
Jul 12, 2026
c35452e
test(node): vet the withheld-walk bound end to end; use the configure…
Jul 12, 2026
454e59b
docs(node): the withheld-walk is bounded by the service timeout, not …
Jul 12, 2026
79b9ee5
fix(node): shed the served-git pool before the DB and cap per-source …
Jul 12, 2026
f19f281
fix(node): give the receive-pack advert its own pool so it can't shed…
Jul 13, 2026
473924d
fix(node): compile the bounded visibility git runner on non-Unix targ…
Jul 13, 2026
7a6bd35
fix(node): SIGKILL a SIGTERM-ignoring member of the visibility-walk p…
Jul 13, 2026
9412aa9
fix(node): bound and reap push-candidate git children under the write…
Jul 13, 2026
4410b83
docs(node): correct concurrency + timeout docs to match the final git…
Jul 13, 2026
cf97405
test(node): execution-cover the delta-path push-scan timeout wiring (…
Jul 13, 2026
a030eef
test(node): retry the ETXTBSY exec race in build_filtered_pack timeou…
Jul 13, 2026
3cc0189
fix(node): keep the visibility-walk watchdog armed until the child is…
Jul 15, 2026
ac59bd7
fix(node): reap the served-git process group on disconnect, not just …
Jul 15, 2026
be0cdd6
fix(node): hold upload-pack admission through the walk on disconnect …
Jul 15, 2026
1264357
fix(node): cap concurrent receive-pack pushes per source IP (#174)
Jul 15, 2026
2a54c15
fix(node): bound concurrent post-push encryption walks with an admiss…
Jul 15, 2026
5749df6
test(node): INV-22 completeness guard for the served-git concurrency …
Jul 15, 2026
72e899d
fix(node): hold served-git admission until the process group is reape…
Jul 15, 2026
8d17038
fix(node): bound the served-git storage-acquisition phase with a rele…
Jul 15, 2026
f424ccb
fix(node): gate the /ipfs walk with bounded concurrency admission + p…
Jul 15, 2026
3947687
fix(node): bound the post-push encryption task set by per-repo coales…
Jul 15, 2026
413d6cf
fix(node): reject unsupported git service before the read slot; docum…
Jul 15, 2026
433c891
feat(node): integrate the #173 IPFS CID tree-gate onto the #174 walk-…
Jul 17, 2026
8afc9ea
fix(node): close grok P1 findings on the #173/#174 IPFS integration
Jul 17, 2026
c767658
fix(node): bound the /ipfs cat-file probes under the walk permit; bac…
Jul 17, 2026
2eea354
fix(node): tie the /ipfs walk ceiling to MAX_PIN_SOURCES + 1
Jul 17, 2026
94ca744
fix(node): align ipfs_pin return with pinata; add Retry-After to Sear…
Jul 17, 2026
d26cf6d
fix(node): close the /ipfs pin-source griefing hole via a bounded sca…
Jul 17, 2026
8682804
fix(node): hold read admission through the filtered-pack reaper
Jul 20, 2026
757edf5
fix(node): own /ipfs admission for the life of the walk
Jul 20, 2026
3351e09
fix(node): requeue coalesced pushes instead of dropping them
Jul 21, 2026
c2fce67
fix(node): wire GITLAWB_IPFS_MAX_REPOS_WALKED to the legacy-probe budget
Jul 21, 2026
0a1618f
fix(node): split /ipfs work accounting off the route rate limiter
Jul 21, 2026
b774c43
fix(node): retry pin-source and pinned-CID recording on transient errors
Jul 21, 2026
45e269b
fix(node): opportunistically repair legacy provider-CID pins
Jul 21, 2026
fba3c6f
fix(review): share one deadline across the /ipfs size+content reads
Jul 21, 2026
62a9927
fix(node): bound the post-push pin and repair reads so a wedge cannot…
Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,73 @@ GITLAWB_MAX_PACK_BYTES=2147483648

# Max seconds a served git upload-pack / receive-pack (clone / push) may run
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
# worker and, on push, the repo write lock. Does NOT cover the info/refs
# advertisement or the withheld-blob path, which remain unbounded. Default 600.
# worker and, on push, the repo write lock. Also bounds both info/refs
# advertisements, the withheld-blob pack build, and the push-side candidate
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
# Default 600.
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600

# Max seconds the storage-ACQUISITION phase of a served git op may run before the
# request is shed with a 503, separate from the git-run timeout above. A
# concurrency permit is taken before this phase and GITLAWB_GIT_SERVICE_TIMEOUT_SECS
# only starts once git spawns, so without this a stalled backend (a hung Tigris
# HEAD/GET, or a hung pg advisory-lock iteration on push) pins the permit and drains
# the pool until every later request 503s. On expiry the permit is released
# (fail-closed). Kept separate because acquisition and git execution are distinct
# cost centers. Must be positive; set very large to effectively disable. Default 30.
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS=30

# Max concurrent git READ ops (upload-pack + the upload-pack info/refs
# advertisement) served at once, a global pool separate from the push pool below.
# The anon receive-pack info/refs advertisement has its OWN pool (see below), not
# this one. Over-cap sheds a clean 503 + Retry-After. Anonymous reads draw from
# here, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER (below) so one
# caller cannot monopolize the pool. Default 128.
GITLAWB_MAX_CONCURRENT_GIT_OPS=128

# Max concurrent git-receive-pack (push) POST operations, in a pool separate from
# the read pool (GITLAWB_MAX_CONCURRENT_GIT_OPS) so anonymous reads cannot shed an
# authenticated push at admission. The anon receive-pack info/refs advertisement
# runs in a SEPARATE pool of the same size (disjoint from this one), so an
# advertisement flood cannot shed a push either. Over-cap sheds a 503 +
# Retry-After. Default 32.
GITLAWB_MAX_CONCURRENT_GIT_PUSHES=32

# Max concurrent read ops (upload-pack + the upload-pack info/refs advertisement)
# a single caller may hold, so one caller cannot monopolize the read pool. Keyed
# on the resolved SOURCE IP, never the DID: a signature does not move a caller off
# this cap. The source-IP key is only as granular as GITLAWB_TRUSTED_PROXY below:
# left unset, a node behind an edge/NAT keys all callers on the edge IP and this
# collapses to one global cap. Set GITLAWB_TRUSTED_PROXY for per-client keying; a
# high-fanout caller (CI behind one NAT) then needs the operator to raise this.
# Default 16.
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16

# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
# Max receive-pack requests (info/refs advertisement + push POST) per client
# IP per hour. 0 disables. Default 600.
GITLAWB_PUSH_RATE_LIMIT=600

# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
# so it is bounded to keep a permissionless caller from fanning out unbounded
# concurrent walks and exhausting blocking-pool threads + PIDs.
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
# the served-git pools). Over-cap sheds a 503. Default 32.
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
# Default 4.
GITLAWB_IPFS_WALK_PER_SOURCE=4
# Max legacy (NULL-provenance) repos probed per single /ipfs request, bounding the
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
# truncated scan sheds a retryable 503, never a false 404. Default 256.
GITLAWB_IPFS_MAX_REPOS_WALKED=256
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
# from the concurrency caps above). 0 disables. Default 600.
GITLAWB_IPFS_RATE_LIMIT=600

# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
Expand Down
11 changes: 6 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,20 @@ Important node settings:
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. |
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |

Production note: change the default Postgres password before exposing a node publicly.

Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.

---

## Optional node staking
Expand Down
45 changes: 45 additions & 0 deletions crates/gitlawb-core/src/cid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ impl Cid {
}
}

/// True when `s` parses as a CIDv1 with the raw codec — the exact shape
/// [`Cid::from_git_object_bytes`] produces and the `/ipfs` resolver looks up.
/// A legacy provider CID (Kubo dag-pb, Pinata CIDv0) parses to a different
/// version or codec and returns `false`, marking it an opportunistic-repair
/// candidate. Decidable from the string alone (no object bytes), so the pin path
/// can gate the byte-read/recompute cost on it and leave non-legacy rows at the
/// existing DB-only skip cost. An unparseable string is non-canonical (`false`).
pub fn is_raw_cidv1(s: &str) -> bool {
s.parse::<CidGeneric<64>>()
.map(|c| c.version() == cid::Version::V1 && c.codec() == RAW)
.unwrap_or(false)
}

impl fmt::Display for Cid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
Expand Down Expand Up @@ -177,6 +190,38 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn is_raw_cidv1_classifies_codec_from_string() {
// The canonical resolver key: CIDv1 + raw codec → not a repair candidate.
let raw = Cid::from_git_object_bytes(b"blob 5\0hello");
assert!(
is_raw_cidv1(raw.as_str()),
"from_git_object_bytes output is CIDv1/raw"
);

// A CIDv0 (Pinata dag-pb legacy shape) → repair candidate.
assert!(
!is_raw_cidv1("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"),
"a CIDv0 dag-pb value is a legacy-repair candidate"
);

// A CIDv1 with the dag-pb codec (the Kubo above-block-size root) over the
// same multihash → still a repair candidate (codec, not just version).
let parsed = raw.as_str().parse::<CidGeneric<64>>().unwrap();
const DAG_PB: u64 = 0x70;
let dagpb = CidGeneric::<64>::new_v1(DAG_PB, *parsed.hash()).to_string();
assert!(
!is_raw_cidv1(&dagpb),
"a CIDv1 dag-pb value is a legacy-repair candidate"
);

// Garbage is non-canonical.
assert!(
!is_raw_cidv1("not-a-cid"),
"an unparseable string is non-canonical"
);
}

#[test]
fn sha256_hex_of_empty_input_is_well_known() {
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
Expand Down
Loading
Loading