Compare commits

..
Author SHA1 Message Date
beardthelion e50506561d Merge remote-tracking branch 'origin/main' into pr-465 2026-11-08 15:43:29 -06:00
cairn-intern 7dbdf047ab fix(node): address beardthelion P3 findings on name bound and input messages
- Assert each rejection's own message in the reposPage invalid-inputs
  loop: the loop no longer accepts either message for every query, so a
  misattributed message (limit arm returning the cursor message) goes red.
- Pin the create/fork name-bound wiring through the handler: new test
  drives create_repo with an overlong name via test_state_lazy and asserts
  the 400; deleting the validate_repo_name call site would proceed to the
  lazy DB pool and fail with a connection error instead.
- Document the API-side vs disk-side validate_repo_name divergence on the
  new function, and tie the store's literal 100 to MAX_REPO_NAME_LEN so
  the two validators cannot drift apart on length.
2026-11-07 05:35:14 +00:00
Marlowe 1c5838b561 fix(node)!: bound repo names so emitted cursors always parse
The reposPage cursor embeds base64(JSON(owner_did, name)) while
parse_repo_cursor rejects cursors over 4096 bytes, but repo names were
unbounded on every write path. A single long-named visible repo made the
server emit an endCursor it then rejected as "invalid repository cursor",
dead-ending pagination for every caller past that row.

Bound names to MAX_REPO_NAME_LEN (100 bytes) at the create_repo and
fork_repo handlers via a shared validate_repo_name helper (bad charset is
still a 400), and reject overlong peer-supplied names in
Db::upsert_mirror_repo (the sync caller already drops the error and skips
the mirror).

Also in this round:
- docs/graphql-pagination.md states the 200 page-size maximum and corrects
  the over-budget remedy: a smaller limit does not reduce the reposPage or
  repos price, which is charged at the full MAX_VISIBLE_REPO_PAGE_SIZE
  scan regardless of limit.
- Tests pin refUpdates(limit: -1) clamping to zero rows, the
  parse_repo_cursor wrong-shape-JSON arm with its exact "invalid
  repository cursor" message, the longest-admissible-name cursor
  round-trip, and the mirror write-path rejection.

BREAKING CHANGE: the GraphQL `repos` field now returns an error instead of
the full list when more than MAX_VISIBLE_REPO_PAGE_SIZE (200) repositories
are visible; larger lists must use `reposPage` with `limit`/`after`.
Repository names are additionally limited to 100 bytes at creation, fork,
and mirror-registration time.
2026-11-07 05:35:13 +00:00
Marlowe 35bbf9e6bb fix(node): bound GraphQL query cost 2026-11-07 05:35:10 +00:00
26 changed files with 568 additions and 14362 deletions

No files matched your search

-31
View File
@@ -21,25 +21,6 @@ ignore = [
# the build, at which point it becomes a real reachable advisory.
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
# lru 0.16.3 (use-after-free in pop()). Reachable via alloy. The
# lockfile holds only 0.16.3 — the aws-sdk-s3 path that once pulled
# 0.12.5 no longer does. The RUSTSEC-2026-0253 advisory covers this
# range, so the ignore stays load-bearing until a fixed 0.16.x lands;
# REMOVE when the locked version is fixed or lru leaves the tree.
# Retirement owner: node maintainers, enforced by the weekly
# audit-schedule drift guard ("Fail if lru moved while its ignore
# remains"), which fails if either locked version moves or lru
# leaves the tree. No separate tracking issue exists; file one
# against the lru upgrade if this waiver needs discussion outside
# the schedule job.
"RUSTSEC-2026-0253",
#
# Round-3 P2 (reviewer 2): the previous version of this file
# ignored RUSTSEC-2026-0258 for h2 0.4.13, but the lockfile
# already has h2 0.4.18 (verified 2026-08-31: cargo tree -p h2
# shows 0.4.18) — the fix is in. The ignore was stale and would
# have silently re-accepted the advisory if h2 dropped back to
# 0.4.13. Removed.
# crypto-bigint 0.5.5 (secret-dependent branches in operations meant
# to be constant-time). REACHABLE accepted risk, on two paths:
# - alloy-signer -> elliptic-curve 0.13 -> crypto-bigint 0.5: the k256
@@ -58,16 +39,4 @@ ignore = [
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
# libsqlite3-sys 0.30.1 (bundled SQLite WAL uninitialized-memory disclosure,
# CVE-2026-119375). Present in Cargo.lock because sqlx resolves sqlx-sqlite
# unconditionally; sqlx is built postgres-only
# (crates/gitlawb-node/Cargo.toml), so sqlx-sqlite and its bundled SQLite C
# source are never compiled or linked (`cargo tree -i sqlx-sqlite` prints
# nothing) and there is no reachable WAL open to attack. The fix requires
# libsqlite3-sys >= 0.39, but sqlx-sqlite 0.8.6 pins ^0.30.1; a compatible
# bump needs a sqlx upgrade, not a lockfile refresh. REMOVE this ignore if
# sqlx is ever built with the `sqlite` feature or any other libsqlite3-sys
# consumer enters the build.
"RUSTSEC-2026-0459", # libsqlite3-sys bundled SQLite CVE (not linked in our build)
]
-14
View File
@@ -359,20 +359,6 @@ GITLAWB_TRUSTED_PROXY=
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false
# ── Reconciliation sweep ─────────────────────────────────────────────────
# Periodic durability sweep: re-derives the public pin set and the withheld-blob
# recovery set each hour and fills gaps so a dropped replication job never means
# data loss. Defaults to true; set to false to disable the sweep even when a pin
# backend (IPFS/Pinata) is configured.
#
# Phase-capability matrix:
# - Public pin repair: IPFS-only, Pinata-only, or both (requires the
# respective backend to be configured).
# - Encrypted recovery repair: requires local IPFS (GITLAWB_IPFS_API).
# Pinata-only nodes reconcile public pins only; encrypted recovery
# reconciliation is not performed.
GITLAWB_RECONCILIATION_SWEEP=true
# ── iCaptcha proof-of-intelligence gate ───────────────────────────────────
# Optional gate on create_repo + register: require callers to present an
# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com.
-24
View File
@@ -86,27 +86,3 @@ jobs:
exit 1
fi
echo "No drift: ignore list is consistent with the pinned hickory-proto version."
# Drift guard: the RUSTSEC-2026-0253 (lru) ignore is only valid while
# the lockfile holds exactly the version it was written for (0.16.3
# via alloy). Any move — an upgrade that may carry the upstream fix,
# or removal of the dependency — must revisit the ignore. Retirement
# owner: node maintainers, via this weekly job (no separate tracking
# issue; see the waiver comment in .cargo/audit.toml).
- name: Fail if lru moved while its ignore remains
run: |
set -euo pipefail
versions="$(grep -A1 'name = "lru"' Cargo.lock | grep '^version' | cut -d'"' -f2 | sort -u | tr '\n' ' ')"
echo "lru versions in Cargo.lock: ${versions:-not present}"
lru_ignore_present=false
if grep -q 'RUSTSEC-2026-0253' .cargo/audit.toml; then
lru_ignore_present=true
fi
# Drift = the lockfile no longer matches what the ignore assumes:
# the version moved (possible fix) or lru left the tree
# (dead ignore). Both must fail for explicit re-triage.
if [ "$lru_ignore_present" = true ] && [ "${versions}" != "0.16.3 " ]; then
echo "::error::lru versions are [${versions:-absent from Cargo.lock}] but the RUSTSEC-2026-0253 ignore is still in .cargo/audit.toml. Re-triage the waiver against the advisory's fixed range and either drop the ignore or update the pinned set."
exit 1
fi
echo "No drift: lru ignore is consistent with the pinned versions."
Generated
-1
View File
@@ -3565,7 +3565,6 @@ dependencies = [
"multiaddr",
"percent-encoding",
"prometheus",
"rand 0.8.6",
"reqwest",
"serde",
"serde_json",
-1
View File
@@ -404,7 +404,6 @@ Important node settings:
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_RECONCILIATION_SWEEP` | Enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. |
| `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, 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. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
| `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. |
-37
View File
@@ -539,43 +539,6 @@ mod tests {
assert!(matches!(err, Error::Signature(_)));
}
/// The identity-point forgery must be rejected: the shared attestation
/// verifier is a cert-bound provenance gate, so accepting the weak-key
/// signature would let anyone mint a forged attestation that verifies.
/// Strict verification rejects small-order public keys and R (the identity
/// point here), which ordinary verification does not.
#[test]
fn verify_rejects_identity_point_forgery() {
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&fresh(), cert_hash);
// Public key A = identity point (0,1); signature R = identity, S = 0.
// The equation `[S]B = R + [k]A` then holds for any k and any message.
let identity = [
1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
];
let mut buf = Vec::with_capacity(34);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&identity);
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let mut sig = [0u8; 64];
sig[..32].copy_from_slice(&identity);
att.sig = B64U.encode(sig);
let err = att.verify_signature(cert_hash).unwrap_err();
match err {
Error::Did(msg) => assert!(
msg.contains("small-order"),
"identity-point signer must be refused as a small-order key, got: {msg}"
),
other => panic!("expected Error::Did for an identity-point signer, got {other:?}"),
}
}
/// A payload that happens to contain a `cert_hash` field of its own does
/// not interfere with the outer binding: the attestation envelope's
/// `cert_hash` is the only field consulted by `verify_signature`, and the
-40
View File
@@ -77,14 +77,6 @@ impl Keypair {
}
/// Verify an Ed25519 signature.
///
/// Strict verification: rejects small-order `R` and small-order public keys
/// (the identity point, and any point of low order). Ordinary `verify` accepts
/// a signature forged with the identity point as the public key plus
/// `R = identity, S = 0`, which verifies for *any* message. `identity::verify`
/// is the shared primitive behind HTTP request authentication, UCANs, and
/// certificates, so weak-key acceptance is an authentication bypass, not a
/// malleability nuance.
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
let sig = Signature::from_bytes(sig_bytes);
verifying_key
@@ -216,38 +208,6 @@ mod tests {
);
}
/// The identity-point forgery: with public key A = identity, R = identity,
/// and S = 0, the equation `[S]B = R + [k]A` holds for every message,
/// because `[k]·identity = identity`. Ordinary (non-strict) Ed25519
/// verification accepts it, so the shared `verify` primitive must use
/// strict verification, which rejects small-order R and public keys.
#[test]
fn verify_rejects_identity_point_forgery() {
use ed25519_dalek::Verifier;
// The identity point (0,1) compresses to y = 1 with sign bit 0.
let identity = [
1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
];
let vk = VerifyingKey::from_bytes(&identity).expect("identity point is on the curve");
let mut sig_bytes = [0u8; 64];
sig_bytes[..32].copy_from_slice(&identity);
let msg = b"arbitrary message the key owner never signed";
// Prove the forged signature satisfies the ordinary verification
// equation, so the strict check below is what actually defends the
// boundary (not a signature that was already invalid everywhere).
assert!(
vk.verify(msg, &Signature::from_bytes(&sig_bytes)).is_ok(),
"identity-point forgery must satisfy ordinary verification (this is why strict is needed)"
);
assert!(
verify(&vk, msg, &sig_bytes).is_err(),
"strict verification must reject the identity-point forgery"
);
}
#[test]
fn verify_rejects_weak_key_signature() {
// Regression guard for strict verification: a signature forged under a
-1
View File
@@ -75,7 +75,6 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
rand = { workspace = true }
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
[dev-dependencies]
+3 -421
View File
@@ -2130,32 +2130,9 @@ async fn gate_and_serve(
/// GET /api/v1/ipfs/pins
///
/// Returns all CIDs that have been pinned from git objects received via push.
/// Each entry includes the git SHA-256 hex and the timestamp when it was
/// pinned. The wire contract is nullable by design:
///
/// - `cid` / `local_cid` — node-local resolver keys: the raw CIDv1 the
/// `GET /ipfs/{cid}` endpoint serves. Non-null ONLY when the stored value
/// parses as a raw CIDv1 AND writer-owned local provenance confirms the
/// bytes went through this node's IPFS daemon. Otherwise null, even when
/// the database holds some other CID-shaped string for the row.
/// - `pinata_cid` — the provider identifier Pinata returned (dag-pb/UnixFS,
/// not a node resolver key). The only usable identifier for remote-only
/// rows; `gl` consumers must read this field, never `cid`, for those.
/// - `local_pinned` — `true` iff the local-IPFS writer path
/// (`record_pinned_cid_with_source`) pushed the bytes into the local
/// daemon. Writer-owned; never inferred from CID shape. A row can carry
/// `local_pinned = true` with a null `cid` when its stored key predates
/// the raw-key contract (legacy provider key awaiting repair).
/// - `pinata_pinned` — `true` iff the row has a non-null `pinata_cid`.
///
/// Rows with neither a local nor a Pinata CID are omitted so the response
/// only contains rows with at least one backend. Remote-only rows stay
/// visible through `pinata_cid`/`pinata_pinned`; nothing is hidden.
///
/// #218 review P2: the response surfaces writer-owned provenance so a
/// `gl` consumer can distinguish local-only, remote-only, and dual rows
/// without re-inferring semantics from nullability.
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(
State(state): State<AppState>,
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
@@ -2169,38 +2146,6 @@ pub async fn list_pins(
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let pins = state.db.list_pinned_cids().await?;
let pins: Vec<serde_json::Value> = pins
.into_iter()
.filter(|p| p.cid.is_some() || p.pinata_cid.is_some())
.map(|p| {
// A stored `cid` is advertised as a node-local resolver key
// only when it passes the raw-CIDv1 contract AND local
// provenance is confirmed. The historical `cid` column has
// held more than one namespace (Kubo dag-pb, Pinata CIDv0),
// and the `/ipfs/{cid}` resolver recomputes the raw CID
// from object bytes and rejects anything else — so a
// legacy provider key in `cid`/`local_cid` would promise a
// local object the node immediately refuses to serve.
// Such rows stay listed (with their provenance flags) but
// carry no local key until repair rewrites them.
let local_key = match &p.cid {
Some(c) if gitlawb_core::cid::is_raw_cidv1(c) && p.local_ipfs_provenance => {
Some(c.clone())
}
_ => None,
};
serde_json::json!({
"sha256_hex": p.sha256_hex,
"cid": local_key.clone(),
"local_cid": local_key,
"pinata_cid": p.pinata_cid,
"local_pinned": p.local_ipfs_provenance,
"pinata_pinned": p.pinata_cid.is_some(),
"pinned_at": p.pinned_at,
})
})
.collect();
Ok(Json(serde_json::json!({
"pins": pins,
"count": pins.len(),
@@ -2514,369 +2459,6 @@ mod closed_pool_tests {
);
}
/// #218 review P2: the `list_pins` API response surfaces writer-owned
/// provenance (`local_pinned`, `pinata_pinned`) so a `gl` consumer
/// can distinguish local-only, remote-only, and dual rows without
/// re-inferring semantics from nullability. `cid`/`local_cid` carry a
/// value ONLY for rows whose stored key parses as a raw CIDv1 with
/// confirmed local provenance; anything else (Pinata-only rows,
/// legacy provider keys) exposes its usable identifier through
/// `pinata_cid` alone, and every non-null local key must resolve
/// through `GET /ipfs/{cid}`.
#[sqlx::test]
async fn list_pins_reports_writer_owned_provenance_for_all_shapes(pool: sqlx::PgPool) {
use sqlx::Row as _;
let state = crate::test_support::test_state(pool.clone()).await;
let db = &state.db;
// Raw resolver keys must be REAL raw CIDv1 strings: the handler
// gates `cid`/`local_cid` on `is_raw_cidv1`, so placeholder text
// would fail the gate for the wrong reason. Provider CIDs stay
// opaque Qm… strings (never syntax-checked, only echoed).
fn raw_cid_for(content: &[u8]) -> String {
gitlawb_core::cid::Cid::from_git_object_bytes(content).to_string()
}
// Two real blobs on disk in one public repo, so the local and
// dual rows below can be followed through `GET /ipfs/{cid}`.
// The resolver locates repos through `repo_store.acquire`, not
// the row's `disk_path`, so the recipe overwrites the acquired
// bare path with a real clone: workdir commit of both files,
// cloned --bare into the store path, oids via rev-parse, raw
// keys recomputed from the file bytes exactly as the serve
// path does.
fn run_git(args: &[&str], cwd: &std::path::Path) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let content1 = b"pins follow-through one\n";
let content2 = b"pins follow-through two\n";
let tmp = tempfile::TempDir::new().unwrap();
let work = tmp.path().join("work-pins-list");
std::fs::create_dir_all(&work).unwrap();
std::fs::write(work.join("one.txt"), content1).unwrap();
std::fs::write(work.join("two.txt"), content2).unwrap();
run_git(
&["init", "-q", "--object-format=sha256", "-b", "main"],
&work,
);
run_git(&["config", "user.email", "t@t"], &work);
run_git(&["config", "user.name", "t"], &work);
run_git(&["add", "."], &work);
run_git(&["commit", "-qm", "seed"], &work);
let oid_of = |spec: &str| {
let out = std::process::Command::new("git")
.args(["rev-parse", spec])
.current_dir(&work)
.output()
.expect("git rev-parse runs");
assert!(out.status.success(), "rev-parse {spec} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let sha_local = oid_of("HEAD:one.txt");
let sha_dual = oid_of("HEAD:two.txt");
let raw1 = raw_cid_for(content1);
let raw4 = raw_cid_for(content2);
db.upsert_mirror_repo(
"zPinsListOwner",
"pins-list",
"/unused-pins-list",
None,
false,
)
.await
.unwrap();
let rec = db
.get_repo("zPinsListOwner", "pins-list")
.await
.unwrap()
.expect("mirror repo row");
let bare = state
.repo_store
.acquire(&rec.owner_did, &rec.name)
.await
.unwrap();
let _ = std::fs::remove_dir_all(&bare);
std::fs::create_dir_all(&bare).unwrap();
run_git(
&[
"clone",
"--bare",
"-q",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
tmp.path(),
);
// The combination table for what the response must carry:
//
// shape | local_pinned | pinata_pinned | local_cid | pinata_cid
// -------------------+--------------+---------------+-----------+-----------
// local-only | true | false | raw | null
// pinata-only (raw) | false | true | null | provider
// pinata-only (null) | false | true | null | raw(=provider)
// dual | true | true | raw | provider
// legacy provider key| true | false | null | null
//
// The pinata-only (raw) row keeps a genuine raw key in the
// column but must NOT surface it: provenance is unconfirmed,
// so no local field may carry it. The legacy row predates the
// raw-key contract (dag-pb key, provenance claimed) and must
// surface no local key until repair rewrites it — while the
// row itself (and its `local_pinned` flag) stays visible.
//
// Distinct shas (the table is keyed on sha256_hex) keep the
// rows independent.
// (1) local-only: real local pin via the production writer.
db.record_pinned_cid_with_source(&sha_local, &raw1, &rec.id)
.await
.unwrap();
// (2) pinata-only (raw != provider): the post-v27/v30 row shape
// produced by `record_pinata_cid`. The stored raw key is genuine
// but local provenance is unconfirmed, so neither local field
// may carry it; the provider field is the usable identifier.
let sha_pinata_raw = "sha_p2_pinata_only_distinct_cids";
let raw2 = raw_cid_for(b"pins pinata-only bytes\n");
let pinata2 = "QmPinataProviderCidForRawOnlyRow";
assert_ne!(raw2, pinata2);
db.record_pinata_cid(
sha_pinata_raw,
&raw2,
pinata2,
Some("repo-p2-pinata-raw"),
i64::MAX,
)
.await
.unwrap();
// (3) pinata-only (raw == provider): `record_pinata_cid` stores
// `cid = NULL` so the resolver key isn't aliased to a dag-pb
// provider CID that doesn't hash to the raw bytes (#173).
let sha_pinata_null = "sha_p2_pinata_only_null_cid";
let same = "QmPinataOnlyCidRawEqualsProvider";
db.record_pinata_cid(
sha_pinata_null,
same,
same,
Some("repo-p2-pinata-null"),
i64::MAX,
)
.await
.unwrap();
// (4) dual: local first (sets the flag and `cid`), then Pinata
// (sets `pinata_cid` and preserves `cid` and `local_ipfs_provenance`).
let pinata4 = "QmPinataProviderCidForDualRow";
db.record_pinned_cid_with_source(&sha_dual, &raw4, &rec.id)
.await
.unwrap();
db.record_pinata_cid(&sha_dual, &raw4, pinata4, Some(&rec.id), i64::MAX)
.await
.unwrap();
// (5) legacy provider key: a pre-fix row whose `cid` is a dag-pb
// provider identifier with claimed local provenance. Production
// writers can no longer produce this shape, so it is seeded with
// raw SQL — the point is that the projection must not trust column
// shape as proof of a raw local key.
let sha_legacy = "sha_p2_legacy_provider_key";
sqlx::query(
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid, repo_id, local_ipfs_provenance)
VALUES ($1, $2, $3, NULL, $4, TRUE)",
)
.bind(sha_legacy)
.bind("QmLegacyProviderKeyPredatesRawContract")
.bind("2026-07-01T00:00:00Z")
.bind(&rec.id)
.execute(&pool)
.await
.unwrap();
// Hit the production handler end-to-end through a one-shot router
// request, so the test exercises the actual response shape
// (not a unit test on PinnedCidRecord fields).
let resp = Router::new()
.route("/api/v1/ipfs/pins", axum::routing::get(list_pins))
.layer(axum::Extension(crate::auth::AuthenticatedDid(
"did:key:zPinListTest".to_string(),
)))
.with_state(state.clone())
.oneshot(
axum::http::Request::builder()
.uri("/api/v1/ipfs/pins")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
let pins = v
.get("pins")
.and_then(|p| p.as_array())
.expect("pins array");
assert_eq!(
pins.len(),
5,
"all five shapes must appear in the response (every row has at least one of cid/pinata_cid set)"
);
// Build a map sha -> pin object for ergonomic assertions.
let by_sha: std::collections::HashMap<String, &Value> = pins
.iter()
.map(|p| {
(
p.get("sha256_hex")
.and_then(|s| s.as_str())
.unwrap()
.to_string(),
p,
)
})
.collect();
// Helper: assert a single pin's fields.
let assert_pin = |sha: &str,
local_pinned: bool,
pinata_pinned: bool,
local_cid: Option<&str>,
pinata_cid: Option<&str>| {
let pin = by_sha.get(sha).unwrap_or_else(|| {
panic!(
"pin row for {sha} missing; got shas {:?}",
by_sha.keys().collect::<Vec<_>>()
)
});
assert_eq!(
pin.get("local_pinned").and_then(|v| v.as_bool()),
Some(local_pinned),
"{sha}: local_pinned mismatch",
);
assert_eq!(
pin.get("pinata_pinned").and_then(|v| v.as_bool()),
Some(pinata_pinned),
"{sha}: pinata_pinned mismatch",
);
assert_eq!(
pin.get("local_cid").and_then(|v| v.as_str()),
local_cid,
"{sha}: local_cid mismatch",
);
assert_eq!(
pin.get("pinata_cid").and_then(|v| v.as_str()),
pinata_cid,
"{sha}: pinata_cid mismatch",
);
// Round-3 P2: `cid` is the LOCAL resolver key, full
// stop. It is the raw CID when the row has a local pin
// (local-only or dual shape) and `None` for Pinata-only
// rows. The previous contract aliased the Pinata provider
// CID into `cid` for Pinata-only rows, which made
// `gl ipfs list` advertise a CID the node's own
// `/ipfs/{cid}` resolver cannot serve (404). The
// provenance split is exposed via `local_cid`,
// `pinata_cid`, and the boolean `local_pinned` /
// `pinata_pinned` flags.
let cid_value = pin.get("cid").cloned().unwrap_or(serde_json::Value::Null);
let expected_cid = local_cid
.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null);
assert_eq!(cid_value, expected_cid, "{sha}: cid mismatch");
};
// (1) local-only
assert_pin(&sha_local, true, false, Some(raw1.as_str()), None);
// (2) pinata-only (raw != provider): the stored raw key is
// genuine but provenance is unconfirmed, so NO local field may
// carry it — remote-only state is visible through `pinata_cid`
// alone.
assert_pin(sha_pinata_raw, false, true, None, Some(pinata2));
// (3) pinata-only (raw == provider) — cid NULL, pinata_cid set
assert_pin(sha_pinata_null, false, true, None, Some(same));
// (4) dual
assert_pin(&sha_dual, true, true, Some(raw4.as_str()), Some(pinata4));
// (5) legacy provider key: no local field carries the dag-pb
// key, but the row and its claimed provenance stay visible so
// the repair gap is observable rather than hidden.
assert_pin(sha_legacy, true, false, None, None);
// Sanity: the `local_ipfs_provenance` column itself is what
// powers `local_pinned`, so the response field must agree
// with the database column. Reading directly avoids any
// confusion if the writer path changes.
let rows = sqlx::query("SELECT sha256_hex, local_ipfs_provenance FROM pinned_cids")
.fetch_all(&pool)
.await
.unwrap();
let mut db_provenance: std::collections::HashMap<String, bool> = Default::default();
for r in rows {
let sha: String = r.get("sha256_hex");
let p: bool = r.get("local_ipfs_provenance");
db_provenance.insert(sha, p);
}
for (sha, expected) in [
(sha_local.as_str(), true),
(sha_pinata_raw, false),
(sha_pinata_null, false),
(sha_dual.as_str(), true),
(sha_legacy, true),
] {
assert_eq!(
db_provenance.get(sha).copied(),
Some(expected),
"db column for {sha} does not match expected writer-owned provenance"
);
}
// Follow-through: every non-null local key in the response must
// resolve through `GET /ipfs/{cid}`. The local-only and dual rows
// above name real blobs on disk in a public repo, so both must
// serve their exact bytes; the Pinata-only and legacy rows carry
// no local key and have nothing to follow.
let follow_router = Router::new()
.route("/ipfs/{cid}", axum::routing::get(get_by_cid))
.with_state(state.clone());
for (raw, content) in [(&raw1, &content1[..]), (&raw4, &content2[..])] {
let resp = follow_router
.clone()
.oneshot(
axum::http::Request::builder()
.uri(format!("/ipfs/{raw}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::OK,
"advertised local key {raw} must resolve"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(
body.as_ref(),
content,
"resolved bytes for {raw} must match the pinned blob"
);
}
}
/// #251 / CodeRabbit nit: cover `get_by_cid`'s DB-error conversion path — a
/// valid CID must still yield 503 on a closed pool.
#[sqlx::test]
+19 -91
View File
@@ -1490,17 +1490,14 @@ async fn pin_new_objects_gated(
db: &Arc<crate::db::Db>,
repo_id: &str,
batch_budget: std::time::Duration,
) -> crate::ipfs_pin::PinBatchOutcome {
) -> Vec<(String, String)> {
// Nothing to pin: answer before taking a permit (#174 F2b). The permit bounds how
// many pin loops run concurrently, and an empty list does no pinning, so parking
// here would spend a global pin slot on no work. The pool DEFERS rather
// than sheds, so those calls stall pins for every other repo. Empty is the normal
// shape for a push whose walk failed or that may replicate nothing.
if object_list.is_empty() {
return crate::ipfs_pin::PinBatchOutcome {
confirmed: Vec::new(),
last_attempted: None,
};
return Vec::new();
}
let _permit = pin_sem
.clone()
@@ -1516,7 +1513,6 @@ async fn pin_new_objects_gated(
db,
repo_id,
batch_budget,
None,
)
.await
}
@@ -1551,9 +1547,9 @@ async fn pin_and_encrypt_objects(
crate::ipfs_pin::PIN_BATCH_BUDGET,
)
.await;
if !pinned.confirmed.is_empty() {
tracing::info!(count = pinned.confirmed.len(), "pinned git objects to IPFS");
for (sha, cid) in &pinned.confirmed {
if !pinned.is_empty() {
tracing::info!(count = pinned.len(), "pinned git objects to IPFS");
for (sha, cid) in &pinned {
tracing::info!(sha = %sha, %cid, "pinned");
}
}
@@ -1585,14 +1581,7 @@ async fn pin_and_encrypt_objects(
&ctx.db,
repo_id,
&node_seed,
// The real git, not `ctx.git_bin`: tests point that at a fake
// walk git, and the seal reads must run the real one.
"git",
crate::ipfs_pin::PIN_BATCH_BUDGET,
&recipients,
// Push path: recipients derived at admission under a write lease,
// no sweep-style snapshot to fence (see PolicyFence's doc).
None,
)
.await;
@@ -3067,34 +3056,19 @@ async fn post_receive_replication_tail(
&db_clone,
&repo_id,
crate::ipfs_pin::PIN_BATCH_BUDGET,
// Push path: no sweep-style batch snapshot to fence (see
// PolicyFence's doc).
None,
)
.await,
)
} else {
(
false,
crate::ipfs_pin::PinBatchOutcome {
confirmed: Vec::new(),
last_attempted: None,
},
)
(false, Vec::new())
};
if !pinned.confirmed.is_empty() {
tracing::info!(
count = pinned.confirmed.len(),
"pinned git objects to Pinata"
);
if !pinned.is_empty() {
tracing::info!(count = pinned.len(), "pinned git objects to Pinata");
}
// Build sha→cid map from durably recorded pins only: an
// unconfirmed provider upload must not drive branch/gossip CID
// state for a row the resolver cannot serve.
let cid_map: std::collections::HashMap<String, String> =
pinned.confirmed.into_iter().collect();
// Build sha→cid map from pinned objects
let cid_map: std::collections::HashMap<String, String> = pinned.into_iter().collect();
// Record branch→CID for each ref update and publish gossip
for (ref_name, old_sha, new_sha) in &ref_updates_clone {
@@ -5970,13 +5944,7 @@ mod tests {
)
.await;
assert!(replicable.contains(&head), "HEAD commit kept");
// The root tree is dropped too under the structural gate: its bytes
// name the withheld subtree's OID, so pinning it to public IPFS leaks
// that subtree's existence (the same leak class as the subtree).
assert!(
!replicable.contains(&root_tree),
"root tree naming a withheld subtree drops under the structural gate"
);
assert!(replicable.contains(&root_tree), "root tree kept");
assert!(replicable.contains(&public_tree), "public tree kept");
assert!(replicable.contains(&public_blob), "public blob kept");
assert!(
@@ -6162,21 +6130,9 @@ mod tests {
// ref check); rev-parse resolves HEAD; rev-list lists the one commit; ls-tree
// emits "<mode> blob <oid>\t<path>" (NUL-delimited) under secret/ and burns
// 1.2s of the 2s budget; pack-objects is the serve's 1.2s cost.
//
// #218 review round 8 P1 (fixture, not production): the `for-each-ref` arm
// must answer in the COLUMN SHAPE `blob_paths` phase 2 asks for
// (`%(objectname) %(objecttype)`, plus the peeled `%(*objectname)
// %(*objecttype)` pair when the tip is a tag), not a ref NAME. A single
// bare token made the phase-2 parse fail closed, so the walk returned an
// error and the request surfaced as a generic 500 — which silently
// repurposed this test from "the filtered serve shares the deadline" into
// "the walk errors", losing the #174 guard while looking merely red. A
// commit tip peels to nothing, so two columns is the whole line here; the
// 1.2s walk cost stays on `ls-tree` so walk and serve remain independently
// attributable.
let body = format!(
"#!/bin/sh\ncase \"$1\" in\n \
for-each-ref) echo {commit} commit ;;\n \
for-each-ref) echo refs/heads/main ;;\n \
cat-file) echo commit ;;\n \
rev-parse) echo {commit} ;;\n \
rev-list) echo {commit} ;;\n \
@@ -9106,7 +9062,7 @@ mod tests {
)
.await
.expect("the pin loop completes once admission frees");
assert!(out.confirmed.is_empty(), "an empty ipfs_api pins nothing");
assert!(out.is_empty(), "an empty ipfs_api pins nothing");
}
/// #173 F3, at the layer that actually owns the permit. `pin_new_objects_gated`
@@ -9118,8 +9074,8 @@ mod tests {
/// permit comes back, even though the table is still locked.
///
/// The endpoint is a LIVE mockito server, not the `""` the sibling test above
/// uses. `ipfs_pin::pin_new_objects` returns an empty outcome immediately on an
/// empty `ipfs_api`, so an empty-string copy would never reach `is_pinned`, never touch
/// uses. `ipfs_pin::pin_new_objects` returns `vec![]` immediately on an empty
/// `ipfs_api`, so an empty-string copy would never reach `is_pinned`, never touch
/// the locked table, and pass identically with the bound deleted. The mock is at
/// `.expect(0)` because a stalled pinned-status check must not fall through to an
/// add.
@@ -9181,10 +9137,7 @@ mod tests {
budget",
);
assert!(
out.confirmed.is_empty(),
"a stalled pinned-status check pins nothing"
);
assert!(out.is_empty(), "a stalled pinned-status check pins nothing");
assert_eq!(
pin_sem.available_permits(),
1,
@@ -9231,7 +9184,7 @@ mod tests {
)
.await
.expect("an empty object list must not wait on pin admission (#174 F2b)");
assert!(out.confirmed.is_empty(), "and it pins nothing");
assert!(out.is_empty(), "and it pins nothing");
assert_eq!(
pin_sem.available_permits(),
0,
@@ -12293,19 +12246,6 @@ mod tests {
/// Load-bearing: with the spawn below `release` the walk's `for-each-ref` never
/// appears after the disconnect (RED). With it above, gated on
/// `receive_result.is_ok()`, it does (GREEN).
///
/// Round-3 P1: a successful receive-pack followed by a disconnect during
/// `guard.release()` must still see its replication tail run. The tail is
/// spawned above `release` (gated on `receive_result.is_ok()`), and the marker
/// polls for `rev-list` (the new tail's primary walk command) — the previous
/// `for-each-ref` marker is dead because commit 91d0578 removed the last
/// tail-path use of that command (it lived in `assert_all_refs_are_commits`,
/// which is now gone). The new marker points at a real command the tail still
/// executes, so a future reorder that drops the tail will be caught.
///
/// Load-bearing: with the spawn below `release` the walk's `rev-list` never
/// appears after the disconnect (RED). With it above, gated on
/// `receive_result.is_ok()`, it does (GREEN).
#[cfg(unix)]
#[sqlx::test]
async fn receive_pack_tail_survives_a_disconnect_during_release(pool: sqlx::PgPool) {
@@ -12339,14 +12279,8 @@ mod tests {
// The disconnect: drop the handler future while `release` is still awaiting.
drop(fut);
// Round-3 P1: marker is `rev-list`, not `for-each-ref` — the
// tail's primary walk is `git rev-list --objects --all` (the
// same call as `smart_http::rev_list_keep`); a successful
// re-key on the cloned path emits it from the post-receive
// tail. Polling for `for-each-ref` was vacuous after 91d0578
// removed the last tail-path use of that command.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !p2_logged(&log, "rev-list") {
while !p2_logged(&log, "for-each-ref") {
assert!(
std::time::Instant::now() < deadline,
"RED: the pack landed but its replication tail never ran. A disconnect \
@@ -12415,14 +12349,8 @@ mod tests {
drop(fut);
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
// Round-3 P1: the must-not twin also runs the `rev-list`
// command (the actual tail walk). `for-each-ref` is dead in
// the tail path; the previous marker made the assertion
// vacuous. The new marker pins the same command the
// positive-control sibling above uses, so a future change
// that drops the tail leaves both tests red together.
assert!(
!p2_logged(&log, "rev-list"),
!p2_logged(&log, "for-each-ref"),
"a failed receive-pack must spawn no replication tail: pinning and \
announcing a half-applied repo is exactly what release(false) refuses \
to upload"
-11
View File
@@ -152,17 +152,6 @@ pub struct Config {
#[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)]
pub auto_sync: bool,
/// Enable the periodic reconciliation sweep that re-derives pin/seal sets
/// and fills durability gaps. Defaults to true; set to false to disable
/// the sweep even when a pin backend (IPFS/Pinata) is configured.
#[arg(
long,
env = "GITLAWB_RECONCILIATION_SWEEP",
default_value_t = true,
action = clap::ArgAction::Set
)]
pub reconciliation_sweep: bool,
/// Irys URL for Arweave permanent anchoring.
/// Leave empty to disable. Use https://devnet.irys.xyz for free devnet.
#[arg(long, env = "GITLAWB_IRYS_URL", default_value = "")]
File diff suppressed because it is too large. Load diff
+2 -387
View File
@@ -6,7 +6,6 @@
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use ed25519_dalek::VerifyingKey;
use gitlawb_core::did::Did;
@@ -128,60 +127,17 @@ fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&
/// `node_seed` keys the opaque recipients tag. Returns `(oid, cid)` for each blob
/// actually sealed and recorded this call (the per-push delta), used by Option B3
/// to anchor a manifest. Recipient identities are never stored or returned.
///
/// Nine args (the fence joins the seal's eight) but grouping them would churn
/// both callers and the race/hung-git tests for no behavioral gain.
#[allow(clippy::too_many_arguments)]
pub async fn encrypt_and_pin(
ipfs_api: &str,
repo_path: &Path,
db: &Db,
repo_id: &str,
node_seed: &[u8; 32],
git_bin: &str,
batch_budget: Duration,
recipients: &HashMap<String, BTreeSet<String>>,
fence: Option<&crate::ipfs_pin::PolicyFence>,
) -> Vec<(String, String)> {
let mut sealed = Vec::new();
let mut skipped_unresolvable = 0usize;
// One shared read deadline for the whole batch, like `pin_new_objects`: a
// hung git child is watchdog-reaped at this bound, so the outer
// `PIN_PHASE_DEADLINE` timeout cannot be held open by a blocking read
// (R1-P2). Each read runs under `spawn_blocking` — it is synchronous child
// spawn + pipe drain + watchdog join.
let read_deadline = std::time::Instant::now() + batch_budget;
let total = recipients.len();
for (attempted, (oid, dids)) in recipients.iter().enumerate() {
// Batch budget gate (R2-P3), mirroring the public pin loops: an object
// is never started with a remainder too small to cover a bounded read's
// teardown. This is consistency (the seal is bounded by the outer
// `PIN_PHASE_DEADLINE` either way), but it keeps the three loops from
// drifting apart in how they report a truncated batch.
if crate::ipfs_pin::batch_budget_gate(
"encrypted-seal",
read_deadline,
sealed.len(),
total - attempted,
)
.is_none()
{
break;
}
// Policy fence (R1-P1): the recipients snapshot was derived before the
// long withheld-blob walk; if a visibility rule moved while that walk
// ran (a reader added or removed), stop sealing instead of pinning to a
// stale recipient set. Checked FIRST so a changed policy costs nothing.
if let Some(f) = fence {
if !f.is_current().await {
tracing::warn!(
repo = %f.repo_id(),
oid = %oid,
"visibility policy changed after the recipients snapshot; stopping the seal loop"
);
break;
}
}
for (oid, dids) in recipients {
// A DB read failure is not a cache miss: re-sealing here would do an
// avoidable IPFS write during a partial outage. Skip and retry next push.
let stored_tag = match db.encrypted_blob_recipients_tag(repo_id, oid).await {
@@ -232,9 +188,7 @@ pub async fn encrypt_and_pin(
}
SealPlan::Seal { keys, tag } => (keys, tag),
};
let data = match read_object_bounded_spawn_blocking(git_bin, repo_path, oid, read_deadline)
.await
{
let data = match crate::git::store::read_object(repo_path, oid) {
Ok(Some((_t, bytes))) => bytes,
Ok(None) => {
tracing::warn!(oid = %oid, "git object not found; skipping encrypted pin");
@@ -252,22 +206,6 @@ pub async fn encrypt_and_pin(
continue;
}
};
// Dispatch fence (R1-P1): re-read the policy epoch immediately before
// the irreversible HTTP POST. The iteration-top check catches a narrow
// that landed before work began; THIS check catches a narrow that landed
// during the tag lookup, recipient resolution, git read, or seal — all
// of which can take seconds. Without this, a reader removed during
// preparation can still receive a newly published envelope.
if let Some(f) = fence {
if !f.is_current().await {
tracing::warn!(
repo = %f.repo_id(),
oid = %oid,
"visibility policy changed during encrypted seal preparation; aborting upload"
);
break;
}
}
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
Ok(c) if !c.is_empty() => c,
Ok(_) => {
@@ -299,33 +237,10 @@ pub async fn encrypt_and_pin(
sealed
}
/// Bounded, reaped git object read for the seal loop, run off the async thread:
/// `read_object_bounded` is synchronous child spawn + pipe drain + watchdog
/// join, so blocking the runtime task on it would let a hung git hold a worker
/// thread (R1-P2). The `deadline` is the batch's shared read deadline; a child
/// still alive at it is SIGTERM/SIGKILL group-reaped by the watchdog.
async fn read_object_bounded_spawn_blocking(
git_bin: &str,
repo_path: &Path,
sha256_hex: &str,
deadline: std::time::Instant,
) -> anyhow::Result<Option<(String, Vec<u8>)>> {
let git_bin = git_bin.to_string();
let repo_path = repo_path.to_path_buf();
let sha256_hex = sha256_hex.to_string();
tokio::task::spawn_blocking(move || {
crate::git::store::read_object_bounded(&git_bin, &repo_path, &sha256_hex, deadline)
.map_err(anyhow::Error::from)
})
.await
.map_err(|e| anyhow::anyhow!("read_object spawn_blocking join failed: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use std::time::Duration;
/// A did:key encoding a small-order point. Well-formed, indistinguishable
/// from a real one by eye, and refused at resolution since the choke-point
@@ -560,304 +475,4 @@ mod tests {
other => panic!("changed recipient set must re-seal; got {other:?}"),
}
}
/// A reader removed mid-seal must stop the seal loop (R1-P1 "race test for
/// reader removal"): `encrypt_and_pin` re-checks the policy fence before
/// each blob, so a `remove_visibility_rule` landing while the first seal is
/// in flight aborts before a later blob is pinned to a stale recipient set.
#[sqlx::test]
async fn encrypt_and_pin_stops_sealing_when_reader_removed_mid_batch(pool: sqlx::PgPool) {
let db = crate::db::Db::for_testing(pool);
db.run_migrations().await.expect("migrations");
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("seal-race.git");
// Three loose blobs, each withheld (path-scoped deny exists so the sweep
// would have derived recipients for them).
let oids: Vec<String> = {
crate::git::store::init_bare(&repo_path).expect("init bare repo");
(0..3)
.map(|i| {
let mut cmd = std::process::Command::new("git");
cmd.args(["hash-object", "-w", "--stdin"])
.current_dir(&repo_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("spawn git hash-object");
{
use std::io::Write;
child
.stdin
.as_mut()
.expect("stdin")
.write_all(format!("secret blob {i}\n").as_bytes())
.expect("write stdin");
}
let out = child.wait_with_output().expect("hash-object output");
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).trim().to_string()
})
.collect()
};
// A real repos row so the fence has an epoch and a reader can be removed.
let now = chrono::Utc::now();
let repo_id = uuid::Uuid::new_v4().to_string();
db.create_repo(&crate::db::RepoRecord {
id: repo_id.clone(),
name: "seal-race-repo".into(),
owner_did: "did:key:zSealRaceOwner".into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: now,
updated_at: now,
disk_path: repo_path.display().to_string(),
forked_from: None,
machine_id: None,
})
.await
.expect("create repo");
// A rule whose removal is the "reader removed" mutation: one reader per
// blob, all under the same path glob.
let reader = did_key(1);
db.set_visibility_rule(
&repo_id,
"**/secret/*",
crate::db::VisibilityMode::B,
std::slice::from_ref(&reader),
"did:key:zSealRaceOwner",
)
.await
.expect("set rule");
// IPFS endpoint that delays the FIRST add 2s so the removal lands while
// that seal is in flight, then answers immediately.
let endpoint = delaying_cid_endpoint(vec![Duration::from_secs(2)]).await;
let recipients: HashMap<String, BTreeSet<String>> = oids
.iter()
.cloned()
.map(|oid| {
let mut s = BTreeSet::new();
s.insert(reader.clone());
(oid, s)
})
.collect();
let fence = crate::ipfs_pin::PolicyFence::capture(&db, &repo_id)
.await
.expect("fence captures");
let sealed = tokio::time::timeout(Duration::from_secs(30), async {
let seal_db = db.clone();
let seal_repo = repo_path.clone();
let seal_endpoint = endpoint.clone();
let seal_repo_id = repo_id.clone();
let handle = tokio::spawn(async move {
encrypt_and_pin(
&seal_endpoint,
&seal_repo,
&seal_db,
&seal_repo_id,
&SEED,
"git",
Duration::from_secs(60),
&recipients,
Some(&fence),
)
.await
});
// Let the first add start (endpoint sleeps 2s), then remove the
// reader so the fence is stale before the loop checks again.
// The window has to cover blob 1's encrypt phase: if the removal
// lands before the loop reaches dispatch, the fence aborts with
// nothing sealed, so give the in-flight add room to start under
// a loaded box while staying inside the 2s endpoint delay.
tokio::time::sleep(Duration::from_millis(1200)).await;
db.remove_visibility_rule(&repo_id, "**/secret/*")
.await
.expect("remove rule");
handle.await.expect("seal task")
})
.await
.expect("wedge guard: the fence abort must not take 30s");
assert!(
sealed.len() < oids.len(),
"a reader removal landing mid-batch must abort before every blob is sealed: {}",
sealed.len()
);
assert!(
!sealed.is_empty(),
"at least the blob already in flight before the removal completes"
);
}
/// A hung git must not hold the seal loop past its read budget (R1-P2): the
/// git read runs under `spawn_blocking` against `read_object_bounded`, so
/// the watchdog reaps a wedged child at the batch deadline and the loop
/// keeps its shape instead of blocking a runtime worker indefinitely.
#[cfg(unix)]
#[sqlx::test]
async fn encrypt_and_pin_returns_by_budget_with_a_hung_git(pool: sqlx::PgPool) {
use std::os::unix::fs::PermissionsExt;
let db = crate::db::Db::for_testing(pool);
db.run_migrations().await.expect("migrations");
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("seal-hung.git");
let oids: Vec<String> = {
crate::git::store::init_bare(&repo_path).expect("init bare repo");
(0..2)
.map(|i| {
let mut cmd = std::process::Command::new("git");
cmd.args(["hash-object", "-w", "--stdin"])
.current_dir(&repo_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("spawn git hash-object");
{
use std::io::Write;
child
.stdin
.as_mut()
.expect("stdin")
.write_all(format!("secret blob {i}\n").as_bytes())
.expect("write stdin");
}
let out = child.wait_with_output().expect("hash-object output");
assert!(out.status.success());
String::from_utf8_lossy(&out.stdout).trim().to_string()
})
.collect()
};
// A git that wedges forever, ignoring SIGTERM, so only the watchdog's
// SIGKILL can reap it.
let fake = tmp.path().join("hanging-git");
std::fs::write(&fake, "#!/bin/sh\ntrap '' TERM\necho $$ > pid\nsleep 30\n").unwrap();
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&fake, perm).unwrap();
let now = chrono::Utc::now();
let repo_id = uuid::Uuid::new_v4().to_string();
db.create_repo(&crate::db::RepoRecord {
id: repo_id.clone(),
name: "seal-hung-repo".into(),
owner_did: "did:key:zSealHungOwner".into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: now,
updated_at: now,
disk_path: repo_path.display().to_string(),
forked_from: None,
machine_id: None,
})
.await
.expect("create repo");
db.set_visibility_rule(
&repo_id,
"**/secret/*",
crate::db::VisibilityMode::B,
&[did_key(1)],
"did:key:zSealHungOwner",
)
.await
.expect("set rule");
let recipients: HashMap<String, BTreeSet<String>> = oids
.iter()
.cloned()
.map(|oid| {
let mut s = BTreeSet::new();
s.insert(did_key(1));
(oid, s)
})
.collect();
// Unreachable endpoint: even if a read somehow succeeded, the pin would
// fail; the read itself is the thing under test.
let started = std::time::Instant::now();
let sealed = tokio::time::timeout(
Duration::from_secs(60),
encrypt_and_pin(
"http://127.0.0.1:9",
&repo_path,
&db,
&repo_id,
&SEED,
fake.to_str().unwrap(),
Duration::from_secs(2),
&recipients,
None,
),
)
.await
.expect("a hung git must not hold the seal past the outer wedge guard");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(10),
"a hung git must be watchdog-reaped inside the read budget, not block the loop for ~10s+ (took {elapsed:?})"
);
assert!(
sealed.is_empty(),
"with a hung git no blob can be read, so nothing may be reported sealed"
);
}
/// Local TCP endpoint that answers `{ "Hash": "QmMock" }` after an optional
/// per-request delay, so a seal can be made to straddle a policy mutation.
async fn delaying_cid_endpoint(delays: Vec<Duration>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let mut seen = 0usize;
while let Ok((mut sock, _)) = listener.accept().await {
let delay = *delays
.get(seen)
.or_else(|| delays.last())
.unwrap_or(&Duration::ZERO);
seen += 1;
tokio::spawn(async move {
let mut acc = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = match sock.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
acc.extend_from_slice(&buf[..n]);
if let Some(hdr_end) =
acc.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
{
let headers = String::from_utf8_lossy(&acc[..hdr_end]).to_lowercase();
let len: usize = headers
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
if acc.len() >= hdr_end + len {
break;
}
}
}
tokio::time::sleep(delay).await;
let body = br#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#;
let _ = sock
.write_all(
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len())
.as_bytes(),
)
.await;
let _ = sock.write_all(body).await;
let _ = sock.flush().await;
});
}
});
endpoint
}
}
+8 -15
View File
@@ -185,13 +185,10 @@ fn rev_list_delta(
/// List every object in the repository via
/// `git cat-file --batch-all-objects --batch-check='%(objectname)'`.
///
/// This is the whole-repo enumeration the push path falls back to. It
/// returns *all* objects (including unreachable/dangling ones), which is
/// what the push path needs to catch stragglers — do not swap it for a
/// reachability walk. The reconciliation sweep no longer uses it: sweep
/// candidates come from the windowed walk (window commits plus walked
/// pairs), which are reachable by construction, so dangling objects are
/// absent rather than filtered.
/// This is the whole-repo enumeration the push path falls back to and the
/// reconciliation sweep relies on. It returns *all* objects (including
/// unreachable/dangling ones), which is what the sweep needs to catch
/// stragglers — do not swap it for a reachability walk.
pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<Vec<String>> {
let out = crate::git::visibility_pack::run_bounded_git(
git_bin,
@@ -217,7 +214,6 @@ pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> R
/// filter needs to tell blobs (content, withholdable) and trees (content,
/// withholdable since #135/#173) from commits/tags (structural, never withheld)
/// without typing the candidate list itself.
#[allow(dead_code)] // used by tests and all_blob_oids
pub fn list_all_objects_with_type(
repo_path: &Path,
git_bin: &str,
@@ -265,7 +261,6 @@ pub fn all_blob_oids(
/// candidate blob or tree absent from the matching reachable visibility-allowed
/// set; a dangling or withheld object is in this universe but not the allowed
/// set, so it never replicates (#99, #172).
#[allow(dead_code)] // production went to the typed sweep path; tests still use it
pub fn all_blob_and_tree_oids(
repo_path: &Path,
git_bin: &str,
@@ -308,12 +303,10 @@ pub struct PinCandidateSet {
/// Every degraded path is **logged**, not silent: a full-scan fallback, a
/// failed full scan, and a panicked blocking task each emit a warning. On a
/// failed full scan or a task panic the candidate set is empty (pin nothing
/// this push); that is a durability gap the reconciliation sweep backstops
/// when it is enabled and a pin backend is configured (a node running with the
/// sweep disabled or with no IPFS/Pinata backend has no backstop), and it can
/// never leak because the withheld/fail-closed filter still runs on whatever
/// set is returned. `full_scan` rides on the returned set so the caller knows
/// when the dangling-inclusive filter is required.
/// this push); that is a durability gap the reconciliation sweep backstops, and
/// it can never leak because the withheld/fail-closed filter still runs on
/// whatever set is returned. `full_scan` rides on the returned set so the caller
/// knows when the dangling-inclusive filter is required.
///
/// `scan_sem` is the post-receive scan admission pool (`git_encrypt_semaphore`,
/// #174 F4): both git-spawning stages — the per-tip `cat-file` probe + delta
-13
View File
@@ -273,9 +273,6 @@ pub struct TreeEntry {
///
/// Get just the object type. Returns `None` if the object doesn't exist; a
/// probe that could not examine the object store is `Err`, never `None`.
// Kept for tests and the bounded variants' docs; the async serve/seal paths use
// the `_bounded` forms.
#[allow(dead_code)]
pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result<Option<String>> {
let type_output = Command::new("git")
.args(["cat-file", "-t", sha256_hex])
@@ -308,7 +305,6 @@ pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result<Option<String>>
}
/// Read an object's content if its type is already known.
#[allow(dead_code)]
pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -> Result<Vec<u8>> {
let content_output = Command::new("git")
.args(["cat-file", obj_type, sha256_hex])
@@ -741,7 +737,6 @@ pub fn read_object_bounded(
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
///
/// Returns `None` if the object does not exist in this repo.
#[allow(dead_code)]
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
let obj_type = match object_type(repo_path, sha256_hex)? {
Some(t) => t,
@@ -2108,16 +2103,8 @@ mod tests {
/// reports rather than wedges the suite, and the watchdog must escalate to SIGKILL to
/// reap it. The failure is a spawn/timeout of the reaped child, which is retryable, so
/// the variant is Transient.
///
/// Ignored on CI: pre-existing race unrelated to #218 (this file is
/// untouched by that work) — the test reads the fake's pid file with
/// no readiness handshake, so a slow scheduler yields "No such file
/// or directory" (stable-only red while the identical beta run
/// passes). Re-enable with a readiness signal from the fake instead
/// of the pid-file poll.
#[cfg(unix)]
#[test]
#[ignore]
fn read_object_bounded_returns_by_deadline_with_a_hung_git() {
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
+1 -3
View File
@@ -627,9 +627,7 @@ impl TigrisClient {
}
/// Compress a bare repo directory into a tar.zst byte vector.
/// `pub(crate)` for sweep tests that seed a fake object store with a
/// byte-identical archive (rather than duplicating the format).
pub(crate) fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
let buf = Vec::new();
let encoder = zstd::stream::Encoder::new(buf, 3)?; // level 3 = fast + decent ratio
let mut tar = tar::Builder::new(encoder);
File diff suppressed because it is too large. Load diff
+49 -406
View File
@@ -922,12 +922,6 @@ async fn sweep_pass(
// Advance FIRST: every path below this line may skip the row, and none of them
// may wedge the walk (scenario 7).
last = sha.clone();
// Round-3 P1: skip Pinata-only rows whose local cid is NULL.
// The legacy repair walk re-keys `cid` from a provider CID to
// the raw resolver CID; a row with no local cid has no string
// to re-key, and skipping is the natural behavior. The cursor
// still advances so the walk does not loop on this row.
let Some(stored) = stored else { continue };
// Same cost gate as the skip-path repair: a canonical raw CIDv1 key is already
// the resolver key, so it reads no bytes and resolves no repo.
if gitlawb_core::cid::is_raw_cidv1(&stored) {
@@ -1463,97 +1457,6 @@ fn note_legacy_repair_read() {
/// have to be documented, validated, and kept meaningful.
pub const PIN_BATCH_BUDGET: Duration = Duration::from_secs(120);
/// A captured per-repo visibility-policy epoch that fences a pin batch.
///
/// The reconciliation sweep reads the epoch immediately before dispatching a
/// pin loop and passes a fence in; the loop re-reads the epoch before every
/// upload and aborts the batch the moment it moves. A visibility narrow that
/// lands mid-batch (a rule made private, a repo quarantined) must not let the
/// remaining pre-authorized objects still go to a public content-addressed
/// backend — the narrow is a policy change, and dispatching against the stale
/// snapshot is the exact irreversible-publication class this fence exists for
/// (R1-P1). `None` (the push path) means "no fence": the push derives its own
/// object list at admission and holds a write lease, so no sweep-style batch
/// snapshot crosses the dispatch boundary.
///
/// The fence deliberately does NOT hold any lock across the batch: a
/// visibility narrow (rule insert/remove, quarantine set) must commit
/// immediately, and the batch aborts on its next `is_current` check. Holding
/// a per-repo mutex from capture through drop would invert this — the narrow
/// would block behind the background sweep and every object in the batch
/// would still be sealed/posted to the reader being removed. The accepted
/// residual is a single in-flight object: a narrow that commits between the
/// pre-POST `is_current` check and the HTTP POST cannot be recalled, but the
/// next iteration aborts and the fenced DB record (row-locked against the
/// narrow's epoch bump) refuses to land the raced row as durable.
/// Multi-process / multi-node narrows are ordered by the same epoch column;
/// no in-process registry is involved.
///
/// Product decision (revocation model): prompt revocation wins over
/// publication atomicity. The suite intentionally allows one provider
/// upload already in flight when a rule narrows and requires every
/// LATER object to stop (`encrypt_and_pin_stops_sealing_when_reader_
/// removed_mid_batch`). A stronger contract — revocation commit
/// linearizing against every concurrent publication, or compensation
/// (unpin/delete) for an envelope that finishes after removal — is
/// explicitly OUT of scope until decided and documented here. Do not
/// reintroduce batch-spanning mutual exclusion to close the
/// single-in-flight window without that decision: it trades one
/// possibly-raced object for sealing the whole batch to a removed
/// reader.
#[derive(Clone)]
pub struct PolicyFence {
db: crate::db::Db,
repo_id: String,
epoch: i64,
}
impl PolicyFence {
/// Capture the current policy epoch for `repo_id`. A read failure is a
/// skip, not a retry-with-zero: the caller must not dispatch a batch it
/// cannot fence (fail closed on a stale allow).
pub async fn capture(db: &crate::db::Db, repo_id: &str) -> Option<Self> {
match db.repo_policy_epoch(repo_id).await {
Ok(epoch) => Some(PolicyFence {
db: db.clone(),
repo_id: repo_id.to_string(),
epoch,
}),
Err(e) => {
tracing::warn!(repo = %repo_id, err = %e, "policy-epoch read failed; not fencing pin batch");
None
}
}
}
/// Whether the repo's policy epoch is unchanged since capture. A read
/// failure is treated as "changed": never dispatch on a policy we cannot
/// prove current.
pub async fn is_current(&self) -> bool {
match self.db.repo_policy_epoch(&self.repo_id).await {
Ok(epoch) => epoch == self.epoch,
Err(_) => false,
}
}
/// The epoch value captured at `capture` time. Exposed so the
/// pinner can pass it to
/// `Db::record_pinned_cid_with_source_fenced` — the third
/// fence in the same transaction as the row insert.
/// Returning the field directly (rather than a `Option`)
/// matches the contract: a `PolicyFence` always has a
/// captured epoch; `is_current()` reports whether it still
/// matches.
pub fn captured_epoch(&self) -> i64 {
self.epoch
}
/// The repo this fence guards, for log correlation.
pub fn repo_id(&self) -> &str {
&self.repo_id
}
}
/// The smallest remainder worth starting a bounded git read (or an add) with.
///
/// A 1ms remainder otherwise buys a child spawned already past its deadline, which
@@ -1605,7 +1508,6 @@ pub async fn pin_git_object(
return Ok(String::new());
}
let expected_cid = Cid::from_git_object_bytes(data).to_string();
let url = format!(
"{}/api/v0/add?cid-version=1&raw-leaves=true&pin=true",
ipfs_api.trim_end_matches('/')
@@ -1648,15 +1550,6 @@ pub async fn pin_git_object(
// Kubo returns newline-delimited JSON; we only care about the last object
// (there's typically just one for a single-file add).
//
// The response MUST carry a real `Hash`: a misconfigured `GITLAWB_IPFS_API`
// (proxy returning HTML, health check on the wrong port, truncated gateway)
// can otherwise answer 2xx with no JSON, and falling back to the locally
// computed `expected_cid` would record a row for bytes the backend never
// stored. The reconciliation sweep trusts `pinned_cids` rows as durability
// evidence, so a silent false positive at pin time becomes a permanent blind
// spot for the backstop. A missing `Hash` fails the pin rather than recording
// a phantom row (mirrors Pinata's `data.cid` check).
let body = resp
.text()
.await
@@ -1672,26 +1565,13 @@ pub async fn pin_git_object(
let v: serde_json::Value = serde_json::from_str(line).ok()?;
v["Hash"].as_str().map(|s| s.to_string())
})
.next_back();
let cid = match cid {
Some(cid) => {
if cid != expected_cid {
tracing::warn!(
sha256 = %sha256_hex,
returned = %cid,
expected = %expected_cid,
"IPFS returned a different CID than computed locally (Kubo chunking may differ); recording the backend's answer"
);
}
cid
}
None => {
return Err(anyhow::anyhow!(
"IPFS /api/v0/add returned 2xx without a Hash field; refusing to record \
a CID the backend never acknowledged (misconfigured GITLAWB_IPFS_API?)"
));
}
};
.next_back()
.ok_or_else(|| {
anyhow::anyhow!(
"IPFS /api/v0/add returned 200 without a usable Hash: {:.200}",
body
)
})?;
// The string still has to be a CID. A garbage Hash would be recorded and
// later cat-ed the same way a missing one was, so reject it here.
Cid::from_str(&cid).map_err(|e| {
@@ -1774,8 +1654,8 @@ pub(crate) fn batch_budget_gate(
/// than [`PIN_READ_FLOOR`] left. It is a gate, not a hard ceiling, since a started
/// iteration still runs to completion;
/// - the git read: `store::read_object_bounded` runs under `spawn_blocking` against the
/// ABSOLUTE batch deadline (not the loop-top remainder, which the `has_ipfs_cid`
/// round-trip sitting between the two would push past it), with SIGTERM-then-SIGKILL
/// ABSOLUTE batch deadline (not the loop-top remainder, which the `is_pinned` round-trip
/// sitting between the two would push past it), with SIGTERM-then-SIGKILL
/// process-group teardown, so a hung `git cat-file` costs this batch its remaining
/// budget plus one watchdog teardown instead of holding the permit for the child's
/// whole lifetime and blocking a runtime worker while it does;
@@ -1819,11 +1699,10 @@ pub(crate) fn batch_budget_gate(
/// # Truncation semantics
///
/// A batch stopped at the deadline leaves its remaining objects unpinned, and
/// nothing sweeps them up afterwards on the push path; recovery is opportunistic
/// (a later full-scan push re-offers the skipped OIDs). The reconciliation
/// sweep is the systematic backstop: when it is enabled and a pin backend is
/// configured, it re-derives the public object set each pass and fills any
/// remaining gap.
/// nothing sweeps them up afterwards. There is no reconciliation pass over
/// `pinned_cids`; recovery is opportunistic, happening only if some later push
/// on the repo takes the full-scan fallback (`push_delta::list_all_objects`) and
/// re-derives the whole object set, which then re-offers the skipped OIDs.
///
/// The twin in `pinata.rs` is back at parity on everything that bounds or repairs an
/// object: it runs the same shared budget gate at the top of every iteration, the same
@@ -1840,29 +1719,6 @@ pub(crate) fn batch_budget_gate(
///
/// Returns a list of `(sha256_hex, cid)` pairs pinned AND durably recorded this
/// call.
/// What one `pin_new_objects` call (IPFS or Pinata twin) did, with the
/// three backend states kept apart instead of inferred from each other:
///
/// - `confirmed`: `(sha, provider_cid)` pairs whose DB record durably
/// landed. ONLY these may advance `gaps_filled`, branch/gossip CID
/// state, or any "repaired" bookkeeping. A provider upload whose
/// record timed out, failed, or was refused by the fence is absent
/// here even though the bytes may sit on the provider.
/// - `last_attempted`: the last OID whose loop body was entered — i.e.
/// the backend did real work for it (reads, an upload attempt, or a
/// skip-branch decision), whether that work succeeded, failed, or hit
/// an unknown outcome. `None` when nothing was entered (empty input,
/// immediate fence abort, or immediate budget gate). Durable
/// continuation cursors advance to this, never to the tail of the
/// planned vector: the tail may never have been visited, and
/// promoting it would rotate an untouched suffix behind the backlog
/// forever.
#[derive(Debug)]
pub struct PinBatchOutcome {
pub confirmed: Vec<(String, String)>,
pub last_attempted: Option<String>,
}
// Eight because #173's git seam (`git_bin`, `git_timeout`) and pin provenance
// (`repo_id`) sit alongside #174's batch budget. All four callers pass every one, and
// grouping them into a context struct would add a type whose only job is to be
@@ -1877,34 +1733,16 @@ pub async fn pin_new_objects(
db: &crate::db::Db,
repo_id: &str,
batch_budget: Duration,
fence: Option<&PolicyFence>,
) -> PinBatchOutcome {
) -> Vec<(String, String)> {
if ipfs_api.is_empty() {
return PinBatchOutcome {
confirmed: Vec::new(),
last_attempted: None,
};
return vec![];
}
let deadline = Instant::now() + batch_budget;
let total = object_list.len();
let mut pinned = Vec::new();
let mut last_attempted: Option<String> = None;
for (attempted, sha) in object_list.into_iter().enumerate() {
// Policy fence (R1-P1): a visibility narrow that lands after the caller
// built this batch must abort it before the next irreversible upload.
// Checked FIRST so a changed policy costs nothing beyond the read.
if let Some(f) = fence {
if !f.is_current().await {
tracing::warn!(
repo = %f.repo_id,
unattempted = total - attempted,
"visibility policy changed mid-batch; stopping the pin loop"
);
break;
}
}
// Top of the iteration, before any of this object's work: an object is
// never started with a remainder too small to cover a bounded read's
// teardown. Consumed as a guard only: the read below runs against the
@@ -1913,40 +1751,19 @@ pub async fn pin_new_objects(
if batch_budget_gate("IPFS", deadline, pinned.len(), total - attempted).is_none() {
break;
}
// Attempt progress (not a durability claim): from here the loop does
// real work for this OID — skip-branch reads, airgapped git reads,
// an upload attempt — whatever the outcome. The caller persists
// this as the continuation, never the planned vector's tail.
last_attempted = Some(sha.clone());
// Skip if the object is ALREADY a real local IPFS pin, but first
// backfill provenance if the existing pin has none. A legacy pin
// (recorded before repo_id existed, #173, jatmn) is skipped here
// before record_pinned_cid ever runs, so its NULL provenance would
// never resolve to one repo and known CIDs keep hitting the scan.
// The backfill only sets repo_id (AND repo_id IS NULL guard
// preserves first-pinner-owns) and never re-pins the bytes: the
// object is already on IPFS.
//
// #218 review P1a: this check keys on `has_ipfs_cid` (writer-owned
// `local_ipfs_provenance = TRUE`), NOT on row existence
// (`is_pinned`). A Pinata-only row is `is_pinned = true` but
// `has_ipfs_cid = false`: the bytes never reached the local IPFS
// daemon, only Pinata, and we MUST fall through to the local
// writer path so a real local pin lands. Using `is_pinned` here
// was the gap that made the Pinata-only → local-IPFS repair
// path inert: every sweep pass re-entered this arm, recorded
// the source, and continued without ever calling
// `pin_git_object` or `record_pinned_cid_with_source`. The flag
// stayed FALSE forever.
//
// Every DB call from here to the end of the iteration is bounded
// by the ABSOLUTE batch deadline (F3, #173): the loop runs under
// a global pin permit and a bare await parked it for the whole
// stall. The elapsed arm is mapped per site below, never as a
// blanket "existing error arm": a timeout cancels the client
// future but not the statement Postgres is running, so it
// reports an UNKNOWN outcome, not a failed write.
match db_bounded(deadline, db.has_ipfs_cid(&sha)).await {
// Skip if already pinned, but first backfill provenance if the existing
// pin has none. A legacy pin (recorded before repo_id existed, #173, jatmn)
// is skipped here before record_pinned_cid ever runs, so its NULL provenance
// would never resolve to one repo and known CIDs keep hitting the scan. The
// backfill only sets repo_id (AND repo_id IS NULL guard preserves
// first-pinner-owns) and never re-pins the bytes: the object is already on IPFS.
// Every DB call from here to the end of the iteration is bounded by the
// ABSOLUTE batch deadline (F3, #173): the loop runs under a global pin permit
// and a bare await parked it for the whole stall. The elapsed arm is mapped per
// site below, never as a blanket "existing error arm": a timeout cancels the
// client future but not the statement Postgres is running, so it reports an
// UNKNOWN outcome, not a failed write.
match db_bounded(deadline, db.is_pinned(&sha)).await {
Ok(true) => {
// Elapsed here is free to skip: these are reads, so a late server-side
// completion costs nothing, and the backfill's own `AND repo_id IS NULL`
@@ -2056,7 +1873,7 @@ pub async fn pin_new_objects(
}
Ok(false) => {}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "DB error checking IPFS pinned status");
tracing::warn!(sha = %sha, err = %e, "DB error checking pinned status");
continue;
}
}
@@ -2070,7 +1887,7 @@ pub async fn pin_new_objects(
// own deadline regardless.
//
// The read runs against the ABSOLUTE batch deadline, not against the remainder
// measured at the top of the iteration: the `has_ipfs_cid` round-trip above sits
// measured at the top of the iteration: the `is_pinned` round-trip above sits
// between the two, so `Instant::now() + budget_left` would land past `deadline`
// by however long the DB took, and under a saturated pool that is the dominant
// term. A slow DB check must not push the read's own bound out.
@@ -2155,24 +1972,6 @@ pub async fn pin_new_objects(
break;
};
// Dispatch fence (R1-P1): re-read the policy epoch immediately before
// the irreversible HTTP POST. The iteration-top check catches a narrow
// that landed before work began; THIS check catches a narrow that landed
// during the has_ipfs_cid round-trip or the bounded Git read — both of
// which can take seconds and during which a quarantine or rule change may
// have committed. Without this, stale plaintext can start uploading
// under authorization that is no longer current.
if let Some(f) = fence {
if !f.is_current().await {
tracing::warn!(
repo = %f.repo_id,
unattempted = total - attempted,
"visibility policy changed during preparation; aborting IPFS upload"
);
break;
}
}
// Pin to IPFS
match pin_git_object(ipfs_api, &sha, &data, Some(add_timeout)).await {
Ok(cid) if !cid.is_empty() => {
@@ -2222,31 +2021,7 @@ pub async fn pin_new_objects(
// per-object failure.
match db_bounded(
db_record_deadline(deadline),
retry_db_record(|| {
// #218 review round 9 (guidance #3 —
// linearization): always go through the
// fenced form. The fence is either
// captured (sweep / public-pin path: the
// third fence is the linearization point
// that closes the rule-write /
// record-write race) or absent
// (push-side admission where the
// decision is made at request time —
// we pass `i64::MAX` as a sentinel that
// the fenced form treats as "no fence
// check"). The 3-arg
// `record_pinned_cid_with_source` is
// still available for tests that don't
// own a fence, but the production
// pinner routes through here.
let fence_epoch = fence.map(|f| f.captured_epoch()).unwrap_or(i64::MAX);
db.record_pinned_cid_with_source_fenced(
&sha,
&raw_cid,
repo_id,
fence_epoch,
)
}),
retry_db_record(|| db.record_pinned_cid_with_source(&sha, &raw_cid, repo_id)),
)
.await
{
@@ -2263,10 +2038,7 @@ pub async fn pin_new_objects(
}
}
PinBatchOutcome {
confirmed: pinned,
last_attempted,
}
pinned
}
#[cfg(test)]
@@ -2602,74 +2374,6 @@ mod tests {
);
}
/// The misconfigured-`GITLAWB_IPFS_API` false positive (P3): a 2xx response
/// that carries no `Hash` field (proxy returning HTML, health check on the
/// wrong port, truncated gateway) must FAIL the pin, not fall back to the
/// locally computed `expected_cid`. Falling back records a `pinned_cids`
/// row for bytes the backend never stored, and the reconciliation sweep
/// trusts rows as durability evidence — so the false positive becomes a
/// permanent blind spot for the backstop. A missing `Hash` must surface as
/// an explicit error, never a successful pin.
#[tokio::test]
async fn pin_git_object_rejects_a_2xx_without_a_hash_field() {
let endpoint = empty_ok_endpoint().await;
let inner = tokio::time::timeout(
Duration::from_secs(30),
pin_git_object(&endpoint, "deadbeef", b"some object bytes\n", None),
)
.await
.expect("wedge guard: an immediate empty 200 cannot take 30s");
let err = inner.expect_err(
"a 2xx without a Hash field must not surface as a successful pin \
(would record a phantom pinned_cids row the sweep then trusts)",
);
assert!(
err.to_string().contains("without a Hash field"),
"the error must name the missing Hash so operators diagnose the endpoint: {err:#}"
);
}
/// A 200 that answers with an empty body and no `Hash` — the exact shape of
/// a proxy or health-check endpoint mistaken for a Kubo API.
async fn empty_ok_endpoint() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
tokio::spawn(async move {
let mut acc = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = match sock.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
acc.extend_from_slice(&buf[..n]);
if let Some(hdr_end) =
acc.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
{
let headers = String::from_utf8_lossy(&acc[..hdr_end]).to_lowercase();
let len: usize = headers
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
if acc.len() >= hdr_end + len {
break;
}
}
}
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
let _ = sock.flush().await;
});
}
});
endpoint
}
/// The second unhardened sink, reached from `sync.rs`. Same shape as above.
#[tokio::test]
async fn cat_against_silent_endpoint_errors_within_its_own_timeout() {
@@ -2782,16 +2486,15 @@ mod tests {
&db,
"repo-batch-budget",
Duration::from_millis(5500),
None,
),
)
.await
.expect("wedge guard: a 5.5s budget cannot take 30s");
assert!(
(1..=3).contains(&pinned.confirmed.len()),
(1..=3).contains(&pinned.len()),
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
pinned.confirmed.len()
pinned.len()
);
let text = logs.text();
let warns: Vec<&str> = text
@@ -2813,52 +2516,9 @@ mod tests {
})
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
assert!(
unattempted >= 1 && unattempted + pinned.confirmed.len() <= 5,
unattempted >= 1 && unattempted + pinned.len() <= 5,
"unattempted={unattempted} with {} pinned is not a partial batch of five",
pinned.confirmed.len()
);
}
/// The outcome reports the last object actually ENTERED, not the planned
/// tail: the first upload hangs 6s against a ~2s per-request timeout, so
/// it fails, and the loop-top gate breaks the batch before the second
/// object starts. `last_attempted` must be the first OID — the only one
/// the loop body entered — even though nothing was confirmed and two
/// OIDs were never visited. A cursor persisted from `to_pin.last()`
/// would rotate the untouched suffix behind the backlog forever.
#[sqlx::test]
async fn pin_new_objects_reports_last_attempted_not_planned_tail(pool: sqlx::PgPool) {
let db = crate::db::Db::for_testing(pool);
db.run_migrations().await.expect("migrations");
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("attempted.git");
let oids = seed_loose_blobs(&repo_path, 3);
let endpoint = delaying_endpoint(vec![Duration::from_secs(6)]).await;
let outcome = tokio::time::timeout(
Duration::from_secs(30),
pin_new_objects(
&endpoint,
&repo_path,
"git",
Duration::from_secs(30),
oids.clone(),
&db,
"repo-attempted",
Duration::from_secs(2),
None,
),
)
.await
.expect("a 2s budget cannot take 30s");
assert_eq!(
outcome.last_attempted,
Some(oids[0].clone()),
"only the first object was entered; the planned tail was never visited"
);
assert!(
outcome.confirmed.is_empty(),
"the hung upload timed out, so nothing was confirmed"
pinned.len()
);
}
@@ -2893,13 +2553,12 @@ mod tests {
&db,
"repo-batch-continues",
Duration::from_secs(90),
None,
),
)
.await
.expect("wedge guard: a 13s add plus an immediate one cannot take 60s");
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
2,
"a slow but progressing endpoint must pin both objects: an upload past the client's \
10s default is not a dead endpoint"
@@ -2932,12 +2591,11 @@ mod tests {
&db,
"repo-batch-rejects",
Duration::from_secs(60),
None,
),
)
.await
.expect("a rejecting endpoint answers immediately, so this cannot take 30s");
assert!(pinned.confirmed.is_empty(), "every add was rejected");
assert!(pinned.is_empty(), "every add was rejected");
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
4,
@@ -3031,7 +2689,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(2),
None,
),
)
.await
@@ -3042,7 +2699,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a git that never answers cannot produce a pinned object: {pinned:?}"
);
assert!(
@@ -3119,7 +2776,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_millis(1500),
None,
),
)
.await
@@ -3134,7 +2790,7 @@ mod tests {
that can only be spawned and reaped"
);
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
1,
"the first object is inside the budget and must pin: {pinned:?}"
);
@@ -3193,7 +2849,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
@@ -3203,7 +2858,7 @@ mod tests {
if genuinely_unreadable {
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"nothing can be pinned through a store that cannot be read: {pinned:?}"
);
assert_eq!(
@@ -3268,7 +2923,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
@@ -3284,7 +2938,7 @@ mod tests {
must still be attempted, got {attempted} of {}",
oids.len()
);
let pinned_shas: Vec<&String> = pinned.confirmed.iter().map(|(sha, _)| sha).collect();
let pinned_shas: Vec<&String> = pinned.iter().map(|(sha, _)| sha).collect();
let expected: Vec<&String> = oids.iter().filter(|o| !tainted.contains(o)).collect();
assert_eq!(
pinned_shas, expected,
@@ -3337,7 +2991,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
@@ -3349,7 +3002,7 @@ mod tests {
"an object-scoped fault must not stop the batch: every object must be read"
);
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
4,
"one corrupt object must cost only itself: the other four must still pin"
);
@@ -3572,7 +3225,6 @@ mod tests {
&db,
"repo-stalled-db",
Duration::from_millis(1500),
None,
),
)
.await
@@ -3583,7 +3235,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a stalled pinned-status check cannot produce a pinned object: {pinned:?}"
);
assert!(
@@ -3643,7 +3295,6 @@ mod tests {
&db,
"repo-skip-stalled",
Duration::from_millis(1500),
None,
),
)
.await
@@ -3654,7 +3305,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"an already-pinned object is skipped, never re-pinned: {pinned:?}"
);
assert!(
@@ -3710,17 +3361,13 @@ mod tests {
&db,
"repo-multi-stalled",
Duration::from_millis(1500),
None,
),
)
.await
.expect("three stalled objects must still cost one budget, not three");
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
"nothing can pin against a stalled DB"
);
assert!(pinned.is_empty(), "nothing can pin against a stalled DB");
assert!(
elapsed < Duration::from_secs(3),
"three stalled objects must charge ONE budget (1.5s), not one each; got {elapsed:?}"
@@ -3743,7 +3390,7 @@ mod tests {
///
/// The lock time is a MARGIN, not a boundary: taking it at 100ms left `is_pinned`
/// racing it on a loaded box, and losing that race makes the read block, time out,
/// and break the batch, which fails on `pinned.confirmed.len() == 1` for a reason that has
/// and break the batch, which fails on `pinned.len() == 1` for a reason that has
/// nothing to do with the floor. Any time between the `is_pinned` round trip and
/// the add's 1.7s return proves the same thing.
#[sqlx::test]
@@ -3781,7 +3428,6 @@ mod tests {
&db,
"repo-spent-budget",
Duration::from_millis(2000),
None,
),
);
let (pinned, ()) = tokio::join!(pin, locker);
@@ -3794,7 +3440,7 @@ mod tests {
nothing can resolve the CID"
);
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
1,
"the durably recorded pin must be returned: {pinned:?}"
);
@@ -3855,7 +3501,6 @@ mod tests {
&db,
"repo-definite-error",
Duration::from_millis(1200),
None,
),
);
let (pinned, ()) = tokio::join!(pin, commit);
@@ -3937,7 +3582,6 @@ mod tests {
&db,
"repo-marker-floor",
Duration::from_millis(1500),
None,
),
);
let (pinned, ()) = tokio::join!(pin, controller);
@@ -3948,7 +3592,7 @@ mod tests {
drop(sources_lock);
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"an already-pinned object is skipped, never re-pinned: {pinned:?}"
);
assert!(
@@ -4042,7 +3686,6 @@ mod tests {
&db,
"repo-repair-stalled",
Duration::from_millis(2200),
None,
),
);
let (pinned, mut cids_lock) = tokio::join!(pin, controller);
-25
View File
@@ -18,7 +18,6 @@ mod operator;
mod p2p;
mod pinata;
mod rate_limit;
mod reconciliation;
mod server;
mod state;
mod sync;
@@ -667,30 +666,6 @@ async fn main() -> Result<()> {
info!("auto-sync worker started");
}
// Periodic reconciliation sweep: re-derives pin/seal sets and fills gaps
// so a dropped replication job never means data loss.
{
let db = state.db.clone();
let config = Arc::clone(&state.config);
let http_client = Arc::clone(&state.http_client);
let node_keypair = Arc::clone(&state.node_keypair);
let node_did = state.node_did.clone();
let pin_sem = Arc::clone(&state.pin_semaphore);
let shutdown_rx = state.subscribe_shutdown();
if reconciliation::spawn(
db,
config,
http_client,
node_keypair,
node_did,
pin_sem,
shutdown_rx,
Some(state.repo_store.clone()),
) {
info!("reconciliation sweep worker started");
}
}
// On-chain operator setup: verify stake + spawn heartbeat loop
if !state.config.contract_node_staking.is_empty()
&& !state.config.operator_private_key.is_empty()
-52
View File
@@ -20,9 +20,6 @@
//! `gitlawb_pack_size_bytes`
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
//! * currently-connected peer count — `gitlawb_peers_connected`
//! * reconciliation sweep gaps found and filled —
//! `gitlawb_reconciliation_gaps_found_total` /
//! `gitlawb_reconciliation_gaps_filled_total`
//!
//! All metrics live in a single process-wide registry initialized by
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
@@ -61,8 +58,6 @@ static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
static GOSSIP_REPLAY_GUARD_SATURATED: OnceLock<IntCounter> = OnceLock::new();
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
static RECONCILIATION_GAPS_FOUND: OnceLock<IntCounter> = OnceLock::new();
static RECONCILIATION_GAPS_FILLED: OnceLock<IntCounter> = OnceLock::new();
/// 1 when the node started without its p2p identity because the key storage
/// could not be used. The node keeps serving HTTP in that state and /health
/// stays green, so without this the outage is visible only to whoever reads
@@ -246,29 +241,6 @@ fn init_inner(version: &str, node_did: &str) {
.set(peers_connected)
.expect("set PEERS_CONNECTED once");
let gaps_found = IntCounter::with_opts(Opts::new(
"gitlawb_reconciliation_gaps_found_total",
"Total reconciliation sweep gaps detected (objects that should be pinned but are not)",
))
.expect("gitlawb_reconciliation_gaps_found_total definition");
registry
.register(Box::new(gaps_found.clone()))
.expect("register gitlawb_reconciliation_gaps_found_total");
RECONCILIATION_GAPS_FOUND
.set(gaps_found)
.expect("set RECONCILIATION_GAPS_FOUND once");
let gaps_filled = IntCounter::with_opts(Opts::new(
"gitlawb_reconciliation_gaps_filled_total",
"Total reconciliation sweep gaps successfully filled (objects pinned by the sweep)",
))
.expect("gitlawb_reconciliation_gaps_filled_total definition");
registry
.register(Box::new(gaps_filled.clone()))
.expect("register gitlawb_reconciliation_gaps_filled_total");
RECONCILIATION_GAPS_FILLED
.set(gaps_filled)
.expect("set RECONCILIATION_GAPS_FILLED once");
let p2p_key_load_failed = IntGauge::with_opts(Opts::new(
"gitlawb_p2p_identity_key_load_failed",
"1 if the node is running without p2p because its identity key could not be loaded",
@@ -425,20 +397,6 @@ pub fn set_peers_connected(count: i64) {
}
}
/// Record reconciliation sweep gaps found (objects that should be pinned but are not).
pub fn record_reconciliation_gaps_found(count: u64) {
if let Some(c) = RECONCILIATION_GAPS_FOUND.get() {
c.inc_by(count);
}
}
/// Record reconciliation sweep gaps filled (objects successfully pinned by the sweep).
pub fn record_reconciliation_gaps_filled(count: u64) {
if let Some(c) = RECONCILIATION_GAPS_FILLED.get() {
c.inc_by(count);
}
}
/// Record whether the node is running without its p2p identity.
pub fn set_p2p_key_load_failed(failed: bool) {
if let Some(g) = P2P_KEY_LOAD_FAILED.get() {
@@ -483,8 +441,6 @@ mod tests {
.expect("PUSHES set after init")
.with_label_values(&["alice/repo"])
.inc();
record_reconciliation_gaps_found(7);
record_reconciliation_gaps_filled(3);
let body = encode().expect("encode should succeed after init");
assert!(
@@ -499,14 +455,6 @@ mod tests {
body.contains("gitlawb_pushes_total{repo=\"alice/repo\"} 1"),
"expected the incremented counter to be visible in: {body}"
);
assert!(
body.contains("gitlawb_reconciliation_gaps_found_total 7"),
"expected the reconciliation gaps-found counter to be visible in: {body}"
);
assert!(
body.contains("gitlawb_reconciliation_gaps_filled_total 3"),
"expected the reconciliation gaps-filled counter to be visible in: {body}"
);
}
/// #192 F4: `init` is idempotent and safe to call repeatedly. The panic that
+41 -489
View File
@@ -153,26 +153,6 @@ pub async fn pin_object(
/// `upsert_branch_cid` and the p2p `publish_ref_update` gossip CID. The twin's return is
/// log-only, so it omits a record-failed pin rather than logging a pin the resolver
/// cannot serve. Moving this side to match would need that consumer moved first.
/// Whether the committed `pinned_cids` row already names `repo_id` as
/// first pinner, making a failed redundant `record_pin_source` insert
/// irrelevant to resolvability (`pin_sources_for_oid` unions the
/// primary row). Bounded read; any failure (including its own
/// timeout) answers false — an unproven row never confirms.
/// Used only after the primary `record_pinata_cid` for this call
/// succeeded or verified: callers must not promote a row this call
/// did not establish.
async fn primary_covers_repo(
db: &crate::db::Db,
sha: &str,
repo_id: &str,
deadline: std::time::Instant,
) -> bool {
matches!(
crate::ipfs_pin::db_bounded(deadline, db.provenance_for_oid(sha)).await,
Ok(Some(owner)) if owner.as_str() == repo_id
)
}
// Ten arguments, over clippy's threshold: the three the budget and the git seam add
// (`git_bin`, `git_timeout`, `batch_budget`) plus #173's `repo_id` are what put the read
// under test injection and under a deadline, and grouping them into a struct would only
@@ -190,33 +170,16 @@ pub async fn pin_new_objects(
db: &crate::db::Db,
repo_id: &str,
batch_budget: Duration,
fence: Option<&crate::ipfs_pin::PolicyFence>,
) -> crate::ipfs_pin::PinBatchOutcome {
) -> Vec<(String, String)> {
if jwt.is_empty() {
return crate::ipfs_pin::PinBatchOutcome {
confirmed: Vec::new(),
last_attempted: None,
};
return vec![];
}
let deadline = Instant::now() + batch_budget;
let total = object_list.len();
let mut pinned = Vec::new();
let mut last_attempted: Option<String> = None;
for (attempted, sha) in object_list.into_iter().enumerate() {
// Policy fence (R1-P1): a visibility narrow that lands after the caller
// built this batch must abort it before the next irreversible upload.
if let Some(f) = fence {
if !f.is_current().await {
tracing::warn!(
repo = %f.repo_id(),
unattempted = total - attempted,
"visibility policy changed mid-batch; stopping the Pinata pin loop"
);
break;
}
}
// Top of the iteration, before any of this object's work: an object is never
// started with a remainder too small to cover a bounded read's teardown. The
// gate is shared with the IPFS loop so the two cannot drift apart in how they
@@ -228,10 +191,6 @@ pub async fn pin_new_objects(
{
break;
}
// Attempt progress (not a durability claim): same contract as the
// IPFS twin — the continuation persists this, never the planned
// vector's tail.
last_attempted = Some(sha.clone());
// Every DB call from here to the end of the iteration is bounded by the
// ABSOLUTE batch deadline (F3, #173), through the same `db_bounded` helper the
@@ -436,21 +395,6 @@ pub async fn pin_new_objects(
}
};
// Dispatch fence (R1-P1): re-read the policy epoch immediately before
// the irreversible HTTP POST. The iteration-top check catches a narrow
// that landed before work began; THIS check catches a narrow that landed
// during the has_pinata_cid round-trip or the bounded Git read.
if let Some(f) = fence {
if !f.is_current().await {
tracing::warn!(
repo = %f.repo_id(),
unattempted = total - attempted,
"visibility policy changed during preparation; aborting Pinata upload"
);
break;
}
}
match pin_object(client, upload_url, jwt, &sha, &data).await {
Ok(cid) if !cid.is_empty() => {
// The resolver key (`pinned_cids.cid`) must be the locally-computed
@@ -466,109 +410,30 @@ pub async fn pin_new_objects(
// runs under the shared client's own ceiling, so a successful one can
// return with ~0 of the batch budget left, and an unfloored bound would
// fail a write that today completes in milliseconds. That costs more on
// this side than on the twin: `pinned.push` below feeds
// `api/repos.rs`, which builds its `cid_map` from the pair and drives
// this side than on the twin: `pinned.push` below is UNCONDITIONAL, so
// `api/repos.rs` builds its `cid_map` from the pair either way and drives
// `upsert_branch_cid` plus the p2p `publish_ref_update` gossip from it. A
// dropped record therefore makes the node ADVERTISE a CID whose `/ipfs`
// read 404s. Only durably recorded pairs are pushed, so the map never
// advertises an unconfirmed row.
//
// Round 10 P2: a closed/failed DB write means the (sha, cid)
// pair is not durable; we suppress the `pinned.push` for this
// sha so the reconcile cannot count a Pinata gap as filled
// when no row exists. The source-record failure arms are also
// hard failures (multi-statement transaction never committed)
// and suppress the push in the same way.
//
// `record_pinata_cid` is an explicit multi-statement
// transaction (row lock + insert + commit), NOT a single
// autocommit upsert, so a timed-out future proves nothing:
// cancellation before COMMIT wrote nothing, during COMMIT is
// unknown. The Elapsed arm therefore verifies by content
// (`verify_pinata_record`: exact row + unchanged fence epoch)
// under its own bound and pushes only on proof; anything
// else stays suppressed and the gap is re-offered.
let fence_epoch = fence.map(|f| f.captured_epoch()).unwrap_or(i64::MAX);
let mut db_record_durable = false;
match crate::ipfs_pin::db_bounded(
// read 404s. If the floored bound still fires, THIS site's outcome really
// is unknown, and unlike the source record below that is a property of
// the operation: `record_pinata_cid` is a single autocommit upsert, so
// the statement Postgres already started can still land after the client
// future is cancelled. The warn names the arm through the error's own
// Display and the site keeps its existing behavior: the pair is still
// returned, and the row may or may not exist.
if let Err(e) = crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
crate::ipfs_pin::retry_db_record(|| {
// P2 (reviewer round 9): Pinata's POST is
// irreversible exactly like IPFS's, so
// route the record through the fenced
// variant when a fence is in scope. The
// `i64::MAX` sentinel tells the helper
// to skip the lock + comparison (the
// unfenced caller path).
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id), fence_epoch)
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id))
}),
)
.await
{
Ok(()) => db_record_durable = true,
Err(crate::ipfs_pin::BoundedDbError::Elapsed) => {
tracing::warn!(
sha = %sha,
"record_pinata_cid deadline elapsed on a multi-statement transaction; \
verifying the exact row before treating it as durable"
);
match crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
db.verify_pinata_record(&sha, &raw_cid, &cid, repo_id, fence_epoch),
)
.await
{
Ok(true) => {
tracing::warn!(
sha = %sha,
"timed-out pinata record verified present with unchanged epoch; \
treating as durable"
);
db_record_durable = true;
}
Ok(false) => {
tracing::warn!(
sha = %sha,
"timed-out pinata record has no matching row or the epoch moved; \
suppressing the push so the gap is re-offered"
);
}
Err(e) => {
tracing::warn!(
sha = %sha,
err = %e,
"pinata record verification did not complete; \
suppressing the push so the gap is re-offered"
);
}
}
}
Err(e) => {
tracing::warn!(
sha = %sha,
err = %e,
"failed to record pinata_cid in DB; suppressing the (sha, cid) push \
so the reconcile cannot count this gap as filled"
);
// db_record_durable stays false → push suppressed
}
tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB");
}
// F1 (#173 round 8): also record the first pinner in pin_repo_sources.
// U3: an exhausted retry marks the set incomplete so the resolver keeps
// the scan fallback rather than 404ing a copy it could serve.
//
// Whether the pair stays confirmed depends on what the
// PRIMARY `record_pinata_cid` above established: that row
// already names this repo as first pinner
// (`pin_sources_for_oid` unions it), so the resolver
// reaches this copy with or without the redundant
// `pin_repo_sources` insert. A source-write failure then
// keeps the pair confirmed (but still marks the set
// incomplete, preserving the U3 compensation). If the
// primary row names ANOTHER repo — or is absent — the
// source write was load-bearing and the push stays
// suppressed. (The IPFS twin needs no equivalent: its
// pin and source land in ONE transaction.)
match crate::ipfs_pin::db_bounded(
crate::ipfs_pin::db_record_deadline(deadline),
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)),
@@ -580,7 +445,9 @@ pub async fn pin_new_objects(
// wraps `record_pin_source`, an explicit transaction, so a timed-out
// call definitely never committed and the source is definitely
// missing. Mark the set incomplete rather than leaving it incomplete
// and unmarked.
// and unmarked. Note the contrast with `record_pinata_cid` a few
// lines up: that one is a single autocommit statement, so its
// timeout genuinely is an unknown outcome and it is warn-only.
Err(e @ crate::ipfs_pin::BoundedDbError::Elapsed) => {
tracing::warn!(
sha = %sha,
@@ -597,16 +464,6 @@ pub async fn pin_new_objects(
{
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
}
if !primary_covers_repo(
db,
&sha,
repo_id,
crate::ipfs_pin::db_record_deadline(deadline),
)
.await
{
db_record_durable = false;
}
}
Err(e) => {
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
@@ -618,21 +475,9 @@ pub async fn pin_new_objects(
{
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
}
if !primary_covers_repo(
db,
&sha,
repo_id,
crate::ipfs_pin::db_record_deadline(deadline),
)
.await
{
db_record_durable = false;
}
}
}
if db_record_durable {
pinned.push((sha, cid));
}
pinned.push((sha, cid));
}
Ok(_) => {}
Err(e) => {
@@ -641,10 +486,7 @@ pub async fn pin_new_objects(
}
}
crate::ipfs_pin::PinBatchOutcome {
confirmed: pinned,
last_attempted,
}
pinned
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -846,20 +688,19 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_millis(5500),
None,
),
)
.await
.expect("wedge guard: a 5.5s budget cannot take 30s");
assert!(
(1..=3).contains(&pinned.confirmed.len()),
(1..=3).contains(&pinned.len()),
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
pinned.confirmed.len()
pinned.len()
);
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
pinned.confirmed.len(),
pinned.len(),
"no upload may be issued for an object the budget stopped short of"
);
let text = logs.text();
@@ -887,9 +728,9 @@ mod tests {
})
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
assert!(
unattempted >= 1 && unattempted + pinned.confirmed.len() <= 5,
unattempted >= 1 && unattempted + pinned.len() <= 5,
"unattempted={unattempted} with {} pinned is not a partial batch of five",
pinned.confirmed.len()
pinned.len()
);
}
@@ -938,7 +779,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(2),
None,
),
)
.await
@@ -949,7 +789,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a git that never answers cannot produce a pinned object: {pinned:?}"
);
assert!(
@@ -1028,7 +868,6 @@ mod tests {
"repo-git-timeout",
// Generous, so a call that ends on time ended on `git_timeout`.
Duration::from_secs(60),
None,
),
)
.await
@@ -1039,7 +878,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a git that never answers cannot produce a pinned object: {pinned:?}"
);
assert!(
@@ -1131,7 +970,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
@@ -1141,7 +979,7 @@ mod tests {
if genuinely_unreadable {
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"nothing can be pinned through a store that cannot be read: {pinned:?}"
);
assert_eq!(
@@ -1206,7 +1044,6 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
@@ -1218,7 +1055,7 @@ mod tests {
"an object-scoped fault must not stop the batch: every object must be read"
);
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
4,
"one corrupt object must cost only itself: the other four must still pin"
);
@@ -1258,18 +1095,13 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
.expect("an immediate endpoint and three healthy objects cannot take 30s");
assert_eq!(
pinned.confirmed.len(),
3,
"every healthy object must pin: {pinned:?}"
);
for (i, (sha, cid)) in pinned.confirmed.iter().enumerate() {
assert_eq!(pinned.len(), 3, "every healthy object must pin: {pinned:?}");
for (i, (sha, cid)) in pinned.iter().enumerate() {
assert_eq!(sha, &oids[i], "the pairs must carry the objects' own oids");
assert_eq!(cid, "QmPinataBatchTestCid");
assert!(
@@ -1292,15 +1124,11 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
.expect("a fully deduped batch cannot take 30s");
assert!(
again.confirmed.is_empty(),
"already-recorded objects must be skipped"
);
assert!(again.is_empty(), "already-recorded objects must be skipped");
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
3,
@@ -1308,63 +1136,6 @@ mod tests {
);
}
/// The outcome reports the last object actually ENTERED, not the planned
/// tail (Pinata twin of the IPFS test above): the first upload takes 6s
/// against a 2s batch budget, so it still completes — the Pinata loop
/// has no per-request timeout — but the loop-top gate then breaks the
/// batch before the second object starts. `last_attempted` must be the
/// first OID even though two OIDs were never visited; the 6s server
/// sleep makes the break deterministic on any box.
#[sqlx::test]
async fn pin_new_objects_reports_last_attempted_not_planned_tail(pool: sqlx::PgPool) {
let db = crate::db::Db::for_testing(pool);
db.run_migrations().await.expect("migrations");
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("attempted.git");
let oids = seed_loose_blobs(&repo_path, 3);
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let endpoint = delaying_pinata_endpoint(
vec![Duration::from_secs(6)],
std::sync::Arc::clone(&requests),
)
.await;
let client = reqwest::Client::new();
let outcome = tokio::time::timeout(
Duration::from_secs(60),
pin_new_objects(
&client,
&endpoint,
"test-jwt",
&repo_path,
"git",
Duration::from_secs(60),
oids.clone(),
&db,
"repo-attempted",
Duration::from_secs(2),
None,
),
)
.await
.expect("one 6s upload plus bounded gates cannot take 60s");
assert_eq!(
outcome.last_attempted,
Some(oids[0].clone()),
"only the first object was entered; the planned tail was never visited"
);
assert_eq!(
outcome.confirmed.len(),
1,
"the slow first upload still completed and recorded"
);
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
1,
"exactly one upload was attempted"
);
}
/// The no-op configuration still short-circuits with the budgeted signature: an
/// empty JWT must return before any git child is spawned and before any request
/// is issued. The `git_bin` here records every invocation, so "git was never
@@ -1406,13 +1177,12 @@ mod tests {
&db,
"repo-merge-test",
Duration::from_secs(60),
None,
),
)
.await
.expect("an unconfigured sink returns immediately");
assert!(pinned.confirmed.is_empty(), "an empty JWT pins nothing");
assert!(pinned.is_empty(), "an empty JWT pins nothing");
assert!(
!log.exists(),
"no git child may be spawned when the sink is not configured"
@@ -1512,7 +1282,6 @@ mod tests {
&db,
"repo-pinata-stalled",
Duration::from_millis(1500),
None,
),
)
.await
@@ -1523,7 +1292,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a stalled pinata-status check cannot produce a pinned object: {pinned:?}"
);
assert!(
@@ -1569,15 +1338,9 @@ mod tests {
// bytes.
let raw_cid =
gitlawb_core::cid::Cid::from_git_object_bytes(b"pinata skip seed").to_string();
db.record_pinata_cid(
&sha,
&raw_cid,
"QmSeedProviderCid",
Some("repo-seed"),
i64::MAX,
)
.await
.unwrap();
db.record_pinata_cid(&sha, &raw_cid, "QmSeedProviderCid", Some("repo-seed"))
.await
.unwrap();
db.record_pin_source(&sha, "repo-seed").await.unwrap();
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let endpoint = delaying_pinata_endpoint(
@@ -1609,7 +1372,6 @@ mod tests {
&db,
"repo-pinata-skip-stalled",
Duration::from_millis(1500),
None,
),
)
.await
@@ -1620,7 +1382,7 @@ mod tests {
let elapsed = started.elapsed();
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"an already-pinned object is skipped, never re-uploaded: {pinned:?}"
);
assert_eq!(
@@ -1664,7 +1426,7 @@ mod tests {
///
/// The lock time is a MARGIN, not a boundary: taking it at 100ms left
/// `has_pinata_cid` racing it on a loaded box, and losing that race makes the read
/// block, time out, and break the batch, which fails on `pinned.confirmed.len() == 1` for a
/// block, time out, and break the batch, which fails on `pinned.len() == 1` for a
/// reason that has nothing to do with the floor. Any time between the
/// `has_pinata_cid` round trip and the upload's 1.7s return proves the same thing.
#[sqlx::test]
@@ -1710,7 +1472,6 @@ mod tests {
&db,
"repo-pinata-spent-budget",
Duration::from_millis(2000),
None,
),
);
let (pinned, ()) = tokio::join!(pin, locker);
@@ -1728,7 +1489,7 @@ mod tests {
record makes api/repos.rs advertise a CID the resolver cannot serve"
);
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
1,
"the uploaded pin must still be returned: {pinned:?}"
);
@@ -1796,7 +1557,6 @@ mod tests {
&db,
"repo-pinata-post-upload",
Duration::from_millis(1500),
None,
),
)
.await
@@ -1810,21 +1570,10 @@ mod tests {
drop(lock);
upload.assert_async().await;
// The primary `record_pinata_cid` committed above naming this
// repo as first pinner, so `pin_sources_for_oid` resolves the
// copy with or without the redundant `pin_repo_sources`
// insert: the pair stays confirmed even though the source
// record timed out. The incomplete marker below is still set
// (compensation preserved); only the suppression is gone.
// Prior to the round 10 fix, the push fired regardless of
// every post-upload outcome; the correction since is that
// confirmation follows the durable primary row, not the
// redundant source write.
assert_eq!(
pinned.confirmed.len(),
pinned.len(),
1,
"record_pin_source timed out (pin_repo_sources locked) but the primary row \
covers this repo, so the pair stays confirmed: {pinned:?}"
"the upload succeeded, so this lane still returns the pair: {pinned:?}"
);
assert!(
elapsed < Duration::from_secs(8),
@@ -1841,203 +1590,6 @@ mod tests {
);
}
/// The other half of the source-write contract: when the committed
/// primary row names ANOTHER repo as first pinner, the failed
/// `record_pin_source` was load-bearing (not redundant) and the
/// push stays suppressed. Fixture: a local-only row owned by
/// repo-first (no Pinata CID yet), then a Pinata loop as
/// repo-second with `pin_repo_sources` locked. The upload lands,
/// the primary upsert keeps repo-first, the source insert stalls,
/// and coverage fails — so no pair, but the incomplete marker
/// still lands and the provider CID is still recorded.
#[sqlx::test]
async fn pinata_post_upload_stalled_source_without_coverage_suppresses_push(
pool: sqlx::PgPool,
) {
let db = crate::db::Db::for_testing(pool.clone());
db.run_migrations().await.expect("migrations");
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("pinata_second_source.git");
let oids = seed_loose_blobs(&repo_path, 1);
let sha = oids[0].clone();
// A local-only row owned by repo-first: the writer path below
// (repo-second, Pinata-only so far) must not claim it.
let raw =
gitlawb_core::cid::Cid::from_git_object_bytes(b"pinata loop object 0\n").to_string();
db.record_pinned_cid_with_source(&sha, &raw, "repo-first")
.await
.unwrap();
let mut server = mockito::Server::new_async().await;
let upload = server
.mock("POST", mockito::Matcher::Any)
.with_status(200)
.with_body(r#"{"data":{"cid":"QmSecondSourceProviderCid"}}"#)
.expect(1)
.create_async()
.await;
let (_logs, _log_guard) = capture_logs();
let mut lock = lock_table(&pool, "pin_repo_sources").await;
let client = reqwest::Client::new();
let pinned = tokio::time::timeout(
Duration::from_secs(20),
pin_new_objects(
&client,
&server.url(),
"test-jwt",
&repo_path,
"git",
Duration::from_secs(30),
oids,
&db,
"repo-second",
Duration::from_millis(1500),
None,
),
)
.await
.expect("record + coverage ladders are each floored");
rollback(&mut lock).await;
drop(lock);
upload.assert_async().await;
assert!(
pinned.confirmed.is_empty(),
"repo-second is not covered by the repo-first primary row, so the \
failed source write stays suppressing: {pinned:?}"
);
assert!(
db.pin_sources_incomplete(&sha).await.unwrap(),
"the compensation marker still lands"
);
// The primary upsert itself landed (provider CID recorded under
// the first-pinner row); only the pair was suppressed.
assert!(
db.has_pinata_cid(&sha).await.unwrap(),
"the committed provider row survives the suppressed push"
);
}
/// Hold a row-level lock on one repos row in an open transaction: plain
/// `SELECT`s (fence reads, skip checks) proceed, but any `SELECT ...
/// FOR UPDATE` on the row — exactly what the fenced record takes —
/// blocks until rollback. A table-level lock would also stall the
/// fence's own epoch reads and deadlock the loop under test, proving
/// nothing about the record arm.
async fn hold_repo_row_lock(
pool: &sqlx::PgPool,
repo_id: &str,
) -> sqlx::pool::PoolConnection<sqlx::Postgres> {
let mut conn = pool.acquire().await.unwrap();
sqlx::raw_sql("BEGIN").execute(&mut *conn).await.unwrap();
sqlx::query("SELECT policy_epoch FROM repos WHERE id = $1 FOR UPDATE")
.bind(repo_id)
.fetch_one(&mut *conn)
.await
.unwrap();
conn
}
/// A timed-out `record_pinata_cid` with no verifiable row suppresses the
/// push: `record_pinata_cid` is an explicit transaction, so Elapsed proves
/// nothing, and the verify read finds no row, so nothing is proven that
/// way either. The upload mock still expects the POST — Pinata accepted
/// the bytes — but without a durable row the pair must not be returned,
/// or the reconcile counts a fill that never happened and the push path
/// advertises an unresolvable CID.
#[sqlx::test]
async fn pinata_post_upload_elapsed_record_without_row_suppresses_push(pool: sqlx::PgPool) {
let db = crate::db::Db::for_testing(pool.clone());
db.run_migrations().await.expect("migrations");
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("pinata_elapsed_verify.git");
let oids = seed_loose_blobs(&repo_path, 1);
let sha = oids[0].clone();
// A real repo row: the fenced record's `FOR UPDATE` must have a row
// to block on. Against a missing row the predicate takes no lock and
// the record would sail through instead of stalling.
let repo_id = "repo-pinata-elapsed-verify";
let now = chrono::Utc::now();
db.create_repo(&crate::db::RepoRecord {
id: repo_id.to_string(),
name: "elapsed-verify".into(),
owner_did: "did:key:zElapsedVerifyOwner".into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: now,
updated_at: now,
disk_path: repo_path.display().to_string(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
// Capture the fence BEFORE locking: the capture itself is a plain
// epoch read and must succeed for the fenced record path to run.
let fence = crate::ipfs_pin::PolicyFence::capture(&db, repo_id)
.await
.expect("fence captures");
let mut server = mockito::Server::new_async().await;
let upload = server
.mock("POST", mockito::Matcher::Any)
.with_status(200)
.with_body(r#"{"data":{"cid":"QmElapsedVerifyProviderCid"}}"#)
.expect(1)
.create_async()
.await;
let (_logs, _log_guard) = capture_logs();
// Stall only the fenced record's `FOR UPDATE`: fence reads and the
// skip check are plain SELECTs and proceed, the upload lands, then
// the record elapses. The verify read proceeds too and finds no row.
let mut lock = hold_repo_row_lock(&pool, repo_id).await;
let client = reqwest::Client::new();
let pinned = tokio::time::timeout(
Duration::from_secs(30),
pin_new_objects(
&client,
&server.url(),
"test-jwt",
&repo_path,
"git",
Duration::from_secs(30),
oids,
&db,
repo_id,
Duration::from_millis(1500),
Some(&fence),
),
)
.await
.expect(
"record + verify ladders are each floored, so the call returns in seconds, \
never at the lock's lifetime",
);
rollback(&mut lock).await;
drop(lock);
upload.assert_async().await;
assert!(
pinned.confirmed.is_empty(),
"an unverified timed-out record must not return the pair: {pinned:?}"
);
assert!(
!db.has_pinata_cid(&sha).await.unwrap(),
"the cancelled record transaction must not have landed a row"
);
}
#[tokio::test]
async fn test_pin_skipped_when_jwt_empty() {
let client = reqwest::Client::new();
File diff suppressed because it is too large. Load diff
+142 -280
View File
@@ -186,153 +186,6 @@ pub(crate) async fn silent_http_endpoint() -> String {
endpoint
}
/// Body of the `for-each-ref` arm of a fake-git fixture in the
/// COLUMN SHAPE `blob_paths` phase 2 parses. The caller is
/// responsible for wrapping this in a full `case "$1" in ... esac`
/// shell script and writing it to a tempdir; this is the form
/// used when a fixture has OTHER arms (e.g. `rev-list`,
/// `pack-objects`) that the visibility-pipeline tests also need
/// to fake.
///
/// Two output shapes:
/// - `tag` tip (`peeled_oid` and `peeled_kind` empty) →
/// `echo '<oid> <kind>'` (two tokens).
/// - Annotated tag tip → `echo '<oid> tag <peeled_oid> <peeled_kind>'`
/// (four tokens; the literal `tag` in slot 2 is the parser's
/// "peeled type is `tag`" trigger for the recursive peel).
///
/// An empty `refs` slice emits a single `:` so phase 2 sees no
/// lines (the same as a bare default `*) : ;;` arm).
#[allow(dead_code)] // referenced by `fake_git_with_refs` and the round-9 fixtures
pub(crate) fn fake_git_for_ref_body(refs: &[(&str, &str, &str, &str)]) -> String {
// P2 (reviewer round 9): the previous body emitted
// `echo ... ;;` per ref, which is a `;;` PER REF inside
// a single case arm. The first `;;` closes the arm, and
// every subsequent `echo` parses as a pattern line, which
// `sh -n` rejects with "word unexpected". The right shape
// is one `;;` per arm, emitted once after the last ref.
let mut body = String::new();
if refs.is_empty() {
body.push_str(" : ;;\n");
} else {
for (oid, kind, peeled_oid, peeled_kind) in refs {
if peeled_oid.is_empty() && peeled_kind.is_empty() {
body.push_str(&format!(" echo '{oid} {kind}'\n"));
} else {
body.push_str(&format!(
" echo '{oid} tag {peeled_oid} {peeled_kind}'\n"
));
}
}
body.push_str(" ;;\n");
}
body
}
/// Full fake-git script body for a fixture whose ONLY fake arm is
/// `for-each-ref`. All other `git` subcommands are answered by
/// the default `*) : ;;` no-op, so a real-git repo with matching
/// refs is needed to drive the rest of the walk. Used by tests
/// that want the parser contract enforced without committing to
/// the other arms the smart-HTTP fixture cares about.
#[allow(dead_code)] // referenced by tests in the unit-test mod below
pub(crate) fn fake_git_with_refs(refs: &[(&str, &str, &str, &str)]) -> String {
let mut body = String::from("#!/bin/sh\ncase \"$1\" in\n for-each-ref)\n");
body.push_str(&fake_git_for_ref_body(refs));
body.push_str(" *) : ;;\nesac\nexit 0\n");
body
}
#[cfg(test)]
mod helper_tests {
use super::*;
/// #218 round 9 (guidance #6): the helper emits the column
/// shape `blob_paths` phase 2 parses. Pin the format at the
/// cargo-test level so a parser regression breaks this
/// helper test in addition to the production tests.
/// P2 (reviewer round 9): also execute the generated script
/// through `sh -n` and against a tempdir; the previous
/// substring check passed while the script was a `sh`
/// syntax error (the `;;` was emitted once per ref inside
/// a single case arm, which `sh` rejects).
#[test]
fn fake_git_with_refs_emits_the_column_shape() {
let script = fake_git_with_refs(&[
("commit0000000000000000000000000000000", "commit", "", ""),
(
"tag0000000000000000000000000000000000",
"tag",
"peel000000000000000000000000000000",
"blob",
),
]);
assert!(
script.contains("'commit0000000000000000000000000000000 commit'"),
"non-tag tip must emit two tokens: got\n{script}"
);
assert!(
script.contains("'tag0000000000000000000000000000000000 tag peel000000000000000000000000000000 blob'"),
"annotated tag tip must emit four tokens: got\n{script}"
);
// P2 (reviewer round 9): execute the script through
// `sh -n` to catch the `;;` per-ref syntax error the
// previous test missed. The previous test only checked
// substring presence and was green while the script was
// malformed shell.
let sh_n = std::process::Command::new("sh")
.args(["-n", "-c", &script])
.status()
.expect("sh -n must run");
assert!(
sh_n.success(),
"the generated script must be valid shell; \
`sh -n` exited {sh_n:?}\n----\n{script}\n----"
);
// Also run the script for real against a tempdir. The
// non-tag tip must echo the two-token line, and the
// annotated-tag tip must echo the four-token line.
let td = tempfile::tempdir().expect("tempdir");
let script_path = td.path().join("fake-git.sh");
std::fs::write(&script_path, &script).expect("write script");
std::fs::set_permissions(
&script_path,
std::os::unix::fs::PermissionsExt::from_mode(0o755),
)
.expect("chmod");
let out = std::process::Command::new(&script_path)
.arg("for-each-ref")
.output()
.expect("run script");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("commit0000000000000000000000000000000 commit"),
"the two-token tip must print: stdout={stdout:?}\nscript:\n{script}"
);
assert!(
stdout.contains(
"tag0000000000000000000000000000000000 tag peel000000000000000000000000000000 blob"
),
"the four-token tip must print: stdout={stdout:?}\nscript:\n{script}"
);
}
#[test]
fn fake_git_with_refs_empty_slice_emits_a_zero_refs_marker() {
let script = fake_git_with_refs(&[]);
assert!(
script.contains("for-each-ref)"),
"the helper still owns the for-each-ref arm: got\n{script}"
);
assert!(
script.contains(" : ;;\n"),
"an empty refs slice must emit a single `:` so phase 2 sees zero lines: got\n{script}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -7327,7 +7180,6 @@ mod tests {
&state.db,
&pub_repo.id,
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await; // asserts /add was NOT called (already pinned)
@@ -7454,7 +7306,6 @@ mod tests {
&state.db,
&pub_repo.id,
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await; // /add NOT called (already pinned)
@@ -7645,7 +7496,6 @@ mod tests {
&state.db,
repo_id,
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -8291,25 +8141,15 @@ mod tests {
let repo = seed_repo(&owner_did, "u3pinata");
state.db.create_repo(&repo).await.expect("seed repo");
// Already a Pinata-pinned row so the Pinata pin loop's
// `has_pinata_cid` skip branch fires and the only DB write
// under test is the U3 source record retry. The IPFS pin
// loop's `is_pinned` / `has_ipfs_cid` skip is a separate
// seam (this test exercises the Pinata path, not the IPFS
// path), so a Pinata-only seed is the right shape here.
// Already carries a pinata_cid, so pin_new_objects takes the skip branch and the
// only DB write under test is the source record.
let (_ty, raw) = crate::git::store::read_object(&bare, &fx.public_oid)
.unwrap()
.expect("object readable");
let raw_cid = gitlawb_core::cid::Cid::from_git_object_bytes(&raw).to_string();
state
.db
.record_pinata_cid(
&fx.public_oid,
&raw_cid,
"QmProvider",
Some(&repo.id),
i64::MAX,
)
.record_pinata_cid(&fx.public_oid, &raw_cid, "QmProvider", Some(&repo.id))
.await
.expect("seed pinata pin");
@@ -8346,7 +8186,6 @@ mod tests {
// never truncates the one object under test: what is being measured
// is the retry backoff, not the budget.
std::time::Duration::from_secs(60),
None,
)
.await;
m.assert_async().await; // the upload is skipped: DB-only path
@@ -8518,7 +8357,6 @@ mod tests {
"repoPinataBound",
// The bound under test.
std::time::Duration::from_secs(2),
None,
),
)
.await
@@ -8586,7 +8424,6 @@ mod tests {
&state.db,
"repoKuboBound",
std::time::Duration::from_secs(2),
None,
),
)
.await
@@ -8649,7 +8486,6 @@ mod tests {
&state.db,
"repoPinataRepair",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -8708,7 +8544,6 @@ mod tests {
&state.db,
"repoPinataGate",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -8781,7 +8616,6 @@ mod tests {
&state.db,
"repoPinataWarn",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -8853,7 +8687,6 @@ mod tests {
&state.db,
"repoPinataNoSkip",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -8870,7 +8703,7 @@ mod tests {
);
assert_eq!(stashed, None, "and nothing is stashed for it");
assert_eq!(
pinned.confirmed,
pinned,
vec![(fx.public_oid.clone(), "QmPinataUploaded".to_string())],
"the pinata return still carries the provider CID for the announcement cid_map"
);
@@ -9344,7 +9177,7 @@ mod tests {
// raw CID in `cid` with the provider CID in `pinata_cid`.
state
.db
.record_pinata_cid("po1", &raw1, "pcid1", Some("repoA"), i64::MAX)
.record_pinata_cid("po1", &raw1, "pcid1", Some("repoA"))
.await
.unwrap();
assert_eq!(
@@ -9359,7 +9192,7 @@ mod tests {
.into_iter()
.find(|r| r.sha256_hex == "po1")
.expect("po1 row exists");
assert_eq!(po1.cid, Some(raw1), "resolver-key cid is the raw CID");
assert_eq!(po1.cid, raw1, "resolver-key cid is the raw CID");
assert_eq!(
po1.pinata_cid.as_deref(),
Some("pcid1"),
@@ -9375,7 +9208,7 @@ mod tests {
.unwrap();
state
.db
.record_pinata_cid("po2", "rawcid2", "pcid2", Some("repoB"), i64::MAX)
.record_pinata_cid("po2", "rawcid2", "pcid2", Some("repoB"))
.await
.unwrap();
assert_eq!(
@@ -9392,8 +9225,7 @@ mod tests {
.find(|r| r.sha256_hex == "po2")
.expect("po2 row exists");
assert_eq!(
po2.cid,
Some(local2),
po2.cid, local2,
"on conflict the prior local pin's cid is left untouched"
);
@@ -9405,7 +9237,7 @@ mod tests {
.unwrap();
state
.db
.record_pinata_cid("po3", "rawcid3", "pcid3", Some("repoY"), i64::MAX)
.record_pinata_cid("po3", "rawcid3", "pcid3", Some("repoY"))
.await
.unwrap();
assert_eq!(
@@ -9437,7 +9269,7 @@ mod tests {
// Pinata-first: no prior local pin, so this INSERT creates the row.
state
.db
.record_pinata_cid("pfsha", &raw_cid, provider_cid, Some("repoP"), i64::MAX)
.record_pinata_cid("pfsha", &raw_cid, provider_cid, Some("repoP"))
.await
.unwrap();
@@ -9490,11 +9322,10 @@ mod tests {
&state.db,
"repoZ",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
assert!(
!pinned.confirmed.is_empty(),
!pinned.is_empty(),
"the object was pinned via the real pin path"
);
m.assert_async().await;
@@ -9555,14 +9386,13 @@ mod tests {
// (PIN_RECORD_ATTEMPTS x PIN_RECORD_BACKOFF), so the batch budget gate
// is never what truncates this run.
std::time::Duration::from_secs(60),
None,
)
.await
})
.await;
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a pin with no durable index row must not be reported as pinned, got {pinned:?}"
);
// Exactly two adds: the first record failure did not break the batch.
@@ -9676,7 +9506,6 @@ mod tests {
&db,
"repoWedge",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
),
)
.await
@@ -9690,7 +9519,7 @@ mod tests {
not leave it running (which would pin the coalescing key until process death)"
);
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"a wedged read pins nothing this pass; a later pass/push retries"
);
}
@@ -9807,12 +9636,11 @@ mod tests {
&state.db,
"repoBF",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
assert!(
pinned.confirmed.is_empty(),
pinned.is_empty(),
"an already-pinned object is not re-pinned (no bytes returned)"
);
m.assert_async().await; // asserts /add was called 0 times
@@ -9884,17 +9712,9 @@ mod tests {
);
// Legacy-shape row: cid = the PROVIDER CID (raw SQL — the helpers store the
// raw CID). The object itself is public and servable: the bytes are
// on local IPFS (so `local_ipfs_provenance = TRUE` under #218 review
// P1's writer-owned contract), only the CID key is wrong. Without
// `local_ipfs_provenance = TRUE`, the new `has_ipfs_cid` check in
// `pin_new_objects` would fall through to the upload path and re-pin
// bytes that are already on IPFS — the test's `expect(0)` mock
// would fail. Setting the flag reflects the real pre-upgrade
// state (a legacy local IPFS pin with the wrong CID).
// raw CID). The object itself is public and servable.
sqlx::query(
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo_id, local_ipfs_provenance) \
VALUES ($1, $2, $3, $4, TRUE)",
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo_id) VALUES ($1, $2, $3, $4)",
)
.bind(&fx.public_oid)
.bind(&provider_cid)
@@ -9938,7 +9758,6 @@ mod tests {
&state.db,
&repo.id,
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -10043,7 +9862,6 @@ mod tests {
&state.db,
"repoCG",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
m.assert_async().await;
@@ -10109,7 +9927,6 @@ mod tests {
&state.db,
"repoUR",
crate::ipfs_pin::PIN_BATCH_BUDGET,
None,
)
.await;
@@ -10315,7 +10132,7 @@ mod tests {
.await
.unwrap()
.iter()
.any(|r| r.cid.as_deref() == Some(raw_cid.as_str())),
.any(|r| r.cid == raw_cid),
"the repaired row is advertised"
);
let (st, body) = cid_parts(
@@ -10935,7 +10752,7 @@ mod tests {
.await
.unwrap()
.iter()
.any(|r| r.cid.as_deref() == Some(raw_cid.as_str())),
.any(|r| r.cid == raw_cid),
"the repaired row is advertised again"
);
}
@@ -11128,7 +10945,7 @@ mod tests {
.await
.unwrap()
.iter()
.any(|r| r.cid.as_deref() == Some(low_raw.as_str())),
.any(|r| r.cid == low_raw),
"the repaired row is advertised again"
);
}
@@ -14722,14 +14539,9 @@ mod tests {
/// resolver would withhold. The resolver recomputes the raw CIDv1 from the object
/// bytes and 404s any row keyed on a legacy PROVIDER CID, so advertising that key
/// hands clients a CID this node deliberately refuses. Both states of ONE row are
/// asserted (listed while legacy, still listed once repaired) so the test
/// cannot pass by accident. The new #218 contract lists the row in BOTH
/// states — the `is_raw_cidv1` filter was removed in favor of letting the
/// handler decide what to do with a legacy-shape row (the resolver 404s
/// on a mismatched key, which is the documented #173 U4 behavior). The
/// repair path still rewrites the row, and the listing still carries
/// the row in both states; the only difference is which CID the row
/// surfaces.
/// asserted (omitted while legacy, present once repaired) so the test cannot pass
/// by accident. RED before the `is_raw_cidv1` filter lands: the legacy row is
/// advertised.
#[sqlx::test]
async fn list_pinned_cids_omits_unrepaired_legacy_row(pool: PgPool) {
let state = test_state(pool).await;
@@ -14745,16 +14557,12 @@ mod tests {
.unwrap();
let listed = state.db.list_pinned_cids().await.unwrap();
// #218: the row IS listed with its legacy key. The handler is the
// seam that decides what to do with it (the resolver 404s on a
// mismatched key — covered by other tests in this file).
let rec = listed
.iter()
.find(|r| r.sha256_hex == oid)
.expect("an unrepaired legacy row is still listed (#218 contract)");
assert_eq!(rec.cid.as_deref(), Some(provider_cid.as_str()));
assert!(
!listed.iter().any(|r| r.sha256_hex == oid),
"an unrepaired legacy provider-CID row is not advertised"
);
// Same row, repaired: it stays listed but the key is now the raw CID.
// Same row, repaired: it comes back, keyed on the raw CID the resolver serves.
state
.db
.repair_legacy_provider_cid(&oid, &raw_cid, &provider_cid)
@@ -14766,8 +14574,7 @@ mod tests {
.find(|r| r.sha256_hex == oid)
.expect("the repaired row is advertised again");
assert_eq!(
rec.cid,
Some(raw_cid),
rec.cid, raw_cid,
"the advertised key is the raw-content resolver key"
);
}
@@ -15637,15 +15444,7 @@ mod tests {
"reader's tree body carries the child filename and raw child oid"
);
// Root tree (path "/") is denied to anon: under #218 review
// P1b's recursive structural gate, the root tree's serialized
// bytes name the `secret` entry and the secret subtree's
// child OID, so admitting it would leak the secret subtree's
// existence through the root tree's bytes. The root tree is
// a *path-scoped* deny too — the `/secret/**` rule matches
// `secret` at depth 1. The reader below confirms a structural
// leak: the listed reader can read the *secret subtree* and
// its tree body carries `b.txt` plus the raw secret oid.
// Root tree (path "/") stays served to anon who passes the "/" gate.
let (st, _) = cid_parts(
cid_router(&state)
.oneshot(cid_anon(&root_tree_cid))
@@ -15653,11 +15452,7 @@ mod tests {
.unwrap(),
)
.await;
assert_eq!(
st,
StatusCode::NOT_FOUND,
"root tree denied to anon: its entries name the withheld /secret subtree"
);
assert_eq!(st, StatusCode::OK, "root tree stays served (must-serve)");
// /public subtree tree stays served to anon (allowed path).
let (st, _) = cid_parts(
@@ -15847,18 +15642,106 @@ mod tests {
assert!(body.contains("public bytes"), "owner gets the content");
}
/// #218 review P1: the previous `ipfs_cid_walk_error_fails_closed`
/// test relied on a ref pointing at a blob to force
/// `withheld_blob_oids` to error via the pre-fix
/// `assert_all_refs_are_commits` guard. With the guard removed
/// (a ref pointing at a non-commit object is a valid Git shape
/// that `git rev-list --all` silently skips), the trigger is
/// gone. The fail-closed arm is still covered by other tests
/// (e.g. `ipfs_cid_commit_tag_walk_error_fails_closed` below uses
/// a different trigger: a nonexistent ref object makes
/// `rev-list --all` fail, exercising the same shared
/// `Ok(Err) => continue` arm). The shared arm is exercised
/// here; the redundant blob-path trigger is removed.
/// Fail-closed walk-error arm: if `withheld_blob_oids` errors (here, a ref
/// pointing at a non-tree-ish blob, which `git ls-tree -r` cannot traverse —
/// the same induction as `visibility_pack::fails_closed_when_a_ref_cannot_be_traversed`),
/// the handler skips the whole repo rather than serving. Asserts no leak of the
/// withheld blob AND that even the *public* blob in that repo is withheld — the
/// latter distinguishes fail-closed-skip from normal per-blob withholding and
/// would serve 200 if the error arm wrongly proceeded. The skip carries no
/// VERDICT (F2), so the response is the retryable truncation 503, not a 404
/// claiming the object is absent — never-serve-unproven and never-404-unproven
/// hold together.
#[sqlx::test]
async fn ipfs_cid_walk_error_fails_closed(pool: PgPool) {
use crate::db::VisibilityMode;
use gitlawb_core::identity::Keypair;
let owner = Keypair::generate();
let owner_did = owner.did().to_string();
let slug = owner_did.replace([':', '/'], "_");
let short = owner_did.split(':').next_back().unwrap().to_string();
let state = test_state(pool).await;
let fx = seed_cid_repos(&slug, &short, &["withhold"]);
let bare = std::path::PathBuf::from("/tmp")
.join(&slug)
.join("withhold.git");
// Recorded pins so get_by_cid resolves each CID to its oid and reaches the
// walk; the 404s below are then the fail-closed skip, not a table miss.
let secret_cid = pin_cid_for(&bare, &fx.secret_oid, &state.db).await;
let public_cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
// Force the withheld walk to fail closed: a ref pointing at a blob (not
// tree-ish) makes `git ls-tree -r` error, which `withheld_blob_oids`
// propagates as Err → the handler's `Ok(Err)` arm skips the repo.
std::fs::write(
bare.join("refs/heads/blobref"),
format!("{}\n", fx.secret_oid),
)
.unwrap();
state
.db
.create_repo(&seed_repo(&owner_did, "withhold"))
.await
.expect("seed repo");
let rec = state
.db
.get_repo(&owner_did, "withhold")
.await
.unwrap()
.unwrap();
state
.db
.set_visibility_rule(&rec.id, "/secret/**", VisibilityMode::B, &[], &owner_did)
.await
.expect("deny rule");
// Withheld secret CID under a walk error → the repo is skipped without a
// verdict, so the scan is truncated (503), and nothing leaks.
let (st, body) = cid_parts(
cid_router(&state)
.oneshot(cid_anon(&secret_cid))
.await
.unwrap(),
)
.await;
assert_eq!(
st,
StatusCode::SERVICE_UNAVAILABLE,
"walk error must not serve the withheld blob — the unproven skip sheds 503"
);
assert!(
!body.contains("TOP SECRET"),
"walk-error 503 must not leak the secret"
);
// The PUBLIC blob in the same repo is also not served: the walk error fails
// closed by skipping the whole repo. Without the fail-closed arm this would
// serve 200, so this assertion is the load-bearing discriminator.
let (st, _) = cid_parts(
cid_router(&state)
.oneshot(cid_anon(&public_cid))
.await
.unwrap(),
)
.await;
assert_eq!(
st,
StatusCode::SERVICE_UNAVAILABLE,
"walk error fails closed: repo skipped without a verdict, even the public \
blob is not served and the scan sheds 503"
);
}
/// #173 review (F2): the commit/tag reachability walk must FAIL CLOSED on a git
/// error, exactly like the blob/tree walk. A ref pointing at a nonexistent object
/// makes `rev-list --all` fail, so `reachable_commit_tag_oids` returns Err, which
/// the handler's shared `Ok(Err) => continue` arm turns into a repo skip. The
/// load-bearing discriminator is that the PUBLIC commit is ALSO 404: if the arm
/// fail-OPENed (served on error) it would 200. Drives the commit/tag branch of
/// the shared fail-closed arm specifically (the sibling test covers blob/tree).
#[sqlx::test]
async fn ipfs_cid_commit_tag_walk_error_fails_closed(pool: PgPool) {
use crate::db::VisibilityMode;
@@ -16448,29 +16331,10 @@ mod tests {
.await
.expect("path rule");
// Reachable trees at ALLOWED paths must still serve despite the
// tag-of-tree. Under #218 review P1b's recursive structural
// gate, anon sees ONLY trees whose structural safety holds at
// the caller's path: the public subtree (`/public`) is safe
// (its only entry is a blob at an allowed path). The root
// tree is NOT safe for anon: its entries include `secret/`
// pointing at a denied subtree, so the structural check
// denies the root tree. The test asserts the public subtree
// serves and the root tree is denied (fail-closed on
// subtree metadata leakage).
for (cid, want_oid, label, want_status) in [
(
&root_tree_cid,
&fx.root_tree_oid,
"root tree",
StatusCode::NOT_FOUND,
),
(
&public_tree_cid,
&fx.public_tree_oid,
"public subtree",
StatusCode::OK,
),
// Reachable trees at ALLOWED paths must still serve despite the tag-of-tree.
for (cid, want_oid, label) in [
(&root_tree_cid, &fx.root_tree_oid, "root tree"),
(&public_tree_cid, &fx.public_tree_oid, "public subtree"),
] {
let resp = cid_router(&state).oneshot(cid_anon(cid)).await.unwrap();
let served = resp
@@ -16480,17 +16344,15 @@ mod tests {
.map(str::to_string);
let (st, _) = cid_parts(resp).await;
assert_eq!(
st, want_status,
"{label} CID status under recursive structural gate: root tree is denied \
because its serialized bytes name the withheld /secret subtree, /public is served"
st,
StatusCode::OK,
"{label} CID must serve despite a pushable tag-of-tree in the repo"
);
assert_eq!(
served.as_deref(),
Some(want_oid.as_str()),
"{label}: the served object is the reachable tree"
);
if want_status == StatusCode::OK {
assert_eq!(
served.as_deref(),
Some(want_oid.as_str()),
"{label}: the served object is the reachable tree"
);
}
}
// Fail-closed preserved: the DENIED subtree's CID is still withheld — the
+8 -178
View File
@@ -127,93 +127,19 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
println!("IPFS pins ({count}) on {node}");
println!();
for pin in &pins {
for line in render_pin(pin) {
println!("{line}");
}
let cid = pin["cid"].as_str().unwrap_or("?");
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
// Trim pinned_at to date+time without subseconds
let ts = crate::text::truncate(pinned_at, 19);
println!(" {cid}");
println!(" sha256: {sha}");
println!(" pinned: {ts}");
println!();
}
Ok(())
}
/// The rendered lines for one pins-listing entry.
///
/// #218 review round 8 P2 — why this is not just `pin["cid"]`: the wire format
/// gained a provenance split, and `cid` is now NULLABLE. The node stopped
/// aliasing a Pinata provider CID into `cid` (round 3) because `cid` is the
/// key `GET /ipfs/{cid}` resolves against, and a Pinata provider CID is not
/// resolvable there — advertising one would send clients to a guaranteed 404.
/// The truthful representation of a Pinata-only row is therefore `cid: null`
/// plus a `pinata_cid`. This function was reading only `cid`, so such a row
/// printed as a bare `?` — the CLI showed nothing usable for an object that is
/// in fact durably stored, and nothing in the client read `pinata_cid` at all.
///
/// So: the heading is the node-resolvable CID when there is one, and otherwise
/// the Pinata provider CID, labelled as such so nobody feeds it back to this
/// node's resolver. A `backends:` line names where the bytes actually are,
/// taken from the writer-owned `local_pinned` / `pinata_pinned` booleans rather
/// than re-inferred from CID shape (which is what the node's own comment warns
/// against). Older nodes send neither flag; their `cid`-only rows fall back to
/// CID presence and render exactly as they did before.
fn render_pin(pin: &Value) -> Vec<String> {
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
// Trim pinned_at to date+time without subseconds; truncate() is
// boundary-safe (a raw &s[..19] panics on a split code point).
let ts = if pinned_at.len() >= 19 {
crate::text::truncate(pinned_at, 19)
} else {
pinned_at
};
// `cid` and `local_cid` carry the same raw CID; read both so a node that
// ships only one of them still renders.
let local_cid = pin["cid"]
.as_str()
.or_else(|| pin["local_cid"].as_str())
.filter(|s| !s.is_empty());
let pinata_cid = pin["pinata_cid"].as_str().filter(|s| !s.is_empty());
// Writer-owned flags when present; CID presence is the pre-split fallback.
let local_pinned = pin["local_pinned"]
.as_bool()
.unwrap_or_else(|| local_cid.is_some());
let pinata_pinned = pin["pinata_pinned"]
.as_bool()
.unwrap_or_else(|| pinata_cid.is_some());
let mut lines = Vec::new();
match (local_cid, pinata_cid) {
// Node-resolvable CID present: it leads, as it always has.
(Some(cid), _) => lines.push(format!(" {cid}")),
// Pinata-only: show the provider CID rather than "?", and say plainly
// that this node's resolver will not serve it.
(None, Some(p)) => lines.push(format!(" {p} (pinata provider CID)")),
(None, None) => lines.push(" ?".to_string()),
}
lines.push(format!(" sha256: {sha}"));
lines.push(format!(" pinned: {ts}"));
// A dual row keeps the provider CID visible too; it differs from the local
// CID and is the only key that resolves on Pinata's gateway.
if local_cid.is_some() {
if let Some(p) = pinata_cid {
lines.push(format!(" pinata: {p}"));
}
}
let backends = match (local_pinned, pinata_pinned) {
(true, true) => "local ipfs, pinata",
(true, false) => "local ipfs",
(false, true) => "pinata",
// Neither flag set: the node filters such rows out, so this is only
// reachable from a hand-rolled response. Say so rather than imply
// durability the row does not claim.
(false, false) => "none recorded",
};
lines.push(format!(" backends: {backends}"));
lines
}
/// Automatic resumes attempted after the initial request when the node reports a
/// truncated legacy scan, so at most `MAX_SCAN_RESUMES + 1` node calls per invocation.
const MAX_SCAN_RESUMES: usize = 8;
@@ -707,102 +633,6 @@ mod tests {
dir
}
/// #218 review round 8 P2: a Pinata-only row must render its provider CID,
/// not a bare `?`. The node stopped aliasing that CID into `cid` because
/// `cid` is the key `GET /ipfs/{cid}` resolves on and a provider CID 404s
/// there — so `cid` is now legitimately null for such a row, and a client
/// that reads only `cid` shows the user nothing for an object that IS
/// durably stored. The heading must carry the provider CID and label it,
/// and the row must not claim a local pin.
#[test]
fn render_pin_shows_provider_cid_for_a_pinata_only_row() {
let pin: Value = serde_json::from_str(
r#"{"sha256_hex":"abc123","cid":null,"local_cid":null,
"pinata_cid":"QmProviderOnly","local_pinned":false,
"pinata_pinned":true,"pinned_at":"2026-07-02T12:00:00.123456Z"}"#,
)
.unwrap();
let out = render_pin(&pin).join("\n");
assert!(
out.contains("QmProviderOnly"),
"the provider CID must be shown; got:\n{out}"
);
assert!(
!out.contains(" ?"),
"a Pinata-only row must not render as a bare `?`; got:\n{out}"
);
assert!(
out.contains("backends: pinata"),
"the row must say where the bytes are; got:\n{out}"
);
assert!(
out.contains("pinata provider CID"),
"the heading must be labelled so it is not fed back to this node's \
resolver, which cannot serve it; got:\n{out}"
);
}
/// A dual row leads with the node-resolvable CID (unchanged behavior) and
/// additionally surfaces the provider CID, which is a different key and the
/// only one Pinata's gateway answers for.
#[test]
fn render_pin_shows_both_cids_for_a_dual_row() {
let pin: Value = serde_json::from_str(
r#"{"sha256_hex":"abc123","cid":"bafyLocal","local_cid":"bafyLocal",
"pinata_cid":"QmProvider","local_pinned":true,
"pinata_pinned":true,"pinned_at":"2026-07-02T12:00:00.123456Z"}"#,
)
.unwrap();
let out = render_pin(&pin).join("\n");
assert!(
out.starts_with(" bafyLocal"),
"the resolver key leads; got:\n{out}"
);
assert!(
out.contains("pinata: QmProvider"),
"the provider CID must still be reachable from the listing; got:\n{out}"
);
assert!(out.contains("backends: local ipfs, pinata"), "got:\n{out}");
}
/// Backward compatibility: a pre-provenance node sends `cid` alone, with no
/// `local_pinned` / `pinata_pinned` flags. That row must render as it always
/// did, with the backends line inferred from CID presence.
#[test]
fn render_pin_handles_a_legacy_cid_only_row() {
let pin: Value = serde_json::from_str(
r#"{"sha256_hex":"abc123","cid":"bafyone",
"pinned_at":"2026-07-02T12:00:00.123456Z"}"#,
)
.unwrap();
let out = render_pin(&pin).join("\n");
assert!(out.starts_with(" bafyone"), "got:\n{out}");
assert!(out.contains("sha256: abc123"), "got:\n{out}");
assert!(
out.contains("pinned: 2026-07-02T12:00:00"),
"the timestamp is still trimmed to seconds; got:\n{out}"
);
assert!(out.contains("backends: local ipfs"), "got:\n{out}");
}
/// A node-supplied timestamp whose 19th byte splits a multi-byte code
/// point must not panic the CLI — `&s[..19]` panics there; `truncate`
/// returns the boundary-safe prefix.
#[test]
fn render_pin_does_not_panic_on_a_non_boundary_timestamp() {
let mut ts = "2026-07-02T12:00:0".to_string();
ts.push('é'); // 2-byte char starting at byte 18: byte 19 is its tail
assert_eq!(ts.len(), 20);
assert!(std::panic::catch_unwind(|| &ts[..19]).is_err());
let pin: Value = serde_json::json!({
"sha256_hex":"abc123","cid":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4",
"local_cid":null,"pinata_cid":null,"local_pinned":true,
"pinata_pinned":false,"pinned_at":ts,
});
let out = render_pin(&pin).join("\n");
assert!(out.contains("pinned: 2026-07-02T12:00:0"), "got:\n{out}");
}
#[tokio::test]
async fn test_cmd_list_signs_request_and_renders_pins() {
let mut server = mockito::Server::new_async().await;
+4 -11
View File
@@ -108,18 +108,11 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
println!(" ✓ Registered successfully");
println!(" Trust score: {trust:.2}");
println!(" UCAN saved to {}", ucan_path.display());
} else {
// A 2xx without a usable ucan is not a registration; the
// response may not even be JSON. Saying success here would
// also leave a stale ucan.json from another node in place.
println!(" ✗ Registration returned no UCAN (unexpected response body)");
// No retry hint here: a `gl register` instruction without
// this invocation's --dir would load a different identity.
}
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
println!(" ✓ Registered successfully");
println!(" Trust score: {trust:.2}");
println!(" UCAN saved to {}", ucan_path.display());
println!();
}
Ok(resp) => {
-1
View File
@@ -91,7 +91,6 @@ Required env for on-chain PoS mode:
Optional:
- `GITLAWB_OPERATOR_STRICT_MODE=true` — refuse to start if not registered or not currently active
- `GITLAWB_HEARTBEAT_INTERVAL_HOURS=20` — how often to post heartbeats (must be < 24)
- `GITLAWB_RECONCILIATION_SWEEP=true` — enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend. Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. Set `=false` to disable.
## 5. Verify