mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 13:31:25 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1c96f6f1 | ||
|
|
10d5b98b2b | ||
|
|
a2262d92b2 |
No files matched your search
@@ -21,6 +21,25 @@ 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
|
||||
@@ -39,4 +58,16 @@ 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)
|
||||
]
|
||||
@@ -359,6 +359,20 @@ 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.
|
||||
|
||||
@@ -86,3 +86,27 @@ 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
@@ -3565,6 +3565,7 @@ dependencies = [
|
||||
"multiaddr",
|
||||
"percent-encoding",
|
||||
"prometheus",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -404,6 +404,7 @@ 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. |
|
||||
|
||||
@@ -539,6 +539,43 @@ 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
|
||||
|
||||
@@ -77,6 +77,14 @@ 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
|
||||
@@ -208,6 +216,38 @@ 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
|
||||
|
||||
@@ -75,6 +75,7 @@ alloy = { version = "1", default-features = false, features = [
|
||||
"network",
|
||||
"rpc-types-eth",
|
||||
] }
|
||||
rand = { workspace = true }
|
||||
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -2130,9 +2130,32 @@ async fn gate_and_serve(
|
||||
|
||||
/// GET /api/v1/ipfs/pins
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub async fn list_pins(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
@@ -2146,6 +2169,38 @@ 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(),
|
||||
@@ -2459,6 +2514,369 @@ 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]
|
||||
|
||||
@@ -1490,14 +1490,17 @@ async fn pin_new_objects_gated(
|
||||
db: &Arc<crate::db::Db>,
|
||||
repo_id: &str,
|
||||
batch_budget: std::time::Duration,
|
||||
) -> Vec<(String, String)> {
|
||||
) -> crate::ipfs_pin::PinBatchOutcome {
|
||||
// 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 Vec::new();
|
||||
return crate::ipfs_pin::PinBatchOutcome {
|
||||
confirmed: Vec::new(),
|
||||
last_attempted: None,
|
||||
};
|
||||
}
|
||||
let _permit = pin_sem
|
||||
.clone()
|
||||
@@ -1513,6 +1516,7 @@ async fn pin_new_objects_gated(
|
||||
db,
|
||||
repo_id,
|
||||
batch_budget,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1547,9 +1551,9 @@ async fn pin_and_encrypt_objects(
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
)
|
||||
.await;
|
||||
if !pinned.is_empty() {
|
||||
tracing::info!(count = pinned.len(), "pinned git objects to IPFS");
|
||||
for (sha, cid) in &pinned {
|
||||
if !pinned.confirmed.is_empty() {
|
||||
tracing::info!(count = pinned.confirmed.len(), "pinned git objects to IPFS");
|
||||
for (sha, cid) in &pinned.confirmed {
|
||||
tracing::info!(sha = %sha, %cid, "pinned");
|
||||
}
|
||||
}
|
||||
@@ -1581,7 +1585,14 @@ 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;
|
||||
|
||||
@@ -3056,19 +3067,34 @@ 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, Vec::new())
|
||||
(
|
||||
false,
|
||||
crate::ipfs_pin::PinBatchOutcome {
|
||||
confirmed: Vec::new(),
|
||||
last_attempted: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
if !pinned.is_empty() {
|
||||
tracing::info!(count = pinned.len(), "pinned git objects to Pinata");
|
||||
if !pinned.confirmed.is_empty() {
|
||||
tracing::info!(
|
||||
count = pinned.confirmed.len(),
|
||||
"pinned git objects to Pinata"
|
||||
);
|
||||
}
|
||||
|
||||
// Build sha→cid map from pinned objects
|
||||
let cid_map: std::collections::HashMap<String, String> = pinned.into_iter().collect();
|
||||
// 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();
|
||||
|
||||
// Record branch→CID for each ref update and publish gossip
|
||||
for (ref_name, old_sha, new_sha) in &ref_updates_clone {
|
||||
@@ -5944,7 +5970,13 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert!(replicable.contains(&head), "HEAD commit kept");
|
||||
assert!(replicable.contains(&root_tree), "root tree 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(&public_tree), "public tree kept");
|
||||
assert!(replicable.contains(&public_blob), "public blob kept");
|
||||
assert!(
|
||||
@@ -6130,9 +6162,21 @@ 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 refs/heads/main ;;\n \
|
||||
for-each-ref) echo {commit} commit ;;\n \
|
||||
cat-file) echo commit ;;\n \
|
||||
rev-parse) echo {commit} ;;\n \
|
||||
rev-list) echo {commit} ;;\n \
|
||||
@@ -9062,7 +9106,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("the pin loop completes once admission frees");
|
||||
assert!(out.is_empty(), "an empty ipfs_api pins nothing");
|
||||
assert!(out.confirmed.is_empty(), "an empty ipfs_api pins nothing");
|
||||
}
|
||||
|
||||
/// #173 F3, at the layer that actually owns the permit. `pin_new_objects_gated`
|
||||
@@ -9074,8 +9118,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 `vec![]` 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 an empty outcome 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.
|
||||
@@ -9137,7 +9181,10 @@ mod tests {
|
||||
budget",
|
||||
);
|
||||
|
||||
assert!(out.is_empty(), "a stalled pinned-status check pins nothing");
|
||||
assert!(
|
||||
out.confirmed.is_empty(),
|
||||
"a stalled pinned-status check pins nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
pin_sem.available_permits(),
|
||||
1,
|
||||
@@ -9184,7 +9231,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("an empty object list must not wait on pin admission (#174 F2b)");
|
||||
assert!(out.is_empty(), "and it pins nothing");
|
||||
assert!(out.confirmed.is_empty(), "and it pins nothing");
|
||||
assert_eq!(
|
||||
pin_sem.available_permits(),
|
||||
0,
|
||||
@@ -12246,6 +12293,19 @@ 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) {
|
||||
@@ -12279,8 +12339,14 @@ 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, "for-each-ref") {
|
||||
while !p2_logged(&log, "rev-list") {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"RED: the pack landed but its replication tail never ran. A disconnect \
|
||||
@@ -12349,8 +12415,14 @@ 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, "for-each-ref"),
|
||||
!p2_logged(&log, "rev-list"),
|
||||
"a failed receive-pack must spawn no replication tail: pinning and \
|
||||
announcing a half-applied repo is exactly what release(false) refuses \
|
||||
to upload"
|
||||
|
||||
@@ -152,6 +152,17 @@ 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 = "")]
|
||||
|
||||
+1923
-42
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@
|
||||
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;
|
||||
@@ -127,17 +128,60 @@ 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;
|
||||
for (oid, dids) in recipients {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
@@ -188,7 +232,9 @@ pub async fn encrypt_and_pin(
|
||||
}
|
||||
SealPlan::Seal { keys, tag } => (keys, tag),
|
||||
};
|
||||
let data = match crate::git::store::read_object(repo_path, oid) {
|
||||
let data = match read_object_bounded_spawn_blocking(git_bin, repo_path, oid, read_deadline)
|
||||
.await
|
||||
{
|
||||
Ok(Some((_t, bytes))) => bytes,
|
||||
Ok(None) => {
|
||||
tracing::warn!(oid = %oid, "git object not found; skipping encrypted pin");
|
||||
@@ -206,6 +252,22 @@ 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(_) => {
|
||||
@@ -237,10 +299,33 @@ 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
|
||||
@@ -475,4 +560,304 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -185,10 +185,13 @@ 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 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.
|
||||
/// 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.
|
||||
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,
|
||||
@@ -214,6 +217,7 @@ 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,
|
||||
@@ -261,6 +265,7 @@ 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,
|
||||
@@ -303,10 +308,12 @@ 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, 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
|
||||
/// 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.
|
||||
///
|
||||
/// `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
|
||||
|
||||
@@ -273,6 +273,9 @@ 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])
|
||||
@@ -305,6 +308,7 @@ 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])
|
||||
@@ -737,6 +741,7 @@ 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,
|
||||
@@ -2103,8 +2108,16 @@ 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};
|
||||
|
||||
@@ -627,7 +627,9 @@ impl TigrisClient {
|
||||
}
|
||||
|
||||
/// Compress a bare repo directory into a tar.zst byte vector.
|
||||
fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
/// `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>> {
|
||||
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
@@ -922,6 +922,12 @@ 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) {
|
||||
@@ -1457,6 +1463,97 @@ 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
|
||||
@@ -1508,6 +1605,7 @@ 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('/')
|
||||
@@ -1550,6 +1648,15 @@ 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
|
||||
@@ -1565,13 +1672,26 @@ 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()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"IPFS /api/v0/add returned 200 without a usable Hash: {:.200}",
|
||||
body
|
||||
)
|
||||
})?;
|
||||
.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?)"
|
||||
));
|
||||
}
|
||||
};
|
||||
// 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| {
|
||||
@@ -1654,8 +1774,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 `is_pinned` round-trip
|
||||
/// sitting between the two would push past it), with SIGTERM-then-SIGKILL
|
||||
/// 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
|
||||
/// 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;
|
||||
@@ -1699,10 +1819,11 @@ 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. 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.
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
@@ -1719,6 +1840,29 @@ 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
|
||||
@@ -1733,16 +1877,34 @@ pub async fn pin_new_objects(
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
batch_budget: Duration,
|
||||
) -> Vec<(String, String)> {
|
||||
fence: Option<&PolicyFence>,
|
||||
) -> PinBatchOutcome {
|
||||
if ipfs_api.is_empty() {
|
||||
return vec![];
|
||||
return PinBatchOutcome {
|
||||
confirmed: Vec::new(),
|
||||
last_attempted: None,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1751,19 +1913,40 @@ pub async fn pin_new_objects(
|
||||
if batch_budget_gate("IPFS", deadline, pinned.len(), total - attempted).is_none() {
|
||||
break;
|
||||
}
|
||||
// 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 {
|
||||
// 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 {
|
||||
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`
|
||||
@@ -1873,7 +2056,7 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking pinned status");
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking IPFS pinned status");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1887,7 +2070,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 `is_pinned` round-trip above sits
|
||||
// measured at the top of the iteration: the `has_ipfs_cid` 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.
|
||||
@@ -1972,6 +2155,24 @@ 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() => {
|
||||
@@ -2021,7 +2222,31 @@ pub async fn pin_new_objects(
|
||||
// per-object failure.
|
||||
match db_bounded(
|
||||
db_record_deadline(deadline),
|
||||
retry_db_record(|| db.record_pinned_cid_with_source(&sha, &raw_cid, repo_id)),
|
||||
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,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2038,7 +2263,10 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
}
|
||||
|
||||
pinned
|
||||
PinBatchOutcome {
|
||||
confirmed: pinned,
|
||||
last_attempted,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2374,6 +2602,74 @@ 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() {
|
||||
@@ -2486,15 +2782,16 @@ 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.len()),
|
||||
(1..=3).contains(&pinned.confirmed.len()),
|
||||
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
|
||||
pinned.len()
|
||||
pinned.confirmed.len()
|
||||
);
|
||||
let text = logs.text();
|
||||
let warns: Vec<&str> = text
|
||||
@@ -2516,9 +2813,52 @@ mod tests {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
|
||||
assert!(
|
||||
unattempted >= 1 && unattempted + pinned.len() <= 5,
|
||||
unattempted >= 1 && unattempted + pinned.confirmed.len() <= 5,
|
||||
"unattempted={unattempted} with {} pinned is not a partial batch of five",
|
||||
pinned.len()
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2553,12 +2893,13 @@ 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.len(),
|
||||
pinned.confirmed.len(),
|
||||
2,
|
||||
"a slow but progressing endpoint must pin both objects: an upload past the client's \
|
||||
10s default is not a dead endpoint"
|
||||
@@ -2591,11 +2932,12 @@ 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.is_empty(), "every add was rejected");
|
||||
assert!(pinned.confirmed.is_empty(), "every add was rejected");
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
4,
|
||||
@@ -2689,6 +3031,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -2699,7 +3042,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -2776,6 +3119,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -2790,7 +3134,7 @@ mod tests {
|
||||
that can only be spawned and reaped"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
pinned.confirmed.len(),
|
||||
1,
|
||||
"the first object is inside the budget and must pin: {pinned:?}"
|
||||
);
|
||||
@@ -2849,6 +3193,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -2858,7 +3203,7 @@ mod tests {
|
||||
|
||||
if genuinely_unreadable {
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"nothing can be pinned through a store that cannot be read: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2923,6 +3268,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -2938,7 +3284,7 @@ mod tests {
|
||||
must still be attempted, got {attempted} of {}",
|
||||
oids.len()
|
||||
);
|
||||
let pinned_shas: Vec<&String> = pinned.iter().map(|(sha, _)| sha).collect();
|
||||
let pinned_shas: Vec<&String> = pinned.confirmed.iter().map(|(sha, _)| sha).collect();
|
||||
let expected: Vec<&String> = oids.iter().filter(|o| !tainted.contains(o)).collect();
|
||||
assert_eq!(
|
||||
pinned_shas, expected,
|
||||
@@ -2991,6 +3337,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3002,7 +3349,7 @@ mod tests {
|
||||
"an object-scoped fault must not stop the batch: every object must be read"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
pinned.confirmed.len(),
|
||||
4,
|
||||
"one corrupt object must cost only itself: the other four must still pin"
|
||||
);
|
||||
@@ -3225,6 +3572,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-stalled-db",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3235,7 +3583,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"a stalled pinned-status check cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -3295,6 +3643,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-skip-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3305,7 +3654,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"an already-pinned object is skipped, never re-pinned: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -3361,13 +3710,17 @@ 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.is_empty(), "nothing can pin against a stalled DB");
|
||||
assert!(
|
||||
pinned.confirmed.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:?}"
|
||||
@@ -3390,7 +3743,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.len() == 1` for a reason that has
|
||||
/// and break the batch, which fails on `pinned.confirmed.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]
|
||||
@@ -3428,6 +3781,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-spent-budget",
|
||||
Duration::from_millis(2000),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, locker);
|
||||
@@ -3440,7 +3794,7 @@ mod tests {
|
||||
nothing can resolve the CID"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
pinned.confirmed.len(),
|
||||
1,
|
||||
"the durably recorded pin must be returned: {pinned:?}"
|
||||
);
|
||||
@@ -3501,6 +3855,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-definite-error",
|
||||
Duration::from_millis(1200),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, commit);
|
||||
@@ -3582,6 +3937,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-marker-floor",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, controller);
|
||||
@@ -3592,7 +3948,7 @@ mod tests {
|
||||
drop(sources_lock);
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"an already-pinned object is skipped, never re-pinned: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -3686,6 +4042,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-repair-stalled",
|
||||
Duration::from_millis(2200),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, mut cids_lock) = tokio::join!(pin, controller);
|
||||
|
||||
@@ -18,6 +18,7 @@ mod operator;
|
||||
mod p2p;
|
||||
mod pinata;
|
||||
mod rate_limit;
|
||||
mod reconciliation;
|
||||
mod server;
|
||||
mod state;
|
||||
mod sync;
|
||||
@@ -666,6 +667,30 @@ 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()
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
//! `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`, ...)
|
||||
@@ -58,6 +61,8 @@ 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
|
||||
@@ -241,6 +246,29 @@ 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",
|
||||
@@ -397,6 +425,20 @@ 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() {
|
||||
@@ -441,6 +483,8 @@ 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!(
|
||||
@@ -455,6 +499,14 @@ 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
|
||||
|
||||
@@ -153,6 +153,26 @@ 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
|
||||
@@ -170,16 +190,33 @@ pub async fn pin_new_objects(
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
batch_budget: Duration,
|
||||
) -> Vec<(String, String)> {
|
||||
fence: Option<&crate::ipfs_pin::PolicyFence>,
|
||||
) -> crate::ipfs_pin::PinBatchOutcome {
|
||||
if jwt.is_empty() {
|
||||
return vec![];
|
||||
return crate::ipfs_pin::PinBatchOutcome {
|
||||
confirmed: Vec::new(),
|
||||
last_attempted: None,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
@@ -191,6 +228,10 @@ 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
|
||||
@@ -395,6 +436,21 @@ 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
|
||||
@@ -410,30 +466,109 @@ 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 is UNCONDITIONAL, so
|
||||
// `api/repos.rs` builds its `cid_map` from the pair either way and drives
|
||||
// this side than on the twin: `pinned.push` below feeds
|
||||
// `api/repos.rs`, which builds its `cid_map` from the pair 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. 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(
|
||||
// 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(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
crate::ipfs_pin::retry_db_record(|| {
|
||||
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id))
|
||||
// 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)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB");
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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)),
|
||||
@@ -445,9 +580,7 @@ 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. 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.
|
||||
// and unmarked.
|
||||
Err(e @ crate::ipfs_pin::BoundedDbError::Elapsed) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
@@ -464,6 +597,16 @@ 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");
|
||||
@@ -475,9 +618,21 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
pinned.push((sha, cid));
|
||||
if db_record_durable {
|
||||
pinned.push((sha, cid));
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -486,7 +641,10 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
}
|
||||
|
||||
pinned
|
||||
crate::ipfs_pin::PinBatchOutcome {
|
||||
confirmed: pinned,
|
||||
last_attempted,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -688,19 +846,20 @@ 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.len()),
|
||||
(1..=3).contains(&pinned.confirmed.len()),
|
||||
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
|
||||
pinned.len()
|
||||
pinned.confirmed.len()
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
pinned.len(),
|
||||
pinned.confirmed.len(),
|
||||
"no upload may be issued for an object the budget stopped short of"
|
||||
);
|
||||
let text = logs.text();
|
||||
@@ -728,9 +887,9 @@ mod tests {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
|
||||
assert!(
|
||||
unattempted >= 1 && unattempted + pinned.len() <= 5,
|
||||
unattempted >= 1 && unattempted + pinned.confirmed.len() <= 5,
|
||||
"unattempted={unattempted} with {} pinned is not a partial batch of five",
|
||||
pinned.len()
|
||||
pinned.confirmed.len()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -779,6 +938,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -789,7 +949,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -868,6 +1028,7 @@ mod tests {
|
||||
"repo-git-timeout",
|
||||
// Generous, so a call that ends on time ended on `git_timeout`.
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -878,7 +1039,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -970,6 +1131,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -979,7 +1141,7 @@ mod tests {
|
||||
|
||||
if genuinely_unreadable {
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"nothing can be pinned through a store that cannot be read: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1044,6 +1206,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1055,7 +1218,7 @@ mod tests {
|
||||
"an object-scoped fault must not stop the batch: every object must be read"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
pinned.confirmed.len(),
|
||||
4,
|
||||
"one corrupt object must cost only itself: the other four must still pin"
|
||||
);
|
||||
@@ -1095,13 +1258,18 @@ 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.len(), 3, "every healthy object must pin: {pinned:?}");
|
||||
for (i, (sha, cid)) in pinned.iter().enumerate() {
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
3,
|
||||
"every healthy object must pin: {pinned:?}"
|
||||
);
|
||||
for (i, (sha, cid)) in pinned.confirmed.iter().enumerate() {
|
||||
assert_eq!(sha, &oids[i], "the pairs must carry the objects' own oids");
|
||||
assert_eq!(cid, "QmPinataBatchTestCid");
|
||||
assert!(
|
||||
@@ -1124,11 +1292,15 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a fully deduped batch cannot take 30s");
|
||||
assert!(again.is_empty(), "already-recorded objects must be skipped");
|
||||
assert!(
|
||||
again.confirmed.is_empty(),
|
||||
"already-recorded objects must be skipped"
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
3,
|
||||
@@ -1136,6 +1308,63 @@ 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
|
||||
@@ -1177,12 +1406,13 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an unconfigured sink returns immediately");
|
||||
|
||||
assert!(pinned.is_empty(), "an empty JWT pins nothing");
|
||||
assert!(pinned.confirmed.is_empty(), "an empty JWT pins nothing");
|
||||
assert!(
|
||||
!log.exists(),
|
||||
"no git child may be spawned when the sink is not configured"
|
||||
@@ -1282,6 +1512,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1292,7 +1523,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"a stalled pinata-status check cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1338,9 +1569,15 @@ 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"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.record_pinata_cid(
|
||||
&sha,
|
||||
&raw_cid,
|
||||
"QmSeedProviderCid",
|
||||
Some("repo-seed"),
|
||||
i64::MAX,
|
||||
)
|
||||
.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(
|
||||
@@ -1372,6 +1609,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-skip-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1382,7 +1620,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"an already-pinned object is skipped, never re-uploaded: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1426,7 +1664,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.len() == 1` for a
|
||||
/// block, time out, and break the batch, which fails on `pinned.confirmed.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]
|
||||
@@ -1472,6 +1710,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-spent-budget",
|
||||
Duration::from_millis(2000),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, locker);
|
||||
@@ -1489,7 +1728,7 @@ mod tests {
|
||||
record makes api/repos.rs advertise a CID the resolver cannot serve"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.len(),
|
||||
pinned.confirmed.len(),
|
||||
1,
|
||||
"the uploaded pin must still be returned: {pinned:?}"
|
||||
);
|
||||
@@ -1557,6 +1796,7 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-post-upload",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1570,10 +1810,21 @@ 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.len(),
|
||||
pinned.confirmed.len(),
|
||||
1,
|
||||
"the upload succeeded, so this lane still returns the pair: {pinned:?}"
|
||||
"record_pin_source timed out (pin_repo_sources locked) but the primary row \
|
||||
covers this repo, so the pair stays confirmed: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(8),
|
||||
@@ -1590,6 +1841,203 @@ 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
@@ -186,6 +186,153 @@ 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::*;
|
||||
@@ -7180,6 +7327,7 @@ 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)
|
||||
@@ -7306,6 +7454,7 @@ mod tests {
|
||||
&state.db,
|
||||
&pub_repo.id,
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await; // /add NOT called (already pinned)
|
||||
@@ -7496,6 +7645,7 @@ mod tests {
|
||||
&state.db,
|
||||
repo_id,
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -8141,15 +8291,25 @@ mod tests {
|
||||
let repo = seed_repo(&owner_did, "u3pinata");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
// Already carries a pinata_cid, so pin_new_objects takes the skip branch and the
|
||||
// only DB write under test is the source record.
|
||||
// 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.
|
||||
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))
|
||||
.record_pinata_cid(
|
||||
&fx.public_oid,
|
||||
&raw_cid,
|
||||
"QmProvider",
|
||||
Some(&repo.id),
|
||||
i64::MAX,
|
||||
)
|
||||
.await
|
||||
.expect("seed pinata pin");
|
||||
|
||||
@@ -8186,6 +8346,7 @@ 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
|
||||
@@ -8357,6 +8518,7 @@ mod tests {
|
||||
"repoPinataBound",
|
||||
// The bound under test.
|
||||
std::time::Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -8424,6 +8586,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoKuboBound",
|
||||
std::time::Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -8486,6 +8649,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoPinataRepair",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -8544,6 +8708,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoPinataGate",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -8616,6 +8781,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoPinataWarn",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -8687,6 +8853,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoPinataNoSkip",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -8703,7 +8870,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(stashed, None, "and nothing is stashed for it");
|
||||
assert_eq!(
|
||||
pinned,
|
||||
pinned.confirmed,
|
||||
vec![(fx.public_oid.clone(), "QmPinataUploaded".to_string())],
|
||||
"the pinata return still carries the provider CID for the announcement cid_map"
|
||||
);
|
||||
@@ -9177,7 +9344,7 @@ mod tests {
|
||||
// raw CID in `cid` with the provider CID in `pinata_cid`.
|
||||
state
|
||||
.db
|
||||
.record_pinata_cid("po1", &raw1, "pcid1", Some("repoA"))
|
||||
.record_pinata_cid("po1", &raw1, "pcid1", Some("repoA"), i64::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -9192,7 +9359,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.find(|r| r.sha256_hex == "po1")
|
||||
.expect("po1 row exists");
|
||||
assert_eq!(po1.cid, raw1, "resolver-key cid is the raw CID");
|
||||
assert_eq!(po1.cid, Some(raw1), "resolver-key cid is the raw CID");
|
||||
assert_eq!(
|
||||
po1.pinata_cid.as_deref(),
|
||||
Some("pcid1"),
|
||||
@@ -9208,7 +9375,7 @@ mod tests {
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.record_pinata_cid("po2", "rawcid2", "pcid2", Some("repoB"))
|
||||
.record_pinata_cid("po2", "rawcid2", "pcid2", Some("repoB"), i64::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -9225,7 +9392,8 @@ mod tests {
|
||||
.find(|r| r.sha256_hex == "po2")
|
||||
.expect("po2 row exists");
|
||||
assert_eq!(
|
||||
po2.cid, local2,
|
||||
po2.cid,
|
||||
Some(local2),
|
||||
"on conflict the prior local pin's cid is left untouched"
|
||||
);
|
||||
|
||||
@@ -9237,7 +9405,7 @@ mod tests {
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.record_pinata_cid("po3", "rawcid3", "pcid3", Some("repoY"))
|
||||
.record_pinata_cid("po3", "rawcid3", "pcid3", Some("repoY"), i64::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -9269,7 +9437,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"))
|
||||
.record_pinata_cid("pfsha", &raw_cid, provider_cid, Some("repoP"), i64::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -9322,10 +9490,11 @@ mod tests {
|
||||
&state.db,
|
||||
"repoZ",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!pinned.is_empty(),
|
||||
!pinned.confirmed.is_empty(),
|
||||
"the object was pinned via the real pin path"
|
||||
);
|
||||
m.assert_async().await;
|
||||
@@ -9386,13 +9555,14 @@ 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.is_empty(),
|
||||
pinned.confirmed.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.
|
||||
@@ -9506,6 +9676,7 @@ mod tests {
|
||||
&db,
|
||||
"repoWedge",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -9519,7 +9690,7 @@ mod tests {
|
||||
not leave it running (which would pin the coalescing key until process death)"
|
||||
);
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"a wedged read pins nothing this pass; a later pass/push retries"
|
||||
);
|
||||
}
|
||||
@@ -9636,11 +9807,12 @@ mod tests {
|
||||
&state.db,
|
||||
"repoBF",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
pinned.is_empty(),
|
||||
pinned.confirmed.is_empty(),
|
||||
"an already-pinned object is not re-pinned (no bytes returned)"
|
||||
);
|
||||
m.assert_async().await; // asserts /add was called 0 times
|
||||
@@ -9712,9 +9884,17 @@ mod tests {
|
||||
);
|
||||
|
||||
// Legacy-shape row: cid = the PROVIDER CID (raw SQL — the helpers store the
|
||||
// raw CID). The object itself is public and servable.
|
||||
// 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).
|
||||
sqlx::query(
|
||||
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo_id) VALUES ($1, $2, $3, $4)",
|
||||
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, repo_id, local_ipfs_provenance) \
|
||||
VALUES ($1, $2, $3, $4, TRUE)",
|
||||
)
|
||||
.bind(&fx.public_oid)
|
||||
.bind(&provider_cid)
|
||||
@@ -9758,6 +9938,7 @@ mod tests {
|
||||
&state.db,
|
||||
&repo.id,
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -9862,6 +10043,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoCG",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
m.assert_async().await;
|
||||
@@ -9927,6 +10109,7 @@ mod tests {
|
||||
&state.db,
|
||||
"repoUR",
|
||||
crate::ipfs_pin::PIN_BATCH_BUDGET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -10132,7 +10315,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|r| r.cid == raw_cid),
|
||||
.any(|r| r.cid.as_deref() == Some(raw_cid.as_str())),
|
||||
"the repaired row is advertised"
|
||||
);
|
||||
let (st, body) = cid_parts(
|
||||
@@ -10752,7 +10935,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|r| r.cid == raw_cid),
|
||||
.any(|r| r.cid.as_deref() == Some(raw_cid.as_str())),
|
||||
"the repaired row is advertised again"
|
||||
);
|
||||
}
|
||||
@@ -10945,7 +11128,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|r| r.cid == low_raw),
|
||||
.any(|r| r.cid.as_deref() == Some(low_raw.as_str())),
|
||||
"the repaired row is advertised again"
|
||||
);
|
||||
}
|
||||
@@ -14539,9 +14722,14 @@ 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 (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.
|
||||
/// 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.
|
||||
#[sqlx::test]
|
||||
async fn list_pinned_cids_omits_unrepaired_legacy_row(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
@@ -14557,12 +14745,16 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let listed = state.db.list_pinned_cids().await.unwrap();
|
||||
assert!(
|
||||
!listed.iter().any(|r| r.sha256_hex == oid),
|
||||
"an unrepaired legacy provider-CID row is not advertised"
|
||||
);
|
||||
// #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()));
|
||||
|
||||
// Same row, repaired: it comes back, keyed on the raw CID the resolver serves.
|
||||
// Same row, repaired: it stays listed but the key is now the raw CID.
|
||||
state
|
||||
.db
|
||||
.repair_legacy_provider_cid(&oid, &raw_cid, &provider_cid)
|
||||
@@ -14574,7 +14766,8 @@ mod tests {
|
||||
.find(|r| r.sha256_hex == oid)
|
||||
.expect("the repaired row is advertised again");
|
||||
assert_eq!(
|
||||
rec.cid, raw_cid,
|
||||
rec.cid,
|
||||
Some(raw_cid),
|
||||
"the advertised key is the raw-content resolver key"
|
||||
);
|
||||
}
|
||||
@@ -15444,7 +15637,15 @@ mod tests {
|
||||
"reader's tree body carries the child filename and raw child oid"
|
||||
);
|
||||
|
||||
// Root tree (path "/") stays served to anon who passes the "/" gate.
|
||||
// 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.
|
||||
let (st, _) = cid_parts(
|
||||
cid_router(&state)
|
||||
.oneshot(cid_anon(&root_tree_cid))
|
||||
@@ -15452,7 +15653,11 @@ mod tests {
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "root tree stays served (must-serve)");
|
||||
assert_eq!(
|
||||
st,
|
||||
StatusCode::NOT_FOUND,
|
||||
"root tree denied to anon: its entries name the withheld /secret subtree"
|
||||
);
|
||||
|
||||
// /public subtree tree stays served to anon (allowed path).
|
||||
let (st, _) = cid_parts(
|
||||
@@ -15642,106 +15847,18 @@ mod tests {
|
||||
assert!(body.contains("public bytes"), "owner gets the content");
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// #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.
|
||||
#[sqlx::test]
|
||||
async fn ipfs_cid_commit_tag_walk_error_fails_closed(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
@@ -16331,10 +16448,29 @@ mod tests {
|
||||
.await
|
||||
.expect("path rule");
|
||||
|
||||
// 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"),
|
||||
// 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,
|
||||
),
|
||||
] {
|
||||
let resp = cid_router(&state).oneshot(cid_anon(cid)).await.unwrap();
|
||||
let served = resp
|
||||
@@ -16344,15 +16480,17 @@ mod tests {
|
||||
.map(str::to_string);
|
||||
let (st, _) = cid_parts(resp).await;
|
||||
assert_eq!(
|
||||
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"
|
||||
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"
|
||||
);
|
||||
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
|
||||
|
||||
+178
-8
@@ -127,19 +127,93 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
println!("IPFS pins ({count}) on {node}");
|
||||
println!();
|
||||
for pin in &pins {
|
||||
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}");
|
||||
for line in render_pin(pin) {
|
||||
println!("{line}");
|
||||
}
|
||||
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;
|
||||
@@ -633,6 +707,102 @@ 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;
|
||||
|
||||
@@ -108,11 +108,18 @@ 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) => {
|
||||
|
||||
@@ -91,6 +91,7 @@ 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user