Compare commits

..
Author SHA1 Message Date
beardthelion 7e9a5be6d4 test(node): complete listing-denial regressions for the new gates 2026-11-07 15:08:26 -06:00
beardthelion 0140c9cd86 fix(node): pass SignatureBodyLimit state to optional_signature layers 2026-11-07 06:57:14 -06:00
beardthelion 5274433503 Merge remote-tracking branch 'origin/main' into bug_fix_2
# Conflicts:
#	crates/gitlawb-node/src/server.rs
2026-11-07 06:50:06 -06:00
beardthelionandeuxarist 0cdb4bcc7c fix(api,sync): Delimit positional arguments in git clone mirror invocations (#539)
* fix(api,sync): Delimit positional arguments in git clone mirror invocations

Pass "--" before positional path/URL arguments in Command invocations
for "git clone --mirror" across repos.rs, sync.rs, and gl mirror.rs.

Fixes #374

* style(gl): Format args array in mirror.rs

Fix cargo fmt line-length break for git clone --mirror arguments.

Refs #376

* fix(node): Pin the git clone delimiter against remote-argument injection

The delimiter change had no test, so nothing stopped it being reverted as
cosmetic. What it guards is real: git parses a remote beginning with a
dash as an option, and --upload-pack=<cmd> is one git hands to a shell.

No path reaches that today. Peer URLs are gated by is_public_http_url, so
the origin clone_repo composes can only begin with http:// or https://;
fork paths are joins under repos_dir; and clap rejects a leading-dash
positional in gl mirror. The delimiter is what removes git's dependence
on those three unrelated gates staying correct.

Assert git reports the whole argument as a repository it cannot find,
which is only true once the delimiter forces it to be a path.
Undelimited, git consumes it as an option and fails against the
destination instead, never naming the injected string, so the assertion
is red before the fix and green after.

Refs #374

* fix(sync,gl): Delimit positional arguments in fetch_repo and gl clone

Carry the positional argument delimiter into fetch_repo when setting the
remote origin URL and into setup_partial_clone across all clone branches.
Correct the error string quoted in the clone regression test docstring.

Refs #374

* test(gl,node): Pin the argv delimiter on every git sink this PR changed

The delimiter contract is duplicated across five direct `git` argv
constructions, but only `sync::clone_repo` had a test that went red when
`--` was deleted. Removing or misplacing the delimiter in any other sink
left the suite green, so the hardening rested on review rather than on
the tests.

Each changed sink now has a subprocess-facing regression driven through
real git: the three `setup_partial_clone` shapes (plain, `--branch`, and
the sparse/promisor arm), `fetch_repo`'s `remote set-url`, `gl mirror`,
and the fork clone.

Two of these could not carry the obvious assertion:

- `git_global` and `git_run` both format failures as
  `git {args:?} failed: {stderr}`, so the injected value is already in
  the message via the debug-printed argv. Asserting that the error
  merely contains it passes with `--` deleted. The clone tests assert on
  git's own single-quoted `repository '<value>'` instead, which only
  appears when git read the value as a repository.
- `remote set-url` does not fail on a delimited option-shaped value, it
  stores it. The `fetch_repo` test asserts the stored URL rather than an
  error: undelimited, git exits 129 with `unknown option` and leaves the
  previous URL in place.

`gl mirror` and `fork_repo` needed their argv extracted to be testable.
`mirror::run` loads a keypair and contacts a node before reaching the
clone, and bails with a message that interpolates the source whether or
not the delimiter is present. The extraction keeps the production
`.status()` call, so a large mirror clone still streams git's progress
to the terminal instead of being captured.

Every one of the five was confirmed red with its delimiter removed and
green with it restored.

Refs #374

* fix(gl): Route the last three clone warnings through emit_warning

`emit_warning` sanitizes a line before writing it, which strips ANSI
escape and bidi characters from untrusted text. Three warning sites in
the blob-recovery paths still used a bare `eprintln!` while interpolating
`oid`, which comes from node JSON or an Arweave gateway manifest, so
those escapes reached the user's terminal raw.

Refs #374

* style(gl): elide the lifetime in mirror_clone_args

* test(gl,node): pin LC_ALL=C on git-subprocess stderr assertions

---------

Co-authored-by: euxarist <[email protected]>
2026-11-07 12:38:49 +00:00
Gravirei ac9ea61927 All three findings addressed on bug_fix_2:
P2 — signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router now seeds
  a second repo under a different owner and asserts irys-other-repo-tx is absent. Negative
  control: flipping the production filter to None made the test go RED with count: 2 showing
  the cross-repo leak.

  P3 — let short = owner_did.split(':').next_back().unwrap() replaced with let short =
  crate::db::normalize_owner_key(&owner_did).to_string(). Reader and writer now share one
  convention.

  P3 — PR #134 description updated: pins bullet now describes the actual pre-merge layering
  (and cites the axum-layer-vs-merge-pitfall memory entry), and round 2's stored-slug
  normalization is added as its own bullet with the regression's coverage.

  5/5 anchor-related tests pass, cargo fmt --check clean.
2026-08-30 17:13:53 +06:00
Gravirei 4242bb85ad fix(node): normalize scoped anchor SQL filter to stored repo slug
After authorize_repo_read succeeds, the SQL filter used the raw ?repo=
string (format!("{owner}/{name}")). Anchor rows are stored with
normalize_owner_key(owner_did)/name (short key), so a caller using
?repo=did🔑.../name passed authz but got anchors: [] — a false
empty page that broke callers that legitimately pass the full DID
form. Build the filter from record.owner_did normalized and
record.name instead, matching the shape the seeder uses.
2026-08-30 11:24:12 +06:00
Gravirei 8dd745e98b fix(node): keep /api/v1/ipfs/pins out of the CID resolver's per-IP flood brake
Follow-up to #134: the previous reordering put `/api/v1/ipfs/pins` and
`/ipfs/{cid}` under the same `rate_limit_by_ip` + `IpRateLimiter` layer
stack, so both routes shared the resolver's flood brake bucket. That
bucket is documented as the once-per-request brake for `GET /ipfs/{cid}`
because the resolver can drive bounded-but-expensive repo walks. The
pin listing is a single `list_pinned_cids()` call and has no reason to
share the bucket. Sharing meant /ipfs/{cid} traffic could exhaust the
bucket and 429 the pins endpoint, and signed pin polling could exhaust
the bucket and 429 legitimate CID reads.

Compose `ipfs_routes` from two sub-routers with independent policies,
then merge:
  * `/ipfs/{cid}` keeps `optional_signature` + `rate_limit_by_ip` + the
    `IpRateLimiter` extension. Same behavior as before #134.
  * `/api/v1/ipfs/pins` gets only `optional_signature`. Auth-required
    (handler denies with 401), but no rate limit — it has nothing to
    flood.

Two new `build_router` regressions exercise the bucket independence
through `TrustedProxy::XForwardedFor`:
  * 5 signed pin requests against a size-2 CID bucket all 200, then a
    `/ipfs/{cid}` from the same IP also 200 (pins did not debit the
    bucket).
  * A `/ipfs/{cid}` exhausts a size-1 CID bucket (200, then 429), then
    a signed pin from the same IP is 200 (the bucket does not gate
    pins).

All 65 tests in `server::tests::` + `test_support::tests::ipfs_*`
still pass, including the existing 429 tests for the CID resolver.
2026-08-29 10:52:55 +06:00
Gravirei f49ed79336 fix(node): gate scoped /api/v1/arweave/anchors reads on repository visibility
Closes the review follow-up on #134: a signed non-reader could pass the
auth-layer check on `?repo=<owner>/<name>` and obtain private-repo anchor
metadata (ref names, old/new SHAs, CIDs, irys tx ids). The auth check
proved only that the signature was valid; it did not bind the request to
the canonical repo-read policy.

* Parse the slug through `validate_repo_slug` (same helper the sync path
  uses) and call `authorize_repo_read` before the SQL query. Missing
  repos, quarantined mirrors, and signed non-readers all collapse to the
  standard 404 (`repo_not_found`).
* The unscoped global listing stays auth-only (#121 narrowing; the wider
  permissionless-identity enumeration is the #136 class, out of scope).
* Clamp the limit to `[0, 200]` so `?limit=-1` no longer reaches Postgres
  as `LIMIT -1`.

Five new `build_router` regressions cover the full production contract
through the real `optional_signature` middleware: scoped unsigned 401,
scoped owner 200, scoped non-reader 404 with no anchor metadata leaked
in the body, scoped missing-repo 404 indistinguishable from non-reader,
and `?limit=-1` clamped to an empty 200.
2026-08-29 03:32:01 +06:00
Gravirei 752e117f07 fix(node): format arweave routes for consistency in build_router function 2026-08-28 16:09:52 +06:00
Gravirei f70bb0008e fix(node): enforce authentication on /api/v1/ipfs/pins and /api/v1/arweave/anchors routes 2026-08-28 16:04:51 +06:00
Gravirei 063a5e689f fix(node): gate /ipfs/pins and /arweave/anchors behind authentication (#134)
Add auth rejection to list_pins and list_anchors handlers. The pin index
spans the entire node and would expose metadata for every object ever
pushed; anonymous callers must not see it.

- Require AuthenticatedDid extension in both handlers, return 401 when absent
- Add server.rs regression test for anonymous rejection through build_router
- Fix closed-pool tests to pass authenticated requests (test 503 path)
2026-08-26 16:22:29 +06:00
12 changed files with 1218 additions and 709 deletions

No files matched your search

+56 -9
View File
@@ -1,12 +1,13 @@
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
use axum::{
extract::{Query, State},
extract::{Extension, Query, State},
Json,
};
use serde::Deserialize;
use crate::error::Result;
use crate::db::normalize_owner_key;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
@@ -21,17 +22,62 @@ fn default_limit() -> i64 {
}
/// GET /api/v1/arweave/anchors
///
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
/// request to the canonical read-authorization path used by every other
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
/// identity attached. Missing repositories, quarantined mirrors, and signed
/// non-readers all collapse to the standard `404` (no existence oracle). The
/// unscoped listing is auth-only — the #121 contract — and does not admit
/// visibility filtering, which is the #136 stale-index class and explicitly
/// out of scope for this auth slice.
pub async fn list_anchors(
State(state): State<AppState>,
Query(q): Query<ListAnchorsQuery>,
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let limit = q.limit.min(200);
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let anchors = state
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await?;
// The route's `optional_signature` layer is permissive (it admits unsigned
// legacy callers) so this in-handler check is the actual gate. A signed
// non-reader still passes this check and proceeds to the authz step below.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if caller.is_none() {
return Err(AppError::Unauthorized(
"authentication required for anchor listing".into(),
));
}
// Clamp before the SQL query. A negative or non-numeric limit must not
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
// contract is the same default ceiling as the listing page itself.
let limit = q.limit.clamp(0, 200);
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
// Parse the user-supplied "owner/name" through the same slug validator
// the sync path uses, so a malformed query is a 400, not a 500.
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
Ok(parts) => parts,
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
};
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
// missing repos, quarantined mirrors, and signed non-readers.
let (record, _rules) =
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
// Build the SQL filter from the canonical stored slug, NOT from the
// user-supplied `?repo=` string. Anchor rows are written as
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
// request that passes authz with the full `did:key:…/name` form would
// match zero rows and return a false empty page. The gate already
// verified the repo is readable; `record.owner_did` is the canonical
// identity and `record.name` is the stored name.
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
state
.db
.list_arweave_anchors(Some(&stored_slug), limit)
.await?
} else {
// No scope → no repo-read decision. Auth-only.
state.db.list_arweave_anchors(None, limit).await?
};
Ok(Json(serde_json::json!({
"anchors": anchors,
@@ -60,6 +106,7 @@ mod closed_pool_tests {
.oneshot(
Request::builder()
.uri("/api/v1/arweave/anchors")
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
.body(axum::body::Body::empty())
.unwrap(),
)
+10 -1
View File
@@ -2133,7 +2133,15 @@ async fn gate_and_serve(
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
pub async fn list_pins(
State(state): State<AppState>,
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
if auth.is_none() {
return Err(crate::error::AppError::Unauthorized(
"authentication required for pin listing".into(),
));
}
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let pins = state.db.list_pinned_cids().await?;
@@ -2426,6 +2434,7 @@ mod closed_pool_tests {
.oneshot(
Request::builder()
.uri("/api/v1/ipfs/pins")
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
.body(axum::body::Body::empty())
.unwrap(),
)
+4 -18
View File
@@ -80,20 +80,15 @@ pub(crate) fn did_matches(a: &str, b: &str) -> bool {
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
/// renders it as `AppError::Forbidden`; a handler that emits its own
/// `(StatusCode, Json)` envelope instead of `AppError` (e.g.
/// `api::tasks::create_task`) must use this constant too, so two copies of one
/// gate cannot drift in observable output.
pub(crate) const REPO_OWNER_ONLY_MESSAGE: &str = "only the repo owner can perform this action";
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
/// and the author check (close policy) share one normalization.
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
if did_matches(caller, &record.owner_did) {
Ok(())
} else {
Err(AppError::Forbidden(REPO_OWNER_ONLY_MESSAGE.into()))
Err(AppError::Forbidden(
"only the repo owner can perform this action".into(),
))
}
}
@@ -247,11 +242,6 @@ mod authz_guard {
(events, "list_repo_events", "authorize_repo_read("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
// #496: create_task is signer-self AND owner-when-repo-scoped — a
// repo_id naming a hosted repo admits tasks only from its owner.
// Both halves are pinned: the did_matches row guards the signer
// binding, this one the repo-ownership gate.
(tasks, "create_task", "require_repo_owner("),
(tasks, "claim_task", "did_matches("),
(tasks, "complete_task", "did_matches("),
(tasks, "fail_task", "did_matches("),
@@ -351,11 +341,7 @@ mod authz_guard {
(
"create_task",
"createTask",
&[
"require_signer(ctx)?",
"did_matches(",
"require_repo_owner(",
],
&["require_signer(ctx)?", "did_matches("],
),
(
"claim_task",
+37 -4
View File
@@ -3508,6 +3508,17 @@ pub struct ForkRepoRequest {
}
/// POST /api/v1/repos/:owner/:repo/fork
/// Argv for the fork's mirror clone, with `--` before the first positional (#374).
///
/// Both positionals here are server-derived paths rather than caller-supplied
/// URLs, so this is defence in depth rather than a reachable injection. It is
/// still the same argv contract as the other clone sites, and it is extracted for
/// the same reason: so the delimiter's placement is pinned by a test instead of
/// resting on review.
fn fork_clone_args<'a>(source: &'a str, dest: &'a str) -> [&'a str; 5] {
["clone", "--mirror", "--", source, dest]
}
pub async fn fork_repo(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
@@ -3593,12 +3604,10 @@ pub async fn fork_repo(
// Clone the source repo as a mirror
let output = std::process::Command::new("git")
.args([
"clone",
"--mirror",
.args(fork_clone_args(
source_path.to_str().unwrap_or(""),
disk_path.to_str().unwrap_or(""),
])
))
.output()
.map_err(|e| AppError::Git(format!("git clone --mirror failed: {e}")))?;
@@ -4065,6 +4074,30 @@ fn dedupe_canonical_repos(rows: Vec<(RepoRecord, i64)>) -> Vec<(RepoRecord, i64)
#[cfg(test)]
mod tests {
use super::*;
/// #374: the fork path builds its own `git clone --mirror` argv. Driven
/// through real git so a missing or misplaced `--` fails, matching the
/// coverage on the `gl clone`, `gl mirror`, and sync sinks.
#[test]
fn fork_clone_never_parses_the_source_as_a_git_option() {
let td = tempfile::TempDir::new().unwrap();
let dest = td.path().join("fork.git");
let injected = "--upload-pack=false";
let out = std::process::Command::new("git")
.env("LC_ALL", "C")
.args(fork_clone_args(injected, dest.to_str().unwrap()))
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "an option-shaped source must fail");
assert!(
stderr.contains(&format!("repository '{injected}'")),
"git must name the source as the repository, not consume it as an option: {stderr}"
);
}
use crate::auth::caller_authorized_to_push;
use crate::error::AppError;
use gitlawb_core::identity::Keypair;
+6 -92
View File
@@ -30,33 +30,6 @@ fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
)
}
/// This module's error shape with an opaque body: the real error is logged
/// server-side and never serialized (#250), matching the envelope
/// `AppError::Db` renders. Also mirrors its status split: pool-class failures
/// are a retryable 503 `db_unavailable`, everything else the opaque 500
/// `db_error`, so a transient outage is not reported as permanent.
fn db_error(e: anyhow::Error) -> (StatusCode, Json<Value>) {
tracing::error!(error = %format!("{e:#}"), "task database error");
if let Some(sql) = e.chain().find_map(|c| c.downcast_ref::<sqlx::Error>()) {
if crate::error::db_unavailable(sql) {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})),
);
}
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "db_error",
"message": crate::error::DB_ERROR_MESSAGE,
})),
)
}
// ── Request / response types ──────────────────────────────────────────────────
#[derive(Deserialize)]
@@ -128,37 +101,6 @@ pub async fn create_task(
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
return Err(forbidden("delegator_did must be the authenticated signer"));
}
// #496: a caller-supplied repo_id must name a repo the caller owns. Without
// this, any signed caller can plant a task — payload and UCAN included —
// under a foreign repo id, where the named assignee can claim it. Resolve
// the id against hosted repos: a hosted, non-quarantined repo admits tasks
// only from its owner (403 otherwise). An id naming no hosted repo
// (unknown, or quarantined — which is hidden as if it did not exist, so
// 403ing it would confirm a real id) is stored verbatim as an opaque
// label: this is a write gate and does not bind an unresolved id to a
// repo. Task reads are caller-independent on this base (the list endpoints
// are not repo-scoped), before and after this change. The 403-vs-201 split
// between a hosted repo id and an unknown one is itself a one-bit oracle —
// a stranger holding a private repo's id learns it is hosted here — but
// denying foreign repos requires drawing exactly that line, and repo ids
// already surface via the task list on this base. Repo-less tasks are
// unaffected.
if let Some(repo_id) = body.repo_id.as_deref() {
let record = state.db.get_repo_by_id(repo_id).await.map_err(db_error)?;
if let Some(record) = record {
let quarantined = state
.db
.is_repo_quarantined(&record.id)
.await
.map_err(db_error)?;
if !quarantined && crate::api::require_repo_owner(&record, &auth.0).is_err() {
// Same denial text the GraphQL copy relays from
// `require_repo_owner`, so the two copies of this gate do not
// disagree in observable output.
return Err(forbidden(crate::api::REPO_OWNER_ONLY_MESSAGE));
}
}
}
let now = Utc::now().to_rfc3339();
let task = AgentTask {
id: Uuid::new_v4().to_string(),
@@ -175,7 +117,12 @@ pub async fn create_task(
updated_at: now,
deadline: body.deadline,
};
state.db.create_task(&task).await.map_err(db_error)?;
state.db.create_task(&task).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
})?;
Ok((StatusCode::CREATED, Json(task_to_json(&task))))
}
@@ -350,36 +297,3 @@ pub async fn fail_task(
});
Ok(Json(task_to_json(&task)))
}
#[cfg(test)]
mod tests {
use super::*;
/// The gate's DB helper must mirror `AppError::Db`: a pool-level failure is
/// the retryable 503 `db_unavailable`, a query fault the opaque 500.
#[test]
fn db_error_matches_app_error_status_split() {
let unavailable = db_error(anyhow::Error::from(sqlx::Error::PoolTimedOut));
assert_eq!(unavailable.0, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(unavailable.1 .0["error"], crate::error::DB_UNAVAILABLE_CODE);
assert_eq!(
unavailable.1 .0["message"],
crate::error::DB_UNAVAILABLE_MESSAGE
);
let query = db_error(anyhow::Error::from(sqlx::Error::Protocol(
"column \"machine_id\" does not exist".into(),
)));
assert_eq!(query.0, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(query.1 .0["error"], "db_error");
assert_eq!(query.1 .0["message"], crate::error::DB_ERROR_MESSAGE);
assert!(!query.1 .0.to_string().contains("machine_id"));
}
/// `.context()` layers must not hide the sqlx error from the status split.
#[test]
fn db_error_sees_sqlx_through_context() {
let wrapped = anyhow::Error::from(sqlx::Error::PoolClosed).context("repo lookup failed");
assert_eq!(db_error(wrapped).0, StatusCode::SERVICE_UNAVAILABLE);
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
/// Connection-level sqlx failures that mean the database is unreachable right
/// now (retryable, 503), as opposed to server-reported query errors.
pub(crate) fn db_unavailable(e: &sqlx::Error) -> bool {
fn db_unavailable(e: &sqlx::Error) -> bool {
matches!(
e,
sqlx::Error::PoolTimedOut
-220
View File
@@ -36,33 +36,6 @@ impl MutationRoot {
}
let delegator_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
// #496: same write gate as the REST handler — a repo_id naming a
// hosted, non-quarantined repo admits tasks only from its owner, so a
// stranger cannot file under a foreign repo id. An id naming no hosted
// repo (unknown, or quarantined — hidden as if it did not exist) is
// stored verbatim as an opaque label; the gate does not bind an
// unresolved id to a repo. Task reads are caller-independent on this
// base (the tasks resolver is not repo-scoped), before and after this
// change. As in tasks.rs, the success-vs-denial split between a hosted
// repo id and an unknown one is itself a one-bit oracle — a stranger
// holding a private repo's id learns it is hosted here — but denying
// foreign repos requires drawing exactly that line.
if let Some(repo_id) = input.repo_id.as_deref() {
let record = db
.get_repo_by_id(repo_id)
.await
.map_err(crate::graphql::graphql_db_err)?;
if let Some(record) = record {
let quarantined = db
.is_repo_quarantined(&record.id)
.await
.map_err(crate::graphql::graphql_db_err)?;
if !quarantined {
crate::api::require_repo_owner(&record, caller)
.map_err(crate::graphql::graphql_app_err)?;
}
}
}
let now = Utc::now().to_rfc3339();
let task = AgentTask {
id: Uuid::new_v4().to_string(),
@@ -213,22 +186,6 @@ mod tests {
.join("; ")
}
/// The `id` scalar returned by a successful `createTask` mutation.
fn created_id(resp: &Response) -> String {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::Object(task) = obj.get("createTask").expect("createTask key")
else {
panic!("createTask not an object");
};
let async_graphql::Value::String(id) = task.get("id").expect("id key") else {
panic!("id not a string");
};
id.clone()
}
/// N2: every GraphQL mutation requires a verified signer and binds the acting
/// DID to it. Unsigned is rejected; a signer other than the claimed actor is
/// rejected; the matching signer passes the auth gate.
@@ -396,181 +353,4 @@ mod tests {
errors(&resp)
);
}
/// #496 (GraphQL): createTask applies the same repo-ownership gate as the
/// REST handler — a stranger naming a hosted repo is rejected without
/// persisting anything, while the owner files. The quarantined and
/// unknown-id arms are pinned too, including that each accepted write
/// stores the supplied id verbatim, so this copy of the gate cannot regress
/// silently.
#[sqlx::test]
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let owner = "did:key:zGQLTASKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
let stranger = "did:key:zGQLTASKSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
let now = chrono::Utc::now();
let repo = crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: "gql-task-gate-repo".to_string(),
owner_did: owner.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: "/tmp/gql-task-gate-repo".to_string(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&repo).await.expect("seed repo");
let schema = state.graphql_schema.as_ref();
let q = |actor: &str| {
format!(
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
repo.id
)
};
// Stranger signs as themselves (so the signer binding passes) but
// names the victim's repo → rejected by the ownership gate, leaking
// nothing about the repo.
let resp = schema
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
"a non-owner must not file under a foreign repo id (shared denial text): {errs}"
);
// The denial must not persist: no task row may carry the victim's
// repo id afterwards.
let stored = state
.db
.list_tasks(None, None, 50)
.await
.expect("list tasks");
assert!(
stored
.iter()
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
"a denied write must not leave a task under the foreign repo id"
);
// The owner files under their own repo id.
let resp = schema
.execute(Request::new(q(owner)).data(AuthenticatedDid(owner.into())))
.await;
assert!(
errors(&resp).is_empty(),
"the owner should file against their repo: {}",
errors(&resp)
);
// Unknown repo ids stay accepted as opaque labels.
let q_unknown = format!(
r#"mutation {{ createTask(delegatorDid: "{stranger}", input: {{ kind: "build", capability: "repo:write", repoId: "no-such-repo-id" }}) {{ id }} }}"#
);
let resp = schema
.execute(Request::new(q_unknown).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).is_empty(),
"an id naming no hosted repo stays an opaque label: {}",
errors(&resp)
);
// Read back the row: the unresolved id must be stored verbatim, not
// dropped or rewritten to a repo it never named.
let stored = state
.db
.get_task(&created_id(&resp))
.await
.expect("get task")
.expect("task must be stored");
assert_eq!(
stored.repo_id.as_deref(),
Some("no-such-repo-id"),
"an unresolved id must be stored verbatim as a label"
);
// A quarantined repo id is treated as unknown (accepted, never a 403
// that would confirm the id names a real repo). This pins the
// `!quarantined` arm on this copy of the gate.
state
.db
.set_repo_quarantine(&repo.id, true)
.await
.expect("quarantine");
let resp = schema
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).is_empty(),
"a quarantined repo id must not 403 (existence oracle): {}",
errors(&resp)
);
// Read back the row: the quarantined id is stored verbatim as a label
// too, so the accepted write is not silently dropped.
let stored = state
.db
.get_task(&created_id(&resp))
.await
.expect("get task")
.expect("task must be stored");
assert_eq!(
stored.repo_id.as_deref(),
Some(repo.id.as_str()),
"a quarantined id must be stored verbatim as a label"
);
}
/// #496 (GraphQL): a repo-lookup fault in createTask's gate must stay
/// opaque. Dropping a column `get_repo_by_id` selects faults that lookup;
/// the resolver must return the fixed DB message and leak no schema text
/// (#250), as `create_task_db_error_message_is_opaque` pins for the
/// agent_tasks fault.
#[sqlx::test]
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let owner = "did:key:zGQLTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
let now = chrono::Utc::now();
let repo = crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: "gql-task-gate-fault-repo".to_string(),
owner_did: owner.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: "/tmp/gql-task-gate-fault-repo".to_string(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&repo).await.expect("seed repo");
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
.execute(&pool)
.await
.expect("drop machine_id");
let q = format!(
r#"mutation {{ createTask(delegatorDid: "{owner}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
repo.id
);
let resp = state
.graphql_schema
.execute(Request::new(&q).data(AuthenticatedDid(owner.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"gate sqlx fault must be opaque: {errs}"
);
assert!(
!errs.contains("machine_id") && !errs.contains("column"),
"schema text leaked through the gate: {errs}"
);
}
}
+708 -17
View File
@@ -237,35 +237,62 @@ pub fn build_router(state: AppState) -> Router {
.layer(axum::Extension(push_limiter));
// ── IPFS content-addressed retrieval and pin listing ──────────────────
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
// identity and can apply per-repo visibility (#110); anonymous callers stay
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
// stays unsigned — gating the pin index is tracked separately (#121).
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
// request can drive a full-history git walk, so the per-IP rate limiter is the
// outermost layer (rejects a flood before the walk-admission work), mirroring the
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
// Two independent sub-routers, then merged. They share a URL prefix family
// but have separate rate-limit policies and must not share a bucket.
//
// `/ipfs/{cid}` (CID resolver): carries `optional_signature` so `get_by_cid`
// sees the caller identity and can apply per-repo visibility (#110); anon
// callers stay anonymous and still read genuinely public content. The
// per-IP flood brake is layered on because the resolver is anon-reachable
// and each request can drive a full-history git walk — the brake is the
// outermost layer (rejects a flood before the walk-admission work), mirroring
// the push/create routers. The `IpRateLimiter` extension MUST be attached
// or `rate_limit_by_ip` is a silent no-op.
//
// `/api/v1/ipfs/pins` (pin listing): now carries `optional_signature` only
// (#121). The handler rejects requests without a verified `AuthenticatedDid`
// with 401. It does NOT carry the CID flood brake — `list_pins` is a single
// `list_pinned_cids()` call, no walk, and routing pins through the resolver's
// bucket would let `/ipfs/{cid}` traffic exhaust the bucket and 429 the
// pins endpoint, or let signed pin polling exhaust the bucket for legitimate
// CID reads. The two surfaces have separate availability contracts.
//
// Both sub-routers are built first with their own layer sets, then merged.
// This is the structure the prior routing guidance called for and the
// earlier 429 test for `/ipfs/{cid}` (test_support.rs) assumes.
let ipfs_limiter = rate_limit::IpRateLimiter {
limiter: state.ipfs_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let ipfs_routes = Router::new()
let ipfs_cid_routes = Router::new()
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(ipfs_limiter))
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
.layer(axum::Extension(ipfs_limiter));
let ipfs_pins_routes = Router::new()
.route("/api/v1/ipfs/pins", get(ipfs::list_pins))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
let ipfs_routes = ipfs_cid_routes.merge(ipfs_pins_routes);
// ── Arweave permanent anchors ──────────────────────────────────────────
// List endpoint only (public; issue #134 tracks surfacing
// visibility rules on list). The verify endpoint and its probe
// are deferred to the vertical slice with the uploader and a
// real provider contract (reviewer 2, #26 split 2/4 round 5).
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
// `list_anchors` rejects callers without a verified `AuthenticatedDid`, so
// unsigned enumeration is denied. The same `optional_signature` layer used
// on the other read surfaces is applied here, closing the anonymous
// anchor-listing gap alongside `/api/v1/ipfs/pins`. The verify endpoint
// and its probe are deferred to the vertical slice with the uploader and
// a real provider contract.
let arweave_routes = Router::new()
.route("/api/v1/arweave/anchors", get(arweave::list_anchors))
.layer(middleware::from_fn_with_state(
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
auth::optional_signature,
));
// ── Bounty routes (write — require HTTP Signature) ─────────────────
let bounty_write_routes = add_auth_layers(
@@ -672,3 +699,667 @@ async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
None => Json(json!({ "enabled": false })),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use sqlx::PgPool;
use tower::ServiceExt;
use crate::db::RepoRecord;
use crate::test_support::test_state;
/// Regression: anonymous callers must not see the pin/anchor index (#121, #134).
#[sqlx::test]
async fn unsigned_get_pins_and_anchors_is_401_through_build_router(pool: PgPool) {
let state = test_state(pool).await;
// Seed identifiable rows so the denial oracle proves the 401 body omits
// them: a status-only check would stay green if a later change served
// the listing before the auth layer rejected.
state
.db
.record_pinned_cid("seeded-pin-oid", "bafyPIN-SECRET-DO-NOT-LEAK", None)
.await
.expect("seed pin");
let owner_did = "did:key:zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let repo = seed_repo_inline(owner_did, "seeded-repo");
state.db.create_repo(&repo).await.expect("seed repo");
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: "zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/seeded-repo",
owner_did,
ref_name: "refs/heads/secret",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
arweave_url: "https://arweave.net/secret-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let router = build_router(state);
let pins = Request::builder()
.method("GET")
.uri("/api/v1/ipfs/pins?limit=50")
.body(Body::empty())
.unwrap();
let pins_resp = router.clone().oneshot(pins).await.unwrap();
assert_eq!(
pins_resp.status(),
StatusCode::UNAUTHORIZED,
"anonymous pin listing must be rejected"
);
let bytes = axum::body::to_bytes(pins_resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("bafyPIN-SECRET-DO-NOT-LEAK"),
"the 401 body must not leak the seeded pin cid; body was: {body}"
);
let anchors = Request::builder()
.method("GET")
.uri("/api/v1/arweave/anchors?limit=50")
.body(Body::empty())
.unwrap();
let anchors_resp = router.oneshot(anchors).await.unwrap();
assert_eq!(
anchors_resp.status(),
StatusCode::UNAUTHORIZED,
"anonymous anchors listing must be rejected"
);
let bytes = axum::body::to_bytes(anchors_resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
);
}
/// Regression: a real RFC-9421 signature produced exactly as `gl` does — built
/// with `gitlawb_core::http_sig::sign_request` over a GET, headers attached,
/// and sent through the actual `build_router` — is verified by the
/// `optional_signature` layer that wraps the pin/anchor routes, and the
/// handler returns 200. Pairs with the anonymous-denial test above; one
/// proves headers are required, the other proves a valid header is honored.
/// Without this test the unsigned-denial test would stay green even if the
/// `optional_signature` layer were never wired onto these routes, because
/// signed and unsigned requests would fail identically (#134 review).
#[sqlx::test]
async fn signed_get_pins_and_anchors_succeeds_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let state = test_state(pool).await;
let router = build_router(state);
let kp = Keypair::generate();
let pins_path = "/api/v1/ipfs/pins";
let signed = sign_request(&kp, "GET", pins_path, b"");
let pins = Request::builder()
.method("GET")
.uri(pins_path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let pins_resp = router.clone().oneshot(pins).await.unwrap();
assert_eq!(
pins_resp.status(),
StatusCode::OK,
"a valid signature on /api/v1/ipfs/pins must be honored through build_router"
);
let anchors_path = "/api/v1/arweave/anchors";
let signed = sign_request(&kp, "GET", anchors_path, b"");
let anchors = Request::builder()
.method("GET")
.uri(anchors_path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let anchors_resp = router.oneshot(anchors).await.unwrap();
assert_eq!(
anchors_resp.status(),
StatusCode::OK,
"a valid signature on /api/v1/arweave/anchors must be honored through build_router"
);
}
/// Companion to the two regressions above: a request that *carries* signature
/// headers but whose signature does not verify (here, garbled) must be denied
/// with 401, not silently treated as anonymous and re-checked by the handler.
/// This pins the failure mode of the `optional_signature` layer: when the
/// caller claims to be signed, the layer must commit to verifying — there is
/// no fall-through path that lets a bad signature bypass auth.
#[sqlx::test]
async fn malformed_signature_on_pins_is_401_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let state = test_state(pool).await;
let router = build_router(state);
let kp = Keypair::generate();
let path = "/api/v1/ipfs/pins";
let mut signed = sign_request(&kp, "GET", path, b"");
// Flip a character well inside the signature value (base64) so the header
// still parses but does not verify against the key.
let mut tampered = signed.signature.clone();
let mid = tampered.len() / 2;
let flipped = if tampered.as_bytes()[mid] == b'A' {
'B'
} else {
'A'
};
tampered.replace_range(mid..mid + 1, &flipped.to_string());
signed.signature = tampered;
let req = Request::builder()
.method("GET")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"a malformed signature must be rejected by the auth layer, not silently accepted"
);
}
/// The same malformed-signature rejection must hold on the anchors route,
/// which carries its own `optional_signature` layer beside pins: a later
/// edit that dropped the layer here (but not on pins) would otherwise stay
/// green.
#[sqlx::test]
async fn malformed_signature_on_anchors_is_401_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let state = test_state(pool).await;
let router = build_router(state);
let kp = Keypair::generate();
let path = "/api/v1/arweave/anchors";
let mut signed = sign_request(&kp, "GET", path, b"");
let mut tampered = signed.signature.clone();
let mid = tampered.len() / 2;
let flipped = if tampered.as_bytes()[mid] == b'A' {
'B'
} else {
'A'
};
tampered.replace_range(mid..mid + 1, &flipped.to_string());
signed.signature = tampered;
let req = Request::builder()
.method("GET")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"a malformed signature must be rejected by the auth layer, not silently accepted"
);
}
// ── Scoped anchor contract (P1 follow-up: ?repo= must authorize_repo_read) ──
//
// The follow-up review (after the auth-layer wiring landed) found that a
// signed but unauthorized caller could still obtain scoped anchor metadata
// because the `?repo=` branch handed the user-supplied string straight to
// the SQL filter. These tests exercise the full production contract end to
// end: real RFC-9421 signature → `optional_signature` layer →
// `authorize_repo_read` → SQL. They fail closed if the authz call is moved
// after the query or removed entirely.
/// Inline repo seed for the scoped-anchor tests. Mirrors the shape in
/// `test_support::tests::seed_repo` without taking a cross-module private
/// helper as a dependency.
fn seed_repo_inline(owner_did: &str, name: &str) -> RepoRecord {
let now = chrono::Utc::now();
RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// Unsigned scoped anchor request → 401, identical to the global anchor
/// 401 test. Proves the auth layer fires before any scope decision (no
/// existence oracle via the `?repo=` path either).
#[sqlx::test]
async fn unsigned_scoped_anchors_is_401_through_build_router(pool: PgPool) {
let state = test_state(pool).await;
// Seed an anchor under a repo the request claims to scope to, so the
// denial oracle proves the 401 body omits it rather than merely
// returning the right status over an empty table.
let owner_did = "did:key:zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let repo = seed_repo_inline(owner_did, "priv");
state.db.create_repo(&repo).await.expect("seed repo");
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: "zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/priv",
owner_did,
ref_name: "refs/heads/secret",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-scoped-secret-DO-NOT-LEAK",
arweave_url: "https://arweave.net/scoped-secret",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let router = build_router(state);
let resp = router
.oneshot(
Request::builder()
.method("GET")
.uri("/api/v1/arweave/anchors?repo=did:key:zSCOPED%2Fpriv")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"unsigned scoped anchor request must be 401, before any scope lookup"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("irys-scoped-secret-DO-NOT-LEAK"),
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
);
}
/// Signed owner on a private repo → 200, anchor metadata returned. Mirrors
/// the existing `list_webhooks_accepts_a_real_gl_signature_e2e` shape: real
/// signature, real middleware, real `authorize_repo_read`.
#[sqlx::test]
async fn signed_scoped_anchors_owner_succeeds_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let owner_did = kp.did().to_string();
let state = test_state(pool).await;
let mut repo = seed_repo_inline(&owner_did, "scoped-priv");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
let short = owner_did.split(':').next_back().unwrap().to_string();
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!("{short}/scoped-priv"),
owner_did: &owner_did,
ref_name: "refs/heads/main",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-owner-tx",
arweave_url: "https://arweave.net/owner-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let path = format!("/api/v1/arweave/anchors?repo={short}/scoped-priv");
let signed = sign_request(&kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the owner of a private repo must see their scoped anchors"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("irys-owner-tx"),
"owner must see the anchor's irys tx id; body was: {body}"
);
// Owner must see the ref name + new SHA so this is not just a count oracle.
assert!(
body.contains("refs/heads/main"),
"owner must see the ref name; body was: {body}"
);
// Sanity: a second test would need a different repo name to avoid
// colliding on `did:key:zSCOPED/scoped-priv` in the anchors table.
// Distinct repo names per test keep the rows queryable in isolation.
let _ = repo;
}
/// #121 R3: the SQL filter must use the canonical stored slug, not the
/// user-supplied `?repo=` string. Anchor rows are stored as
/// `{normalize_owner_key(owner_did)}/{name}` (the short form). Authz
/// passes for the full-DID form, but a literal `WHERE repo=$1` against
/// the full DID would match zero rows and return a false empty page,
/// breaking callers that legitimately use the full `did:key:…/name`
/// form in `?repo=`. RED before the fix: handler built the filter from
/// the raw `?repo=` string, so this query returned `anchors: []`.
#[sqlx::test]
async fn signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router(
pool: PgPool,
) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let owner_did = kp.did().to_string();
let state = test_state(pool).await;
let mut repo = seed_repo_inline(&owner_did, "scoped-did-form");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
// Use the same slug-construction the writer uses
// (`db::normalize_owner_key`) so the test reader and the production
// writer can't drift if a future change moves the slug off the
// short-form. P3 (reviewer-3): the previous `split(':').next_back()`
// hand-rolled a third convention that agreed with the writer for
// `did:key` owners and diverged for every other method.
let short = crate::db::normalize_owner_key(&owner_did).to_string();
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!("{short}/scoped-did-form"),
owner_did: &owner_did,
ref_name: "refs/heads/main",
old_sha: "0".repeat(64).as_str(),
new_sha: "1".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-did-form-tx",
arweave_url: "https://arweave.net/did-form-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
// P2 (reviewer-3): the previous suite only proved the authorized
// row is present. A correct filter must ALSO prove that anchors
// for OTHER repos do not leak into the response — the failure
// mode of the original bug was a cross-repo leak, not a
// fail-safe empty page. Seed a second repo (also under the
// same owner, so the authz test stays scoped) with its own
// irys_tx_id and assert it is absent from the body.
let other_owner = {
let kp2 = Keypair::generate();
kp2.did().to_string()
};
let mut other_repo = seed_repo_inline(&other_owner, "other-repo");
other_repo.is_public = true;
state
.db
.create_repo(&other_repo)
.await
.expect("seed other repo");
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!(
"{}/other-repo",
crate::db::normalize_owner_key(&other_owner)
),
owner_did: &other_owner,
ref_name: "refs/heads/main",
old_sha: "0".repeat(64).as_str(),
new_sha: "2".repeat(64).as_str(),
cid: Some("bafyother"),
irys_tx_id: "irys-other-repo-tx",
arweave_url: "https://arweave.net/other-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed other anchor");
// The caller passes the FULL DID form in ?repo=. validate_repo_slug
// accepts it, authorize_repo_read resolves it, and the handler MUST
// then translate to the canonical stored slug (short form) before
// running the SQL — otherwise this returns 200 with anchors: [].
let path = format!("/api/v1/arweave/anchors?repo={owner_did}/scoped-did-form");
let signed = sign_request(&kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"authz passed via the full DID form, so the response must be 200"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("irys-did-form-tx"),
"the SQL filter must use the stored short slug, not the full DID — \
a false-empty page would break callers that pass ?repo=did:key:…/name. \
body was: {body}"
);
assert!(
!body.contains("irys-other-repo-tx"),
"the scoped anchor filter must exclude other repos' anchors — \
a cross-repo leak would expose metadata of repos the caller \
did not authorize. body was: {body}"
);
let _ = repo;
}
/// Signed non-reader on a private repo → 404, anchor metadata MUST NOT
/// leak. This is the test that catches the "auth-but-no-authz" bug if
/// the gate is moved after the SQL query or removed entirely. The 404
/// is the standard `repo_not_found` shape — indistinguishable from the
/// missing-repo case below.
#[sqlx::test]
async fn signed_scoped_anchors_non_reader_is_404_no_leak_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let owner_kp = Keypair::generate();
let stranger_kp = Keypair::generate();
let owner_did = owner_kp.did().to_string();
let stranger_did = stranger_kp.did().to_string();
let state = test_state(pool).await;
let mut repo = seed_repo_inline(&owner_did, "scoped-priv-nr");
repo.is_public = false;
state.db.create_repo(&repo).await.expect("seed repo");
let short_owner = owner_did.split(':').next_back().unwrap().to_string();
state
.db
.record_arweave_anchor(&crate::db::RecordAnchorInput {
repo: &format!("{short_owner}/scoped-priv-nr"),
owner_did: &owner_did,
ref_name: "refs/heads/secret",
old_sha: "2".repeat(64).as_str(),
new_sha: "3".repeat(64).as_str(),
cid: Some("bafytest"),
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
arweave_url: "https://arweave.net/secret-tx",
node_did: "did:key:zNODE",
})
.await
.expect("seed anchor");
let _ = stranger_did; // signature carries the DID; this is just documentation
let path = format!("/api/v1/arweave/anchors?repo={short_owner}/scoped-priv-nr");
let signed = sign_request(&stranger_kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a signed non-reader must be 404 on a private repo's anchors"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
"the 404 body must not leak the anchor's irys tx id; body was: {body}"
);
assert!(
!body.contains("refs/heads/secret"),
"the 404 body must not leak the ref name; body was: {body}"
);
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v["error"], "repo_not_found",
"non-reader denial must carry the same error code as a missing repo"
);
}
/// Signed caller on a missing repo → 404, same shape as the non-reader
/// case. This is the indistinguishability half: a probe cannot tell
/// "private" from "absent" from the response.
#[sqlx::test]
async fn signed_scoped_anchors_missing_repo_is_404_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let state = test_state(pool).await;
let short = kp
.did()
.to_string()
.split(':')
.next_back()
.unwrap()
.to_string();
let path = format!("/api/v1/arweave/anchors?repo={short}/does-not-exist");
let signed = sign_request(&kp, "GET", &path, b"");
let req = Request::builder()
.method("GET")
.uri(&path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a missing repo must 404 indistinguishably from a non-readable private repo"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v["error"], "repo_not_found",
"the 404 body must carry the standard repo_not_found error code"
);
}
/// `?limit=-1` must not crash as `LIMIT -1` (Postgres 500). It clamps to
/// zero and returns 200 with an empty list — the same shape as a valid
/// listing that happens to have no rows in the configured range.
#[sqlx::test]
async fn signed_anchors_negative_limit_clamps_to_zero_through_build_router(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let state = test_state(pool).await;
let path = "/api/v1/arweave/anchors?limit=-1";
let signed = sign_request(&kp, "GET", path, b"");
let req = Request::builder()
.method("GET")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::empty())
.unwrap();
let router = build_router(state);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"?limit=-1 must clamp, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(v["count"], 0, "clamped limit yields an empty list");
assert_eq!(v["anchors"].as_array().map(|a| a.len()), Some(0));
}
}
+81 -1
View File
@@ -701,6 +701,7 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
if mode == MirrorMode::Promisor {
args.push("--filter=blob:limit=10g");
}
args.push("--");
args.push(remote_url);
args.push(local_str);
@@ -729,7 +730,10 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> {
let local_str = local_path.to_str().unwrap_or(".");
git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?;
git_run(&[
"-C", local_str, "remote", "set-url", "origin", "--", remote_url,
])
.await?;
match mode {
MirrorMode::Promisor => {
@@ -1006,6 +1010,82 @@ mod tests {
assert_eq!(probe, PromisorProbe::NotPromisor);
}
/// #374: `clone_repo` interpolates the peer's origin URL into a `git
/// clone --mirror` argument list. Without a `--` delimiter git parses a
/// remote beginning with `-` as an option instead of a repository.
///
/// The delimiter is what forces the same string to be a repository, and
/// git says so in its own words: it reports the whole argument as a
/// repository it cannot find. Undelimited, git consumes it as an option
/// and fails against the *destination* instead (`fatal: repository '<dest>' does not exist`),
/// never naming the injected string — which is why this assertion is RED
/// before the fix and GREEN after.
///
/// The payload is deliberately free of `:` and spaces: a colon would make
/// git read the argument as an ssh-style `host:path` URL and report a
/// hostname rather than a repository, which is a different code path.
#[tokio::test]
async fn clone_never_parses_a_remote_as_a_git_option() {
let td = TempDir::new().unwrap();
let dest = td.path().join("mirror.git");
let injected = "--upload-pack=false";
let err = clone_repo(injected, &dest, MirrorMode::Plain)
.await
.expect_err("a remote that is not a repository must fail the clone")
.to_string();
assert!(
err.contains(injected),
"git must report the argument as a repository, not consume it as an option: {err}"
);
}
/// #374: `fetch_repo` interpolates the peer's origin URL into
/// `git remote set-url`, a second argv construction with the same contract as
/// the clone. It needs its own proof: the clone test cannot reach this path.
///
/// The assertion is on stored state rather than on the error string, and that
/// is deliberate. `git_run` formats its failure as `git {args:?} failed:
/// {stderr}`, so the injected value is in the message via the debug-printed
/// argv whether or not the delimiter is present — asserting on containment
/// would pass with `--` deleted. Delimited, `set-url` succeeds and stores the
/// value verbatim; undelimited, git exits 129 with `unknown option` and leaves
/// the previous URL in place, so this goes red without the fix.
///
/// The fetch that follows the rewrite fails, because the stored URL is not a
/// repository. That is expected and ignored: the property under test is what
/// `set-url` wrote, not whether the subsequent fetch could succeed.
#[tokio::test]
async fn fetch_never_parses_a_remote_as_a_git_option() {
let td = TempDir::new().unwrap();
let local = td.path().join("mirror.git");
let local_str = local.to_str().unwrap();
let placeholder = "http://example.invalid/x.git";
assert!(Command::new("git")
.args(["init", "-q", "--bare", local_str])
.status()
.unwrap()
.success());
assert!(Command::new("git")
.args(["-C", local_str, "remote", "add", "origin", placeholder])
.status()
.unwrap()
.success());
let injected = "--upload-pack=false";
let _ = fetch_repo(&local, injected, MirrorMode::Plain).await;
assert_eq!(
git_config_get(local_str, "remote.origin.url")
.await
.as_deref(),
Some(injected),
"set-url must store an option-shaped remote verbatim, not reject it as an option"
);
}
#[tokio::test]
async fn probe_reports_unknown_on_git_error() {
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
+175 -339
View File
@@ -1036,345 +1036,6 @@ mod tests {
);
}
/// #496: create_task must reject a caller-supplied repo_id that names a
/// hosted repo the caller does not own. A stranger naming the victim's repo
/// gets 403, leaking nothing about the repo and persisting nothing; the
/// owner files (201). Repo-less tasks stay allowed; an id naming no hosted
/// repo (unknown, or quarantined, hidden as if it did not exist) is stored
/// verbatim as an opaque label, and the 403-vs-201 split between a hosted
/// id and an unknown one is itself a one-bit oracle. This is a write gate:
/// task reads are caller-independent and not repo-scoped on this base.
#[sqlx::test]
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
let owner = "did:key:zTASKREPOOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
let stranger = "did:key:zTASKREPOSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
let state = test_state(pool).await;
let repo = seed_repo(owner, "task-gate-repo");
state.db.create_repo(&repo).await.expect("seed repo");
let router = || {
Router::new()
.route(
"/api/v1/tasks",
axum::routing::post(crate::api::tasks::create_task),
)
.with_state(state.clone())
};
let post_as = |signer: &str, body: String| {
router().oneshot(signed_request_as(
signer,
Method::POST,
"/api/v1/tasks",
Body::from(body),
))
};
let body_with = |signer: &str, repo_id: Option<&str>| {
let repo_field = repo_id
.map(|id| format!(r#","repo_id":"{id}""#))
.unwrap_or_default();
format!(
r#"{{"kind":"build","capability":"repo:write","delegator_did":"{signer}"{repo_field}}}"#
)
};
let created_id = |bytes: &[u8]| -> String {
let v: serde_json::Value = serde_json::from_slice(bytes).expect("task json");
v["id"].as_str().expect("task id").to_string()
};
// Stranger filing under the victim's repo id → exact 403.
let resp = post_as(stranger, body_with(stranger, Some(&repo.id)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"a non-owner must not file tasks against a foreign repo id"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(r#""error":"forbidden""#),
"denial must keep the forbidden envelope: {text}"
);
assert!(
text.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
"denial must carry the shared owner-denial text (same as GraphQL): {text}"
);
// The denial must not persist: no task row may carry the victim's
// repo id afterwards — a 403 that still writes is the injection.
let stored = state
.db
.list_tasks(None, None, 50)
.await
.expect("list tasks");
assert!(
stored
.iter()
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
"a denied write must not leave a task under the foreign repo id"
);
// Owner filing under their own repo id → 201.
let resp = post_as(owner, body_with(owner, Some(&repo.id)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"the owner must be able to file tasks against their repo"
);
// Repo-less task → 201 (unaffected).
let resp = post_as(stranger, body_with(stranger, None)).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"repo-less task creation must keep working"
);
// Unknown repo id → 201 as an opaque label.
let resp = post_as(stranger, body_with(stranger, Some("no-such-repo-id")))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"an id naming no hosted repo stays an opaque label"
);
// Read back the row: the unresolved id must be stored verbatim, not
// dropped or rewritten to a repo it never named.
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let task = state
.db
.get_task(&created_id(&bytes))
.await
.expect("get task")
.expect("task must be stored");
assert_eq!(
task.repo_id.as_deref(),
Some("no-such-repo-id"),
"an unresolved id must be stored verbatim as a label"
);
// Quarantined repo id → treated as unknown (201), never a 403 that
// would confirm the id names a real repo.
state
.db
.set_repo_quarantine(&repo.id, true)
.await
.expect("quarantine");
let resp = post_as(stranger, body_with(stranger, Some(&repo.id)))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"a quarantined repo id must not 403 (existence oracle)"
);
// Read back the row: the quarantined id is stored verbatim as a label
// too, so the accepted write is not silently dropped.
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let task = state
.db
.get_task(&created_id(&bytes))
.await
.expect("get task")
.expect("task must be stored");
assert_eq!(
task.repo_id.as_deref(),
Some(repo.id.as_str()),
"a quarantined id must be stored verbatim as a label"
);
}
/// #496 (REST): a repo-lookup fault in the create_task gate must render the
/// module's opaque `db_error` envelope, not the raw sqlx text. Dropping a
/// column `get_repo_by_id` selects faults that first lookup; the 500 body
/// stays fixed and names no column (#250/#226).
#[sqlx::test]
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
let owner = "did:key:zTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAAA";
let state = test_state(pool.clone()).await;
let repo = seed_repo(owner, "task-gate-fault-repo");
state.db.create_repo(&repo).await.expect("seed repo");
let router = || {
Router::new()
.route(
"/api/v1/tasks",
axum::routing::post(crate::api::tasks::create_task),
)
.with_state(state.clone())
};
let body = format!(
r#"{{"kind":"build","capability":"repo:write","delegator_did":"{owner}","repo_id":"{}"}}"#,
repo.id
);
// Drop a column the lookup's SELECT names so get_repo_by_id faults.
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
.execute(&pool)
.await
.expect("drop machine_id");
let resp = router()
.oneshot(signed_request_as(
owner,
Method::POST,
"/api/v1/tasks",
Body::from(body),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a faulted repo lookup must be a 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(r#""error":"db_error""#),
"gate DB fault must keep the opaque envelope: {text}"
);
assert!(
text.contains(crate::error::DB_ERROR_MESSAGE),
"gate DB fault must carry the fixed message: {text}"
);
assert!(
!text.contains("machine_id") && !text.contains("column"),
"schema text leaked through the gate: {text}"
);
}
/// #496 (REST): the gate's second lookup (`is_repo_quarantined`) runs through
/// the same opaque helper. Dropping the column only it selects leaves
/// `get_repo_by_id` succeeding, so this pins that second call site too.
#[sqlx::test]
async fn create_task_gate_quarantine_lookup_fault_is_opaque(pool: PgPool) {
let owner = "did:key:zTASKFAULT2OWNERAAAAAAAAAAAAAAAAAAAAAAAA";
let state = test_state(pool.clone()).await;
let repo = seed_repo(owner, "task-gate-fault2-repo");
state.db.create_repo(&repo).await.expect("seed repo");
let router = || {
Router::new()
.route(
"/api/v1/tasks",
axum::routing::post(crate::api::tasks::create_task),
)
.with_state(state.clone())
};
let body = format!(
r#"{{"kind":"build","capability":"repo:write","delegator_did":"{owner}","repo_id":"{}"}}"#,
repo.id
);
// `quarantined` is not in get_repo_by_id's SELECT, so that lookup still
// succeeds and the fault lands on is_repo_quarantined.
sqlx::query("ALTER TABLE repos DROP COLUMN quarantined")
.execute(&pool)
.await
.expect("drop quarantined");
let resp = router()
.oneshot(signed_request_as(
owner,
Method::POST,
"/api/v1/tasks",
Body::from(body),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a faulted quarantine lookup must be a 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(r#""error":"db_error""#),
"quarantine DB fault must keep the opaque envelope: {text}"
);
assert!(
text.contains(crate::error::DB_ERROR_MESSAGE),
"quarantine DB fault must carry the fixed message: {text}"
);
assert!(
!text.contains("quarantined") && !text.contains("column"),
"schema text leaked through the gate: {text}"
);
}
/// #496 (REST): the create_task INSERT goes through the same opaque helper,
/// so a write fault must not serialize sqlx text either. A repo-less task
/// reaches the INSERT without a gate lookup; dropping a column the INSERT
/// names faults it.
#[sqlx::test]
async fn create_task_insert_fault_is_opaque(pool: PgPool) {
let signer = "did:key:zTASKINSERTFAULTAAAAAAAAAAAAAAAAAAAAAAAA";
let state = test_state(pool.clone()).await;
let router = || {
Router::new()
.route(
"/api/v1/tasks",
axum::routing::post(crate::api::tasks::create_task),
)
.with_state(state.clone())
};
let body =
format!(r#"{{"kind":"build","capability":"repo:write","delegator_did":"{signer}"}}"#);
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
.execute(&pool)
.await
.expect("drop status");
let resp = router()
.oneshot(signed_request_as(
signer,
Method::POST,
"/api/v1/tasks",
Body::from(body),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a faulted INSERT must be a 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains(r#""error":"db_error""#),
"INSERT fault must keep the opaque envelope: {text}"
);
assert!(
text.contains(crate::error::DB_ERROR_MESSAGE),
"INSERT fault must carry the fixed message: {text}"
);
assert!(
!text.contains("status") && !text.contains("column"),
"schema text leaked through the INSERT: {text}"
);
}
/// N3: get_tree gates on the REQUESTED subtree, not the repo root. A caller
/// denied a withheld subtree is rejected there (404) but passes the gate on a
/// non-withheld path (so the rejection is path-scoped, not repo-wide).
@@ -16899,6 +16560,181 @@ mod tests {
);
}
/// #134 R2 (split buckets): signed `/api/v1/ipfs/pins` polling must NOT debit
/// the CID resolver's per-IP flood brake. The pre-fix composition merged
/// pins under the same router as `/ipfs/{cid}`, so every signed pin
/// request consumed a token from `ipfs_rate_limiter` and a flood of pinned
/// traffic could 429 legitimate CID reads. RED before the split (pins
/// debited the bucket): a flood of pins followed by a CID request → 429
/// on the CID. GREEN after the split: the CID bucket is untouched.
#[sqlx::test]
async fn ipfs_pins_do_not_debit_the_cid_bucket(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let owner_did = kp.did().to_string();
let slug = owner_did.replace([':', '/'], "_");
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
// Bucket size 2: any third /ipfs/{cid} request from the same IP would
// 429 if the bucket were touched by the preceding pin requests.
state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(2, Duration::from_secs(3600));
state.ipfs_work_rate_limiter =
crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor;
// Seed a public, walk-free CID so /ipfs/{cid} serves cheaply. This
// matters because the proof half of the test fires a real CID request
// and needs the route bucket to be the only thing that can 429 it.
let fx = seed_cid_repos(&slug, &short, &["pinsdebit"]);
let bare = std::path::PathBuf::from("/tmp")
.join(&slug)
.join("pinsdebit.git");
let repo = seed_repo(&owner_did, "pinsdebit");
state.db.create_repo(&repo).await.expect("seed repo");
let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
let router = crate::server::build_router(state);
let peer_ip = "203.0.113.50";
// Five signed pin requests — more than the bucket — all must 200 (the
// pins route has no rate limit; the bucket only charges /ipfs/{cid}).
let pins_path = "/api/v1/ipfs/pins";
let pins_signed = sign_request(&kp, "GET", pins_path, b"");
for i in 0..5 {
let req = Request::builder()
.method(Method::GET)
.uri(pins_path)
.header("content-digest", &pins_signed.content_digest)
.header("signature-input", &pins_signed.signature_input)
.header("signature", &pins_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"pin request {i} must not be rate-limited even though the CID bucket is size 2"
);
}
// A subsequent /ipfs/{cid} request from the same IP must still serve
// 200 — the bucket was untouched by the pin traffic. (If pins had
// debited it, the bucket would be empty and the CID request would 429.)
let cid_path = format!("/ipfs/{cid}");
let cid_signed = sign_request(&kp, "GET", &cid_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(&cid_path)
.header("content-digest", cid_signed.content_digest)
.header("signature-input", cid_signed.signature_input)
.header("signature", cid_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the CID bucket must still have tokens — pin traffic did not debit it"
);
}
/// #134 R2 (split buckets, second half): an EXHAUSTED CID bucket must
/// NOT block a valid signed `/api/v1/ipfs/pins` listing. The pre-fix
/// composition routed pins through the same middleware stack, so a CID
/// flood that exhausted the bucket would 429 the pins endpoint too. RED
/// before the split: a CID request that 429s the bucket makes the next
/// pin request 429 too. GREEN after the split: pins never see this
/// bucket and remain servable.
#[sqlx::test]
async fn ipfs_pins_survive_an_exhausted_cid_bucket(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let owner_did = kp.did().to_string();
let slug = owner_did.replace([':', '/'], "_");
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
// Bucket size 1: the first /ipfs/{cid} request from the IP spends the
// only token; the second is 429 (proves exhaustion).
state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(3600));
state.ipfs_work_rate_limiter =
crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600));
state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor;
let fx = seed_cid_repos(&slug, &short, &["pinsexh"]);
let bare = std::path::PathBuf::from("/tmp")
.join(&slug)
.join("pinsexh.git");
let repo = seed_repo(&owner_did, "pinsexh");
state.db.create_repo(&repo).await.expect("seed repo");
let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
let router = crate::server::build_router(state);
let peer_ip = "203.0.113.60";
// First /ipfs/{cid} request — debits the single CID token, serves 200.
let cid_path = format!("/ipfs/{cid}");
let cid_signed = sign_request(&kp, "GET", &cid_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(&cid_path)
.header("content-digest", cid_signed.content_digest)
.header("signature-input", cid_signed.signature_input)
.header("signature", cid_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the first CID request spends the token and serves"
);
// Second /ipfs/{cid} request — bucket empty → 429 (proves the
// exhaustion is real and observable on this router). Re-sign because
// `SignedHeaders` holds owned `String`s.
let cid_signed_2 = sign_request(&kp, "GET", &cid_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(&cid_path)
.header("content-digest", cid_signed_2.content_digest)
.header("signature-input", cid_signed_2.signature_input)
.header("signature", cid_signed_2.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::TOO_MANY_REQUESTS,
"the second CID request from the same IP is 429 — bucket exhausted"
);
// A signed /api/v1/ipfs/pins request from the SAME IP must still
// serve 200 — pins are auth-only and never see this bucket.
let pins_path = "/api/v1/ipfs/pins";
let pins_signed = sign_request(&kp, "GET", pins_path, b"");
let req = Request::builder()
.method(Method::GET)
.uri(pins_path)
.header("content-digest", pins_signed.content_digest)
.header("signature-input", pins_signed.signature_input)
.header("signature", pins_signed.signature)
.header("x-forwarded-for", peer_ip)
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"an exhausted CID bucket must not block a valid signed pin listing"
);
}
/// U5 (R6): the two buckets are independent — the WORK budget can be exhausted
/// (429) WITHOUT draining the ROUTE bucket. Through the production router, route
/// generous (5) but work tight (1): one request drives two legacy probes, so the
+89 -5
View File
@@ -163,8 +163,8 @@ pub fn setup_partial_clone(
if withheld_globs.is_empty() {
match branch {
Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?,
None => git_global(&["clone", "-q", remote_url, dest_str])?,
Some(b) => git_global(&["clone", "-q", "--branch", b, "--", remote_url, dest_str])?,
None => git_global(&["clone", "-q", "--", remote_url, dest_str])?,
}
return Ok(());
}
@@ -174,6 +174,7 @@ pub fn setup_partial_clone(
"-q",
"--filter=blob:none",
"--no-checkout",
"--",
remote_url,
dest_str,
])?;
@@ -388,7 +389,7 @@ async fn recover_encrypted_blobs(
let plaintext = match open_blob(&envelope, keypair) {
Ok(p) => p,
Err(e) => {
eprintln!("warning: could not decrypt {oid}: {e}");
emit_warning(&format!("warning: could not decrypt {oid}: {e}"));
continue;
}
};
@@ -406,7 +407,9 @@ async fn recover_encrypted_blobs(
recovered.push(p.clone());
}
} else {
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
emit_warning(&format!(
"warning: recovered blob {oid} hashed to {written}; discarding"
));
}
}
Ok(recovered)
@@ -804,7 +807,9 @@ async fn recover_from_arweave(
recovered.push(p.clone());
}
} else {
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
emit_warning(&format!(
"warning: recovered blob {oid} hashed to {written}; discarding"
));
}
}
Ok(recovered)
@@ -1090,6 +1095,85 @@ mod tests {
);
}
#[test]
fn setup_partial_clone_plain_without_withheld_paths() {
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
let dest = td.path().join("dest");
setup_partial_clone(&dest, &url, &[], &[], None).unwrap();
assert!(dest.join("file.txt").exists());
}
#[test]
fn setup_partial_clone_with_branch_without_withheld_paths() {
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
let branch_out = Command::new("git")
.args([
"-C",
td.path().join("origin").to_str().unwrap(),
"branch",
"--show-current",
])
.output()
.unwrap();
let branch = String::from_utf8(branch_out.stdout)
.unwrap()
.trim()
.to_string();
let dest = td.path().join("dest");
setup_partial_clone(&dest, &url, &[], &[], Some(&branch)).unwrap();
assert!(dest.join("file.txt").exists());
}
/// #374: every `setup_partial_clone` shape interpolates `remote_url` into a
/// `git clone` argv. The `--` delimiter is what forces an option-shaped
/// remote to be read as a repository rather than parsed as an option.
///
/// The assertion is on git's own words — `repository '<value>'`, single-quoted
/// — and not merely on the value appearing somewhere in the error. `git_global`
/// formats its failure as `git {args:?} failed: {stderr}`, so the injected
/// string is already present in the debug-printed argv whether or not the
/// delimiter is there; asserting on containment alone would pass with `--`
/// deleted. Undelimited, git consumes the value as an option, leaving the
/// destination as the only positional, and names *that* instead
/// (`repository '<dest>' does not exist`) — so this goes red without the fix.
///
/// The payload carries no `:` or space: a colon would make git read the
/// argument as an ssh-style `host:path` URL and report a hostname, which is a
/// different parse.
fn assert_option_shaped_remote_is_a_repository(withheld: &[String], branch: Option<&str>) {
let td = TempDir::new().unwrap();
let dest = td.path().join("dest");
let injected = "--upload-pack=false";
let err = setup_partial_clone(&dest, injected, withheld, &[], branch)
.expect_err("an option-shaped remote is not a repository and must fail")
.to_string();
assert!(
err.contains(&format!("repository '{injected}'")),
"git must name the argument as the repository, not consume it as an option: {err}"
);
}
#[test]
fn plain_clone_never_parses_a_remote_as_a_git_option() {
assert_option_shaped_remote_is_a_repository(&[], None);
}
#[test]
fn branch_clone_never_parses_a_remote_as_a_git_option() {
// Pins that `--branch <b>` stays ahead of the delimiter: if `--` were moved
// before `--branch`, git would read the branch name as a positional instead.
assert_option_shaped_remote_is_a_repository(&[], Some("main"));
}
#[test]
fn sparse_clone_never_parses_a_remote_as_a_git_option() {
// The withheld-globs arm is a separate argv construction
// (`--filter=blob:none --no-checkout`) and needs its own proof.
assert_option_shaped_remote_is_a_repository(&["/secret/**".to_string()], None);
}
#[test]
fn sparse_patterns_subtree_and_exact() {
assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]);
+51 -2
View File
@@ -41,6 +41,17 @@ pub struct MirrorArgs {
pub dir: Option<PathBuf>,
}
/// Argv for the mirror clone, with `--` immediately before the first positional
/// so an option-shaped `source` cannot be parsed as a git option (#374).
///
/// This is a separate function purely so the delimiter's placement can be driven
/// through real git by a test. The production call keeps `.status()` rather than
/// `.output()`: a mirror clone of a large repo streams git's progress to the
/// user's terminal, and capturing it to assert on stderr here would swallow that.
fn mirror_clone_args(source: &str) -> [&str; 4] {
["clone", "--mirror", "--", source]
}
pub async fn run(args: MirrorArgs) -> Result<()> {
let source = args.source.trim_end_matches('/').to_string();
@@ -145,7 +156,7 @@ fn clone_mirror(source: &str, dest: &std::path::Path) -> Result<()> {
// bounded-runner rule governs node request handlers. Same spawn as before,
// only relocated into this helper.
let status = Command::new("git")
.args(["clone", "--mirror", source])
.args(mirror_clone_args(source))
.arg(dest)
.status()
.context("failed to run git clone — is git installed?")?;
@@ -186,6 +197,43 @@ pub fn extract_repo_name(url: &str) -> Option<String> {
mod tests {
use super::*;
/// #374: `gl mirror` interpolates the user-supplied source into a
/// `git clone --mirror` argv. `--` must sit immediately before the first
/// positional so an option-shaped source is read as a repository.
///
/// This drives the production argv through real git rather than asserting on
/// the array's contents, so it fails on a delimiter that is missing *or*
/// misplaced. It cannot go through `run()`: that loads an identity keypair and
/// contacts a node before reaching the clone, and its bail message interpolates
/// `{source}` unconditionally, so an assertion there would pass with `--` gone.
///
/// Asserting on git's single-quoted `repository '<value>'` is what makes this
/// red without the delimiter — undelimited, git consumes the value as an option
/// and names the destination as the repository instead.
#[test]
fn mirror_clone_never_parses_the_source_as_a_git_option() {
let td = tempfile::TempDir::new().unwrap();
let dest = td.path().join("mirror.git");
let injected = "--upload-pack=false";
let out = Command::new("git")
.env("LC_ALL", "C")
.args(mirror_clone_args(injected))
.arg(&dest)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"an option-shaped source is not a repository and must fail the clone"
);
assert!(
stderr.contains(&format!("repository '{injected}'")),
"git must name the source as the repository, not consume it as an option: {stderr}"
);
}
#[test]
fn test_extract_github_url() {
assert_eq!(
@@ -330,7 +378,8 @@ mod tests {
.create_async()
.await;
// We can't easily test the git subprocess calls, but we can test the
// The git subprocess argv is pinned separately by
// `mirror_clone_never_parses_the_source_as_a_git_option`; this covers the
// API error path by calling the create step directly via NodeClient.
let kp2 = gitlawb_core::identity::Keypair::generate();
let client = NodeClient::new(server.url(), Some(kp2));