Compare commits

..
Author SHA1 Message Date
beardthelion 6cc97e48f2 Merge remote-tracking branch 'origin/main' into tmp-412-merge
# Conflicts:
#	crates/gitlawb-node/src/api/repos.rs
#	crates/gitlawb-node/src/git/store.rs
#	crates/gitlawb-node/src/test_support.rs
2026-11-04 21:20:46 -06:00
beardthelion 02fba70bc6 fix(node): validate the fork name before spending the proof
The barrier landed after `proof.consume` and after `repo_store.acquire`, so a
name the character allowlist admits but `validate_repo_name` refuses burned a
valid iCaptcha proof and paid a source-repo download before its 400. The
handler's own header comment promises the opposite: a fork rejected for a bad
name never burns a proof. Reported by CodeRabbit on the review of a7257dce.

It now runs with the other admissibility checks, above both.

The regression test pins the ORDER without iCaptcha plumbing. Seed a repo row
whose bytes are not on disk: late validation lets the acquire fail first and
the caller sees a 500 from git, so only early validation can produce the 400
the test asserts. Verified load-bearing by degrading the early call to
`unwrap_or_else`, which reddens it.

Both name rules are kept. CodeRabbit also suggested deleting the character
allowlist as a strict subset of `validate_repo_name`, and that is not the
relation between them: the allowlist rejects a dot, so `v1.2.3` and `my.repo`
are refused today while `validate_repo_name` accepts both, and the allowlist
accepts a non-ASCII alphanumeric that `validate_repo_name` rejects. Removing
it would newly admit dotted fork names, which is a behaviour change and not a
cleanup. A comment now records why the two coexist.

The empty-name fixture also generates its owner DID per run. It writes to a
fixed `/tmp/<owner_slug>`, so a shared constant let two concurrent `cargo
test` processes delete each other's repo mid-test (reported by Greptile).
2026-09-07 22:54:52 -05:00
beardthelion a7257dce20 fix(node): hold fork to the same repo-name rules as every other creation route
fork_repo built its clone destination with store::repo_disk_path, a raw
join with no validation, while create_repo and the sync mirror path both
go through validated_repo_disk_path. Its own name check is
`.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')`, which is
Unicode-aware and has no length or leading-character rule, so it admits
four shapes validate_repo_name refuses: the empty string (all() is
vacuously true on it), a non-ASCII alphanumeric such as "cafe" with an
acute e, a leading '-', and a name over the 100-char bound.

The empty name was the reachable defect. Driven through the production
router with a real Ed25519 signature, a `{"name":""}` fork returned 201
Created with a repo row whose name is "" and a clone_url ending in
`/.git`, materialized at `<repos_dir>/<owner_slug>/.git`. It now returns
400 and creates neither the row nor the directory.

This is a deliberate narrowing, not only an empty-name fix: the other
three shapes above are now 400 on fork where they previously reached the
raw join. None of them could ever have been created through create_repo,
which fails at repo_store.init on the same validator, and no existing row
can carry such a name (create_repo validates before its DB insert, and
mirror rows go through validate_repo_slug), so no repo becomes
unforkable. A fork that defaults to source.name is unaffected.

The traversal shape of the same join is NOT reachable and is not what
this changes: forker_did comes from AuthenticatedDid, which auth/mod.rs
inserts only after Did::to_verifying_key resolves a multibase-decodable
did:key, so it can carry neither ".." nor "/".

repo_disk_path is now cfg(test). It has no production caller left, and
the attribute turns the next one into a compile error rather than a
silent bypass. Tests keep it because a fixture that must escape repos_dir
cannot be built with the validated form.

Two tests. A unit test on the mounted handler, and an end-to-end test
through server::build_router with real signatures that covers all four
refused shapes plus a positive control asserting an ordinary name still
forks to disk and to a row. Both are mutation-verified in both
directions: reverting to the raw join reddens the refusals, and forcing
fork to always refuse reddens the control.

Closed #272 fixed this class on the sync route and pointed at
repo_disk_path as the sanitizing convention; it is in fact the
unvalidated join, and the fork route was never scoped.
2026-09-07 20:45:37 -05:00
97 changed files with 1564 additions and 28565 deletions

No files matched your search

+2 -21
View File
@@ -39,12 +39,6 @@ GITLAWB_KEY=/data/keys/identity.pem
# Publicly reachable URL of this node (used in peer announcements)
GITLAWB_PUBLIC_URL=https://your-node.example.com
# Base URL for the web view of repos on this node (used by `gl` to print a
# working View: link after repo creation). Omit for nodes with no web front-end.
# Distinct from GITLAWB_PUBLIC_URL: that is API reachability for peers;
# this is browser reachability for humans.
# GITLAWB_WEB_URL=https://gitlawb.com
# ── Server ────────────────────────────────────────────────────────────────
GITLAWB_HOST=0.0.0.0
GITLAWB_PORT=7545
@@ -162,8 +156,8 @@ GITLAWB_ENFORCE_OWNER_PUSH=true
GITLAWB_P2P_BOOTSTRAP=
# ── Access control ────────────────────────────────────────────────────────
# Reserved and currently inert. Repo reads are gated per repository by
# is_public and path-scoped visibility rules; this flag changes nothing.
# Reserved for private-read mode. Public/private repo read enforcement is not
# wired in the current live release; do not rely on this for private repositories.
GITLAWB_PUBLIC_READ=true
# Maximum git smart-HTTP pack request size, in bytes.
@@ -326,19 +320,6 @@ GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
# from the concurrency caps above). 0 disables. Default 600.
GITLAWB_IPFS_RATE_LIMIT=600
# Per-client-IP rate limit for the anonymous task read routes
# (GET /api/v1/tasks, GET /api/v1/tasks/{id}), in requests per hour.
# Both are publicly reachable (optional_signature). GET /api/v1/tasks runs
# collect_visible_tasks and returns a visibility-filtered page;
# GET /api/v1/tasks/{id} runs get_visible_task and returns an opaque 404 when
# the task is hidden or missing. These reads can require task, repository,
# and visibility-rule queries even when no task is returned, so the brake
# bounds the cost of anonymous probes. Keyed on the resolved client IP via
# GITLAWB_TRUSTED_PROXY. 0 disables. Default: 1200 (a list page
# followed by per-task reads is a normal client pattern, so this sits above
# the /ipfs budget). The GraphQL/WS task-read brake shares this budget.
GITLAWB_TASK_READ_RATE_LIMIT=1200
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
-16
View File
@@ -68,22 +68,6 @@ jobs:
- name: Test release tag resolver
run: scripts/test-resolve-release-tag.sh
triage-detector:
name: triage detector matrix
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# pr-triage.yml runs on pull_request_target and never checks out PR
# code, so its inline detector can only be exercised here, where the
# proposed workflow body IS the checked-out one.
- name: Test inline-test detector
run: node scripts/test-pr-triage-detect.mjs
test:
name: test (${{ matrix.toolchain }})
runs-on: ubuntu-latest
+3 -215
View File
@@ -76,221 +76,9 @@ jobs:
// line after horizontal whitespace, covering bare, cfg(test), namespaced,
// optional leading ::, and raw-identifier (r#test) forms. No head-file fetch
// or lexer: needs-tests is advisory, not a merge gate.
//
// The final path component may also carry `test` as an underscore-delimited
// segment, so third-party harnesses land without enumerating them one by one
// (#[test_case(1)], #[wasm_bindgen_test]). It is deliberately a segment and
// not a substring: a substring would also match #[contest] / #[latest], and a
// false positive here SUPPRESSES needs-tests rather than adding it. This
// heuristic has to fail loud, because a wrongly labeled PR gets corrected by
// the author while a wrongly cleared one is invisible. That trade costs the
// no-underscore harness names (#[rstest]), which stay unmatched on purpose.
//
// The segment classes are [A-Za-z0-9]+ rather than \w+ on purpose, so that `_`
// is only ever the literal delimiter. \w+ contains `_`, which makes the repeat
// ambiguous and backtracks exponentially: on this workflow's pull_request_target
// trigger f.patch is fork-controlled, so `#[test` followed by a long `_a` run is
// a permissionless stall of the triage job.
// Rust allows whitespace, line breaks, and comments between the
// attribute path and ] or (. A flat regex cannot decide that
// boundary: `//` runs to end of line, and block comments NEST, which
// is beyond regular languages — every enumeration of comment
// spellings so far has either dropped a legal separator (regressing
// real tests into needs-tests) or let unrelated patch records
// complete each other (clearing needs-tests from a testless PR, the
// invisible direction). So the closer is found by a token-aware
// scan instead: it consumes horizontal whitespace, `//`-to-newline,
// and depth-counted `/* */` runs, and accepts only when the first
// real token after the attribute path is `]` or `(`.
//
// Association is positional: the scan starts on the attribute's own
// added line and may continue ONLY through the immediately following
// added lines (a context line, hunk header, or removal breaks the
// chain). Unrelated `]`/`(` lines elsewhere in the patch can never
// complete a path they do not adjoin. The continuation is bounded;
// past the bound the scan gives up and reports NO inline test, which
// fails toward the loud label rather than the silent clearance.
//
// The detector body is fenced by the TRIAGE_DETECTOR markers so
// scripts/test-pr-triage-detect.mjs can extract and run it against
// the committed case matrix. Keep the fenced block self-contained.
// TRIAGE_DETECTOR_BEGIN
const TEST_ATTR_PATH =
"(?:cfg[ \\t]*\\([ \\t]*test[ \\t]*\\)|(?:::[ \\t]*)?(?:[\\w-]+[ \\t]*::[ \\t]*)*(?:r#)?(?:[A-Za-z0-9]+_)*test(?:_[A-Za-z0-9]+)*)";
// Anchored to the start of one added line; group 1 is whatever
// follows the path on that line, handed to the separator scan.
const TEST_ATTR_LINE = new RegExp(
"^[ \\t]*#\\[[ \\t]*" + TEST_ATTR_PATH + "([\\s\\S]*)$"
);
// An attribute split across more added lines than this is not a
// spelling anyone writes; refusing to scan further keeps the walk
// linear in the patch and fails toward the loud label.
const MAX_ATTR_CONTINUATION_LINES = 16;
// segs[0] is the remainder of the attribute's own line; each later
// entry is the text of one immediately following added line. True
// only when the first non-separator token across them is ] or (.
// Single forward pass, no backtracking: fork-controlled input.
function attrCloserFollows(segs) {
let depth = 0; // open block-comment nesting carried across lines
for (const seg of segs) {
let i = 0;
while (i < seg.length) {
if (depth > 0) {
if (seg.startsWith("/*", i)) { depth += 1; i += 2; }
else if (seg.startsWith("*/", i)) { depth -= 1; i += 2; }
else { i += 1; }
continue;
}
const c = seg[i];
if (c === " " || c === "\t") { i += 1; continue; }
if (seg.startsWith("//", i)) break; // comment to end of line
if (seg.startsWith("/*", i)) { depth = 1; i += 2; continue; }
return c === "]" || c === "(";
}
// Line exhausted inside separators; the newline is itself a
// separator, so continue on the next added line.
}
return false; // out of adjoined lines (or bound hit): stay loud
}
// Before the candidate scan, a pre-pass walks added patch lines
// tracking Rust lexical state: raw strings (r#*", br#*", cr#*"),
// block comments (/* */ with nesting), regular strings ("...",
// which can span newlines), and line comments (//). An added line
// that starts inside any of these is skipped as a candidate,
// because a #[test_case] appearing there is lexical non-code, not
// a real test attribute. State is only tracked across consecutive
// added lines and reset at any break (context line, removal,
// hunk header): context lines carry a partial view of the file,
// so a " or r#" or /* there might be inside a string that opened
// before the hunk's visible window, and treating it as an opener
// would mark every later added line as non-code (a false negative
// on a real #[test]). The tradeoff is that a non-code region
// opened by a context line is invisible: an added #[test] inside
// an existing raw string or block comment is scanned as code and
// may match (a false positive, silent direction). This is accepted
// because (1) the old detector had the same behavior, (2) the
// pattern is rare, and (3) needs-tests is advisory, not a merge
// gate. Scanning context lines to close this gap would re-introduce
// the false negative, which affects the far more common case of a
// context line carrying a stray " from a string that opened before
// the visible window. Single forward pass, O(n) in patch length.
function patchAddsInlineTest(patch) {
const lines = patch.split("\n");
const nonCodeStart = new Set();
let rawHashes = -1; // -1 = not in raw string; >=0 = # count
let blockDepth = 0;
let inString = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const prefix = line[0];
// Only scan added lines. Context and removal lines carry
// a partial view of the file: a " or r#" or /* on a context
// line might be inside a string that opened before the
// hunk's visible window, and treating it as an opener would
// mark every later added line as non-code (a false negative
// on a real #[test]). So lexical state is only tracked across
// consecutive added lines and reset at any break. A non-code
// region opened by a context line is invisible; the candidate
// is still scanned, which fails toward the loud label.
if (prefix !== "+") {
rawHashes = -1; blockDepth = 0; inString = false;
continue;
}
const text = line.slice(1);
if (rawHashes >= 0 || blockDepth > 0 || inString)
nonCodeStart.add(i);
let j = 0;
while (j < text.length) {
if (rawHashes >= 0) {
if (text[j] === '"') {
let h = 1;
while (h <= rawHashes && text[j + h] === "#") h++;
if (h > rawHashes) { rawHashes = -1; j += h; continue; }
}
j += 1; continue;
}
if (blockDepth > 0) {
if (text.startsWith("/*", j)) { blockDepth += 1; j += 2; }
else if (text.startsWith("*/", j)) { blockDepth -= 1; j += 2; }
else { j += 1; }
continue;
}
if (inString) {
if (text[j] === "\\") { j += 2; continue; }
if (text[j] === '"') { inString = false; j += 1; continue; }
j += 1; continue;
}
// Recognize Rust char and byte-char literals ('X', '\X',
// '\u{...}', b'X') so their contents never open string or
// comment state. A char literal has a closing ' within a
// bounded distance; a lifetime or label ('a, 'static,
// 'label:) does not, so it falls through as a regular char.
// This is not a skip-to-next-' rule: the lookahead is 2-4
// chars, or bounded to 20 for \u escapes (at most 6 hex
// digits), never unbounded.
if (text[j] === "'") {
let k = j + 1;
if (text[k] === "\\") {
k += 1;
if (text[k] === "u" && text[k + 1] === "{") {
const uBound = k + 20;
while (k < uBound && text[k] && text[k] !== "}") k++;
if (text[k] === "}") k++;
} else {
k += 1;
}
} else {
k += 1;
}
if (text[k] === "'") { j = k + 1; continue; }
// lifetime or label: just skip the apostrophe
}
if (text[j] === '"') { inString = true; j += 1; continue; }
if (text.startsWith("//", j)) break;
if (text.startsWith("/*", j)) { blockDepth = 1; j += 2; continue; }
const prev = j > 0 ? text[j - 1] : "";
const isIdent = /[A-Za-z0-9_]/.test(prev);
if (!isIdent && text[j] === "r") {
let k = j + 1, h = 0;
while (text[k + h] === "#") h++;
if (text[k + h] === '"') {
rawHashes = h; j = k + h + 1; continue;
}
}
if (!isIdent && (text[j] === "b" || text[j] === "c") && text[j + 1] === "r") {
let k = j + 2, h = 0;
while (text[k + h] === "#") h++;
if (text[k + h] === '"') {
rawHashes = h; j = k + h + 1; continue;
}
}
j += 1;
}
}
for (let i = 0; i < lines.length; i++) {
if (lines[i][0] !== "+") continue;
if (nonCodeStart.has(i)) continue;
const m = TEST_ATTR_LINE.exec(lines[i].slice(1));
if (m === null) continue;
const segs = [m[1]];
for (
let j = i + 1;
j < lines.length &&
lines[j][0] === "+" &&
segs.length <= MAX_ATTR_CONTINUATION_LINES;
j++
) {
segs.push(lines[j].slice(1));
}
if (attrCloserFollows(segs)) return true;
}
return false;
}
// TRIAGE_DETECTOR_END
const addsInlineTest = files.some(
f => f.filename.endsWith(".rs") && !!f.patch && patchAddsInlineTest(f.patch)
);
const addsInlineTest = files.some(f =>
f.filename.endsWith(".rs") && f.patch &&
/^\+[ \t]*#\[[ \t]*(?:cfg[ \t]*\([ \t]*test[ \t]*\)|(?:::[ \t]*)?(?:[\w-]+[ \t]*::[ \t]*)*(?:r#)?test\b)/m.test(f.patch));
const touchedTests =
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
if (changedRust && !touchedTests) want.add("needs-tests");
+34 -231
View File
@@ -20,15 +20,7 @@ on:
type: string
concurrency:
# Not keyed on the ref: two runs racing `imagetools create` on the same
# moving tags could interleave, so runs of this workflow serialize outright.
# GitHub keeps at most one running and one pending run per group; a newer
# queued run replaces the pending one, and cancel-in-progress: false
# protects only the run already executing. So a backfill dispatched while a
# release is running, followed by a push to main, is replaced silently: the
# remedy is to re-dispatch it once the running release completes, which the
# push-only gate on the moving-tag step makes safe in any ordering.
group: release
group: release-${{ github.ref }}
cancel-in-progress: false
# Least privilege by default; every job opts in to exactly what it needs.
@@ -45,7 +37,6 @@ jobs:
permissions:
contents: write
pull-requests: write
environment: release
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
@@ -87,7 +78,6 @@ jobs:
# dispatch the release PR's head carries no check runs and the --locked
# gate never validates it.
actions: write
environment: release
steps:
# Prefer the fresh `pr` output when this run just created/updated the
# release PR; otherwise fall back to querying for an open release-please
@@ -225,7 +215,6 @@ jobs:
permissions:
contents: read
packages: write
environment: release
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -237,7 +226,6 @@ jobs:
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
@@ -249,9 +237,7 @@ jobs:
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Pin the commit the resolver verified; a tag ref re-resolved here
# could have been moved between resolve and checkout.
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
- name: Set up Docker Buildx
@@ -326,7 +312,6 @@ jobs:
permissions:
contents: read
packages: write
environment: release
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -338,14 +323,9 @@ jobs:
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
# ghcr requires a lowercase repository path, and unlike metadata-action,
# buildx's `--output name=` does no lowercasing, so a mixed-case owner
# makes the digest push fail with "invalid reference format".
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
@@ -365,80 +345,24 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
id: manifest
env:
VERSION: ${{ steps.rel.outputs.version }}
IMAGE: ${{ steps.rel.outputs.image }}
run: |
set -euo pipefail
# The docker matrix pushes one digest per arch leg; anything else is
# a broken set, not a smaller multi-arch image.
count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)"
if [ "$count" -ne 2 ]; then
echo "::error::expected 2 arch digests in /tmp/digests, found $count"
exit 1
fi
# ghcr requires a lowercase repository path.
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
MAJOR_MINOR="${VERSION%.*}"
digests=""
for f in /tmp/digests/*; do
d="$(basename "$f")"
case "$d" in
*[!0-9a-f]*)
echo "::error::digest filename is not lowercase sha256 hex: $d"
exit 1
;;
esac
if [ "${#d}" -ne 64 ]; then
echo "::error::digest is not 64 hex chars: $d"
exit 1
fi
digests="$digests $IMAGE@sha256:$d"
digests="$digests $IMAGE@sha256:$(basename "$f")"
done
# The immutable tag always publishes. The moving tags are applied by
# the gated step below, which never runs on workflow_dispatch.
# shellcheck disable=SC2086
docker buildx imagetools create -t "$IMAGE:$VERSION" $digests
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:$MAJOR_MINOR" \
-t "$IMAGE:latest" \
$digests
docker buildx imagetools inspect "$IMAGE:$VERSION"
echo "digests=${digests# }" >> "$GITHUB_OUTPUT"
- name: Move floating tags
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
env:
VERSION: ${{ steps.rel.outputs.version }}
IMAGE: ${{ steps.rel.outputs.image }}
DIGESTS: ${{ steps.manifest.outputs.digests }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
MAJOR_MINOR="${VERSION%.*}"
# :latest and :X.Y track the newest release. Only a push to main
# carries a release-please-cut version, and release-please versions
# are monotonic, so on this path the tags only ever advance. A
# workflow_dispatch backfill republishes an existing tag and must
# never move a pointer, so this step skips on dispatch. After a
# backfill that is genuinely newest in its minor line, advance :X.Y
# by hand: docker buildx imagetools create -t "$IMAGE:X.Y" <digests>.
# The event gate alone does not bound a stale run: re-running an
# older push run replays its stored needs outputs under the
# original event and ref, and would move the pointers backward to
# that run's version. Floor against live state instead: this run's
# release already exists by now, so VERSION must be the newest
# published release or the run is stale and the tags stay put. The
# releases list is ordered by creation, not semver, so read every
# page and take the semver maximum, not the first page's.
tags="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
-q '.[] | select(.draft == false) | .tag_name')" || {
echo "::error::could not list releases to bound the moving tags"
exit 1
}
floor="$(printf '%s\n' "$tags" \
| { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } \
| sed 's/^v//' | sort -V | tail -n 1)"
if [ -n "$floor" ] && [ "$floor" != "$VERSION" ]; then
echo "::error::release v$floor is newer than this run's v$VERSION; refusing to move the moving tags backward (stale re-run?)"
exit 1
fi
# shellcheck disable=SC2086
docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS
- name: Release summary
env:
@@ -462,7 +386,6 @@ jobs:
continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }}
permissions:
contents: write
environment: release
strategy:
fail-fast: false
matrix:
@@ -482,27 +405,10 @@ jobs:
- target: x86_64-pc-windows-msvc
os: windows-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Resolve release tag
id: rel
env:
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash # matrix includes windows-latest, where run: defaults to pwsh
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "$RELEASE_TAG"
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Pin the commit the resolver verified; a tag ref re-resolved here
# could have been moved between resolve and checkout.
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false
- name: Set up Rust toolchain
@@ -548,7 +454,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.rel.outputs.version }}"
VERSION="${{ needs.release-please.outputs.version }}"
BIN_DIR="target/${{ matrix.target }}/release"
EXE=""
case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac
@@ -568,7 +474,7 @@ jobs:
run: |
set -euo pipefail
TARGET="${{ matrix.target }}"
NAME="gitlawb-node-${{ steps.rel.outputs.version }}-${TARGET}"
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}"
BIN_DIR="target/${TARGET}/release"
EXE=""
case "$TARGET" in *windows*) EXE=".exe" ;; esac
@@ -609,7 +515,7 @@ jobs:
- name: Attach to release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: ${{ steps.rel.outputs.tag }}
tag_name: ${{ needs.release-please.outputs.tag_name }}
files: |
dist/*.tar.gz
dist/*.zip
@@ -622,9 +528,7 @@ jobs:
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
# @gitlawb package must have this repo + workflow (release.yml) configured
# as its trusted publisher on npmjs.com, scoped to the release environment
# so a dispatched copy of this file on another ref cannot mint a token, or
# publish fails loudly here —
# as its trusted publisher on npmjs.com, or publish fails loudly here —
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
# never reached npm.
if: >-
@@ -636,7 +540,6 @@ jobs:
permissions:
contents: read
id-token: write # npm trusted publishing (OIDC) + provenance
environment: release
steps:
- name: Check out workflow scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -648,7 +551,6 @@ jobs:
env:
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
@@ -656,9 +558,7 @@ jobs:
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Pin the commit the resolver verified; a tag ref re-resolved here
# could have been moved between resolve and checkout.
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
ref: ${{ steps.rel.outputs.tag }}
persist-credentials: false
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
@@ -671,23 +571,14 @@ jobs:
- name: Ensure npm supports trusted publishing
run: |
set -euo pipefail
npm install -g npm@11.19.1
# Assert the pin took, so an accidental unpin or a drift off 11.19.1
# fails loudly here instead of silently running a different npm on
# the OIDC path.
installed="$(npm --version)"
echo "npm installed: $installed"
if [ "$installed" != "11.19.1" ]; then
echo "::error::npm is not the pinned 11.19.1 (got: $installed)"
exit 1
fi
npm install -g npm@^11.5.1
npm --version
- name: Lay in release binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
ASSETS: ${{ steps.rel.outputs.assets }}
run: |
set -euo pipefail
# npm platform package -> Rust target triple (unix only; Windows is not
@@ -704,18 +595,8 @@ jobs:
target="${entry#*:}"
archive="gitlawb-node-${VERSION}-${target}.tar.gz"
echo "==> $pkg <- $archive"
# Download by the asset id the resolver captured and uploader-
# checked, not by name: release assets are mutable, and an id can
# only ever point at the exact blob captured at resolve time.
# The map keys are base64-encoded asset names.
want="$(printf '%s' "$archive" | base64 -w0)"
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
if [ -z "$asset_id" ]; then
echo "::error::release $TAG has no asset $archive captured at resolve time"
exit 1
fi
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
-H 'Accept: application/octet-stream' > "_dl/$archive"
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$archive" --dir _dl --clobber
tar -xzf "_dl/$archive" -C _dl
src="_dl/gitlawb-node-${VERSION}-${target}"
cp "$src/gl" "npm/packages/$pkg/gl"
@@ -754,39 +635,14 @@ jobs:
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
# Skip versions already on the registry so a rerun after a partial publish
# is idempotent instead of erroring on the first existing package.
# npm publish moves the latest dist-tag to whatever it publishes, so a
# backfilled older version would hand :latest to stale code. Decide
# the dist-tag per package from that package's own registry latest:
# a partial publish can leave the platform packages ahead of the
# wrapper. E404 means the package has never been published, so this
# release gets latest; any other lookup failure must fail closed
# rather than guess at the dist-tag and risk moving latest backward.
# stderr goes to a file so a warning on a successful lookup cannot
# leak into registry_latest; it is read only when the lookup fails.
npm_err="$(mktemp)"
trap 'rm -f -- "$npm_err"' EXIT
for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do
name="@gitlawb/$pkg"
if npm view "$name@$VERSION" version >/dev/null 2>&1; then
echo "==> $name@$VERSION already published, skipping"
continue
fi
registry_latest="$(npm view "$name" dist-tags.latest 2>"$npm_err")" || {
if ! grep -q E404 "$npm_err"; then
cat "$npm_err" >&2
echo "::error::npm dist-tags lookup failed for $name; not guessing the dist-tag"
exit 1
fi
registry_latest=""
}
dist_tag="latest"
if [ -n "$registry_latest" ] && \
[ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then
dist_tag="backfill"
echo "::notice::$VERSION is older than $name@$registry_latest; publishing under dist-tag backfill"
fi
echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)"
npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag"
echo "==> npm publish $name@$VERSION"
npm publish "npm/packages/$pkg" --provenance --access public
done
homebrew-bump:
@@ -796,7 +652,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT
environment: release
steps:
- name: Guard on secret
id: guard
@@ -810,22 +665,6 @@ jobs:
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Check out workflow scripts
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Resolve release tag
if: ${{ steps.guard.outputs.enabled == 'true' }}
id: rel
env:
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "$RELEASE_TAG"
- name: Checkout tap repo
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -839,32 +678,15 @@ jobs:
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
ASSETS: ${{ steps.rel.outputs.assets }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
mkdir -p _sums
# Hash the tarballs themselves, fetched by the asset id the resolver
# captured and uploader-checked. The *.sha256 sidecars are mutable
# release assets too; trusting them would trust the same surface.
sha() {
archive="gitlawb-node-${VERSION}-$1.tar.gz"
want="$(printf '%s' "$archive" | base64 -w0)"
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
if [ -z "$asset_id" ]; then
echo "::error::release $TAG has no asset $archive captured at resolve time" >&2
exit 1
fi
# errexit is off inside the $(...) this runs under, so a failed
# download must exit the function itself instead of hashing a
# truncated file.
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
-H 'Accept: application/octet-stream' > "_sums/$archive" \
|| exit 1
sha256sum "_sums/$archive" | awk '{print $1}'
}
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern '*.tar.gz.sha256' --dir _sums --clobber
sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; }
SHA_MAC_ARM="$(sha aarch64-apple-darwin)"
SHA_MAC_X64="$(sha x86_64-apple-darwin)"
SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)"
@@ -925,7 +747,7 @@ jobs:
working-directory: tap
env:
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
VERSION: ${{ steps.rel.outputs.version }}
VERSION: ${{ needs.release-please.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
@@ -947,7 +769,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT
environment: release
steps:
- name: Guard on secret
id: guard
@@ -961,29 +782,11 @@ jobs:
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Check out workflow scripts
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Resolve release tag
if: ${{ steps.guard.outputs.enabled == 'true' }}
id: rel
env:
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
scripts/resolve-release-tag.sh "$RELEASE_TAG"
- name: Checkout node (release tag)
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Pin the commit the resolver verified; a tag ref re-resolved here
# could have been moved between resolve and checkout.
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
ref: ${{ needs.release-please.outputs.tag_name }}
path: node
persist-credentials: false
@@ -1003,8 +806,8 @@ jobs:
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
@@ -1022,8 +825,8 @@ jobs:
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }}
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
# Canonical install scripts (source of truth lives in the node repo).
Generated
+8 -12
View File
@@ -2208,7 +2208,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2701,7 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@@ -3085,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3563,7 +3563,6 @@ dependencies = [
"libp2p-swarm",
"mockito",
"multiaddr",
"percent-encoding",
"prometheus",
"reqwest",
"serde",
@@ -3581,7 +3580,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"unicode-normalization",
"url",
"uuid",
"zeroize",
"zstd",
@@ -3601,7 +3599,6 @@ dependencies = [
"gitlawb-core",
"icaptcha-client",
"mockito",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
@@ -3610,7 +3607,6 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
"uuid",
]
@@ -3646,9 +3642,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.19"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [
"atomic-waker",
"bytes",
@@ -6227,7 +6223,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7161,7 +7157,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7953,7 +7949,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -3
View File
@@ -54,10 +54,8 @@ uuid = { version = "1", features = ["v4"] }
# http client
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
# take a parsed URL without pulling reqwest into gitlawb-core; also used for the
# absolute-browser-url validation on GITLAWB_WEB_URL in the node and CLI)
# take a parsed URL without pulling reqwest into gitlawb-core)
url = "2"
percent-encoding = "2"
# HMAC
hmac = "0.12"
+5 -7
View File
@@ -66,12 +66,12 @@ Good today:
Known limitations:
- Repository write authorization is secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` only for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint a `did:key` and sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included.
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Task list/get reads (REST and GraphQL) are repository/task-gated and omit `ucan_token`. Read visibility is not a blanket data-classification boundary: IPFS-pin and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
- Current GraphQL mutations require an authenticated signer, but there is no mutation-specific guardrail that prevents a future mutation from omitting that check.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
See:
@@ -311,7 +311,6 @@ metadata local disk / optional S3
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
| UCAN | Delegation token for future capability-based workflows. |
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
| Gossipsub | libp2p topic for ref-update events. |
@@ -401,7 +400,7 @@ Important node settings:
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `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_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_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. |
@@ -420,7 +419,6 @@ Important node settings:
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
| `GITLAWB_TASK_READ_RATE_LIMIT` | Max anonymous task-read requests (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`) per client IP per hour, sharing the budget with the GraphQL/WS task-read brake. Uses `GITLAWB_TRUSTED_PROXY` to resolve the client IP. 0 disables. Default 1200 (above the `/ipfs` budget to allow list pages followed by per-task reads). |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
@@ -555,7 +553,7 @@ Short-term priorities:
2. Add Docker and installer smoke tests.
3. Improve operator docs and `gl doctor` checks.
4. Harden peer writes and publish the signed-peer rollout plan.
5. Wire trusted UCAN delegation into repository write authorization.
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
+11 -11
View File
@@ -26,7 +26,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
**Content addressing**
- Every git object is content-addressed via CIDv1 (SHA-256)
- Tamper-evident by construction: a modified object changes its CID
- Tamper-evident by construction — a modified object changes its CID
**UCAN token validation**
- Bootstrap UCAN tokens are issued at registration.
@@ -35,8 +35,8 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
**Smart contracts (Base Sepolia testnet)**
- `GitlawbDIDRegistry`: on-chain DID → document registry
- `GitlawbNameRegistry`: human name → DID registry
- `GitlawbDIDRegistry` — on-chain DID → document registry
- `GitlawbNameRegistry` — human name → DID registry
- Both auditable on-chain, no admin keys
---
@@ -54,9 +54,9 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
### Repository write authorization defaults
- `git-receive-pack` verifies HTTP Signatures and, with the default configuration, also requires the pusher DID to be the repository owner: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`.
- **Impact:** With the default setting, a valid signature authenticates the pusher, and the pusher must be the repository owner. Authentication is not authorization on its own, since anyone can mint a `did:key` and sign, so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, including private ones.
- **Mitigation:** `GITLAWB_ENFORCE_OWNER_PUSH=false` is the explicit rolling-upgrade compatibility override. Disable it only for the duration of an upgrade whose pushers are not yet the repo owner, then turn it back on. 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.
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
### UCAN delegation and revocation
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
@@ -73,7 +73,7 @@ These are documented limitations of the current live release. They should be pri
### Private repository reads
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
- Task list/get reads (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`, and GraphQL `tasks` / `task` over HTTP and WebSocket) are repository/task-gated through shared visibility collectors and omit `ucan_token`. All of those transports share the same per-IP `GITLAWB_TASK_READ_RATE_LIMIT` budget. `/api/v1/ipfs/pins` and `/api/v1/arweave/anchors` are not repository-gated; pin and anchor listings expose object and ref metadata.
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
@@ -119,10 +119,10 @@ These are documented limitations of the current live release. They should be pri
## Threat Model
Twigpine is designed to be secure against:
- **Unauthenticated writes:** HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Tampered git objects:** CIDv1 content addressing detects modification
- **Identity spoofing:** DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown:** no single point of control; data on IPFS + Arweave
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
- **Tampered git objects** — CIDv1 content addressing detects modification
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
Twigpine is **not yet** designed to defend against:
- A compromised node operator (node operators are trusted for their own node)
+6 -285
View File
@@ -287,10 +287,8 @@ fn handle_connect<R: Read>(
// this into one POST deadlocks a multi-round fetch (#117).
//
// git-receive-pack (push):
// Git sends the ref-update command list (pkt-lines ending in a flush),
// then the complete PACK blob only when a command has a non-zero new-oid.
// A delete-only push sends no pack, and git holds its write pipe open for
// the report-status response, so EOF cannot delimit the request (#369).
// Git sends ref-update commands + the complete PACK blob, then closes its
// write pipe. read_to_end is safe and correct here, a single POST.
let post_url = format!("{}/{}", repo_base, service);
@@ -298,7 +296,10 @@ fn handle_connect<R: Read>(
return negotiate_upload_pack(&client, &post_url, service, signing_key, stdin, &mut stdout);
}
let request_body = read_receive_pack_request(stdin)?;
let mut request_body = Vec::new();
stdin
.read_to_end(&mut request_body)
.context("reading receive-pack request")?;
tracing::debug!("pack request: {} bytes from git", request_body.len());
if request_body.is_empty() {
@@ -534,108 +535,6 @@ fn read_upload_pack_round<R: Read>(stdin: &mut R) -> Result<(Vec<u8>, RoundEnd)>
}
}
/// Read one git-receive-pack request: optional `shallow` lines, then EITHER a
/// command list (pkt-lines terminated by a flush) or a push-cert block
/// (pkt-lines terminated by a "push-cert-end" line), then the negotiated
/// push-options block, then the pack ONLY when a command carries a non-zero
/// new-oid.
///
/// A delete-only push sends no pack, and git holds its write pipe open waiting
/// for the report-status response, so a plain read_to_end deadlocks the two
/// (#369). A signed push is the other trap: when the client sends a push-cert
/// it sends NO command list at all (the commands live inside the cert), so the
/// request starts with a "push-cert" line and no flush ever arrives. The
/// negotiated capability list is on the push-cert line, or on the first
/// command line after its NUL when there is no cert. EOF at any point also
/// ends the read, covering git versions that close the pipe early.
fn read_receive_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
/// Read one pkt-line into `body` (length prefix + data), returning its data
/// or `None` on flush or EOF. Malformed lengths bail rather than collapse
/// into a synthesized flush (#192 F5).
fn read_pkt<R: Read>(stdin: &mut R, body: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
let mut len_bytes = [0u8; 4];
match stdin.read_exact(&mut len_bytes) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e.into()),
}
let Ok(len_hex) = std::str::from_utf8(&len_bytes) else {
bail!("malformed pkt-line length prefix: non-UTF-8 bytes {len_bytes:02x?}");
};
let Ok(pkt_len) = usize::from_str_radix(len_hex, 16) else {
bail!("malformed pkt-line length prefix: non-hex {len_hex:?}");
};
body.extend_from_slice(&len_bytes);
if pkt_len == 0 {
return Ok(None);
}
if pkt_len < 4 {
bail!("invalid pkt-line length: {pkt_len}");
}
let mut data = vec![0u8; pkt_len - 4];
stdin
.read_exact(&mut data)
.context("reading pkt-line data")?;
body.extend_from_slice(&data);
Ok(Some(data))
}
/// "<40-hex old> <40-hex new> <refname>" — the command shape, whether it
/// appears in a command list or inside a push-cert.
fn is_command(data: &[u8]) -> bool {
data.len() >= 82
&& data[..40].iter().all(|b| b.is_ascii_hexdigit())
&& data[40] == b' '
&& data[41..81].iter().all(|b| b.is_ascii_hexdigit())
&& data[81] == b' '
}
let mut body = Vec::new();
let mut caps = String::new();
let mut pack_expected = false;
let mut in_cert = false;
loop {
let Some(data) = read_pkt(stdin, &mut body)? else {
// Flush ends a command list; EOF ends anything. A cert has no
// legal flush inside it, so the same break covers malformed
// input without hanging.
break;
};
if !in_cert && data.starts_with(b"push-cert") {
in_cert = true;
if let Some(nul) = data.iter().position(|&b| b == 0) {
caps = String::from_utf8_lossy(&data[nul + 1..]).into_owned();
}
continue;
}
if in_cert && data == b"push-cert-end\n" {
break;
}
if is_command(&data) {
if caps.is_empty() {
if let Some(nul) = data.iter().position(|&b| b == 0) {
caps = String::from_utf8_lossy(&data[nul + 1..]).into_owned();
}
}
if data[41..81].iter().any(|&b| b != b'0') {
pack_expected = true;
}
}
}
// When both are negotiated the push-options block follows the cert; when
// only options are negotiated it follows the command flush. Either way it
// is pkt-lines to a flush.
if caps.split(' ').any(|c| c.starts_with("push-options")) {
while read_pkt(stdin, &mut body)?.is_some() {}
}
if pack_expected {
stdin
.read_to_end(&mut body)
.context("reading receive-pack pack")?;
}
Ok(body)
}
/// POST one self-contained stateless-RPC request body and stream the response to
/// `stdout`. Signs the request when `signing_key` is present, so every round of a
/// private-repo fetch carries the caller's signature (the node visibility-gates
@@ -2118,184 +2017,6 @@ mod tests {
assert!(help.ends_with('\n'));
}
// ── #369 delete-only push deadlock ───────────────────────────────────────
/// A delete-only receive-pack request ends at the command flush; git then
/// holds the write pipe open for the report and sends no pack. A reader that
/// blocks after the command list reproduces the hang that a Cursor (EOF)
/// hides. The read must return at the flush, inside the timeout.
#[test]
fn delete_only_receive_pack_request_returns_at_command_flush() {
let old = "a".repeat(40);
let zero = "0".repeat(40);
let mut seed = Vec::new();
seed.extend_from_slice(&pkt(&format!("{old} {zero} refs/heads/gone\n")));
seed.extend_from_slice(b"0000"); // command-list flush; git then blocks
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut reader = BlockAfterSeed {
seed: io::Cursor::new(seed),
gate: rx,
};
let (done_tx, done_rx) = std::sync::mpsc::channel::<Vec<u8>>();
let handle = std::thread::spawn(move || {
let _ = done_tx.send(read_receive_pack_request(&mut reader).unwrap());
});
let got = done_rx.recv_timeout(std::time::Duration::from_secs(2));
let _ = tx.send(()); // unblock the reader so the thread can exit
let _ = handle.join();
let body = got.expect(
"read_receive_pack_request blocked past the command flush waiting for \
a pack a delete-only push never sends (#369)",
);
assert!(
body.ends_with(b"0000"),
"the request body keeps the command list and its flush terminator"
);
}
/// A push carrying objects still reads the pack through to EOF, and the
/// command flush sits between the commands and the pack in the body.
#[test]
fn receive_pack_request_with_pack_reads_through_eof() {
let old = "a".repeat(40);
let new = "b".repeat(40);
let mut stream = Vec::new();
stream.extend_from_slice(&pkt(&format!(
"{old} {new} refs/heads/main\0 report-status-v2\n"
)));
stream.extend_from_slice(b"0000");
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
let mut stdin = io::Cursor::new(stream);
let body = read_receive_pack_request(&mut stdin).unwrap();
let pos = body.windows(4).position(|w| w == b"0000").unwrap();
assert_eq!(
&body[pos + 4..],
b"PACK\x00\x00\x00\x02fakepack",
"a non-zero new-oid means the pack follows the flush"
);
}
/// A signed delete-only push sends NO command list: the request starts
/// with the "push-cert" line, the commands live inside the cert, and the
/// cert ends with a "push-cert-end" pkt-line (not a flush). The reader
/// must return after push-cert-end without waiting on the pipe.
#[test]
fn signed_delete_only_push_returns_after_push_cert_end() {
let old = "a".repeat(40);
let zero = "0".repeat(40);
let mut seed = Vec::new();
seed.extend_from_slice(&pkt("push-cert\0 report-status-v2 push-cert\n"));
seed.extend_from_slice(&pkt("certificate version 0.1\n"));
seed.extend_from_slice(&pkt("pusher did:key:z6Mk\n"));
seed.extend_from_slice(&pkt("\n")); // empty line ends the headers
seed.extend_from_slice(&pkt(&format!("{old} {zero} refs/heads/gone\n")));
seed.extend_from_slice(&pkt("sig-line\n"));
seed.extend_from_slice(&pkt("push-cert-end\n"));
// git now blocks for the report; the pipe stays open.
let (tx, rx) = std::sync::mpsc::channel::<()>();
let mut reader = BlockAfterSeed {
seed: io::Cursor::new(seed),
gate: rx,
};
let (done_tx, done_rx) = std::sync::mpsc::channel::<Vec<u8>>();
let handle = std::thread::spawn(move || {
let _ = done_tx.send(read_receive_pack_request(&mut reader).unwrap());
});
let got = done_rx.recv_timeout(std::time::Duration::from_secs(2));
let _ = tx.send(());
let _ = handle.join();
let body = got.expect(
"read_receive_pack_request blocked past push-cert-end on a signed \
delete-only push (#369)",
);
assert!(
body.windows(6).any(|w| w == b"pusher"),
"the cert block is part of the request body"
);
}
/// A signed pack-bearing push: cert with the commands inside, then the
/// pack. Everything must land in the body.
#[test]
fn signed_pack_push_keeps_cert_and_pack() {
let old = "a".repeat(40);
let new = "b".repeat(40);
let mut stream = Vec::new();
stream.extend_from_slice(&pkt("push-cert\0 report-status-v2 push-cert\n"));
stream.extend_from_slice(&pkt("certificate version 0.1\n"));
stream.extend_from_slice(&pkt("\n"));
stream.extend_from_slice(&pkt(&format!("{old} {new} refs/heads/main\n")));
stream.extend_from_slice(&pkt("push-cert-end\n"));
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
let mut stdin = io::Cursor::new(stream);
let body = read_receive_pack_request(&mut stdin).unwrap();
assert!(
body.windows(5).any(|w| w == b"PACK\x00"),
"the pack follows the cert into the body"
);
}
/// A negotiated push-options block is pkt-lines ending in its own flush.
/// With no cert it follows the command flush; with a cert it follows
/// push-cert-end.
#[test]
fn receive_pack_request_consumes_a_push_options_block() {
let old = "a".repeat(40);
let new = "b".repeat(40);
let mut stream = Vec::new();
stream.extend_from_slice(&pkt(&format!(
"{old} {new} refs/heads/main\0 report-status-v2 push-options\n"
)));
stream.extend_from_slice(b"0000"); // command flush
stream.extend_from_slice(&pkt("ci.skip\n"));
stream.extend_from_slice(b"0000"); // options flush
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
let mut stdin = io::Cursor::new(stream);
let body = read_receive_pack_request(&mut stdin).unwrap();
assert!(body.windows(7).any(|w| w == b"ci.skip"), "options survived");
assert!(
body.ends_with(b"PACK\x00\x00\x00\x02fakepack"),
"the pack lands after the options block"
);
}
/// Cert and options together: the cert comes first and the options block
/// sits between push-cert-end and the pack.
#[test]
fn signed_push_with_options_reads_cert_then_options_then_pack() {
let old = "a".repeat(40);
let new = "b".repeat(40);
let mut stream = Vec::new();
stream.extend_from_slice(&pkt(
"push-cert\0 report-status-v2 push-cert push-options\n",
));
stream.extend_from_slice(&pkt("certificate version 0.1\n"));
stream.extend_from_slice(&pkt("\n"));
stream.extend_from_slice(&pkt(&format!("{old} {new} refs/heads/main\n")));
stream.extend_from_slice(&pkt("push-cert-end\n"));
stream.extend_from_slice(&pkt("ci.skip\n"));
stream.extend_from_slice(b"0000"); // options flush
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
let mut stdin = io::Cursor::new(stream);
let body = read_receive_pack_request(&mut stdin).unwrap();
assert!(body.windows(7).any(|w| w == b"ci.skip"), "options survived");
assert!(
body.ends_with(b"PACK\x00\x00\x00\x02fakepack"),
"the pack lands after the options block"
);
}
// ── #117 multi-round fetch negotiation ───────────────────────────────────
/// Encode a git pkt-line: 4-byte hex length (incl. the 4 bytes) + data.
@@ -95,36 +95,6 @@ fn upload_pack(repo: &Path, advertise: bool, input: &[u8]) -> Vec<u8> {
out.stdout
}
/// Run `git receive-pack --stateless-rpc [--advertise-refs] <repo>`, optionally
/// feeding `input` on stdin. Returns raw stdout (the wire bytes). The receive
/// half of `upload_pack`, used so the shim can serve a real push.
fn receive_pack(repo: &Path, advertise: bool, input: &[u8]) -> Vec<u8> {
let mut cmd = Command::new("git");
cmd.arg("receive-pack").arg("--stateless-rpc");
if advertise {
cmd.arg("--advertise-refs");
}
cmd.arg(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn git receive-pack");
let mut stdin = child.stdin.take().unwrap();
let input = input.to_vec();
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(&input);
// drop closes stdin
});
let out = child.wait_with_output().expect("wait receive-pack");
writer.join().ok();
assert!(
out.status.success(),
"git receive-pack failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out.stdout
}
#[derive(Clone, Copy)]
enum ShimMode {
/// Faithful v0 serving: pipe each POST to `git upload-pack --stateless-rpc`.
@@ -137,11 +107,6 @@ enum ShimMode {
struct Shim {
base_url: String,
posts: Arc<AtomicUsize>,
/// Advertisement (GET /info/refs) count. One `git fetch` performs exactly
/// one advertisement, so tests assert this is EXACTLY one: any fetch-level
/// retry that comes back re-advertises and goes red on the spot, which the
/// lower-bound POST assertions alone cannot see (#275 round 6).
gets: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
@@ -171,17 +136,15 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{addr}");
let posts = Arc::new(AtomicUsize::new(0));
let gets = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let posts_t = posts.clone();
let gets_t = gets.clone();
let stop_t = stop.clone();
let handle = std::thread::spawn(move || {
while !stop_t.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
handle_conn(stream, &repo, mode, &posts_t, &gets_t);
handle_conn(stream, &repo, mode, &posts_t);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
@@ -194,7 +157,6 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
Shim {
base_url,
posts,
gets,
stop,
handle: Some(handle),
}
@@ -220,13 +182,7 @@ fn normalize_accepted_stream(stream: &TcpStream) {
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
}
fn handle_conn(
stream: TcpStream,
repo: &Path,
mode: ShimMode,
posts: &AtomicUsize,
gets: &AtomicUsize,
) {
fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsize) {
normalize_accepted_stream(&stream);
let mut reader = BufReader::new(stream);
@@ -262,22 +218,7 @@ fn handle_conn(
reader.read_exact(&mut body).ok();
}
let (content_type, payload) = if method == "GET" && target.contains("service=git-receive-pack")
{
// Receive-pack advertisement, same wrapper shape as upload-pack.
let adv = receive_pack(repo, true, b"");
let mut wrapped = pkt(b"# service=git-receive-pack\n");
wrapped.extend_from_slice(b"0000");
wrapped.extend_from_slice(&adv);
("application/x-git-receive-pack-advertisement", wrapped)
} else if method == "POST" && target.ends_with("/git-receive-pack") {
posts.fetch_add(1, Ordering::SeqCst);
(
"application/x-git-receive-pack-result",
receive_pack(repo, false, &body),
)
} else if method == "GET" && target.contains("/info/refs") {
gets.fetch_add(1, Ordering::SeqCst);
let (content_type, payload) = if method == "GET" && target.contains("/info/refs") {
// v0 advertisement, wrapped exactly as the node's info_refs does.
let adv = upload_pack(repo, true, b"");
let mut wrapped = pkt(b"# service=git-upload-pack\n");
@@ -352,34 +293,6 @@ fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Outpu
run_bounded(cmd, Duration::from_secs(30))
}
/// Run `git push` in `local` through the helper, same wiring and hard timeout as
/// [`fetch_with_helper`]. `args` carries the push spec, e.g.
/// `["--delete", "doomed"]`.
fn push_with_helper(local: &Path, node_url: &str, args: &[&str]) -> (bool, std::process::Output) {
let helper_bin = PathBuf::from(env!("CARGO_BIN_EXE_git-remote-gitlawb"));
let helper_dir = helper_bin.parent().unwrap().to_path_buf();
let path_env = match std::env::var_os("PATH") {
Some(p) => {
let mut dirs = vec![helper_dir.clone()];
dirs.extend(std::env::split_paths(&p));
std::env::join_paths(dirs).unwrap()
}
None => helper_dir.clone().into_os_string(),
};
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(local)
.arg("push")
.arg("origin")
.args(args)
.env("PATH", path_env)
.env("LC_ALL", "C")
.env("GITLAWB_NODE", node_url)
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-push");
run_bounded(cmd, Duration::from_secs(30))
}
/// A synthetic non-success `ExitStatus` used only on the unix timeout path, where
/// `reap_group` has already consumed the child and the later `wait` returns ECHILD.
/// The timeout branch already reports `completed == false`, so the exact status is
@@ -1022,7 +935,6 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
let server = std::thread::spawn(move || {
let posts = AtomicUsize::new(0);
let gets = AtomicUsize::new(0);
let stream = loop {
match listener.accept() {
Ok((s, _)) => break s,
@@ -1037,7 +949,7 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
// the assertions below run on every platform.
stream.set_nonblocking(true).unwrap();
accepted_tx.send(()).unwrap();
handle_conn(stream, &repo, ShimMode::Normal, &posts, &gets);
handle_conn(stream, &repo, ShimMode::Normal, &posts);
});
let mut client = TcpStream::connect(addr).unwrap();
@@ -1101,17 +1013,6 @@ fn real_git_multi_round_fetch_completes() {
posts >= 2,
"fixture did not force multi-round negotiation (observed {posts} POST(s)); the bridging path was not exercised"
);
// Exactly one advertisement: the POST bound above is a floor, so a
// reintroduced fetch-level retry could satisfy it by running a second full
// fetch instead of multi-round negotiation. Re-advertising is what a
// second fetch cannot avoid, so this is the committed guard on the
// retry's absence.
let gets = shim.gets.load(Ordering::SeqCst);
assert_eq!(
gets, 1,
"expected exactly one advertisement GET per fetch; {gets} means a \
fetch-level retry is back"
);
// The fetched tip is present and the clone's object graph is intact.
let server_head = String::from_utf8(git(&server, &["rev-parse", "HEAD"])).unwrap();
@@ -1204,17 +1105,6 @@ fn real_git_withheld_shaped_first_post() {
"helper hung on a withheld-shaped response (should forward-and-terminate, not deadlock). stderr:\n{stderr}"
);
// Exactly one advertisement, pass or fail: the POST assertions below are
// floors, so a reintroduced fetch-level retry could mask the withheld
// shape behind a second full fetch. A retry cannot avoid re-advertising,
// so this is the committed guard on its absence.
let gets = shim.gets.load(Ordering::SeqCst);
assert_eq!(
gets, 1,
"expected exactly one advertisement GET per fetch; {gets} means a \
fetch-level retry is back"
);
if out.status.success() {
// Real git accepted the mid-negotiation pack: the withheld multi-round
// path works end to end with no extra handling.
@@ -1352,77 +1242,3 @@ fn run_bounded_bounds_join_when_leader_exits_leaving_a_pipe_holder() {
held pipes do not stall the joins)"
);
}
/// #369 end-to-end: a real `git push --delete` through the helper completes.
/// The receive-pack request for a delete-only push is commands + flush with NO
/// pack, and git holds its write pipe open for the report — so a helper that
/// reads stdin to EOF deadlocks against it. The shim pipes the POST to a real
/// `git receive-pack --stateless-rpc`, so the branch actually has to be deleted
/// server-side for the assertions to pass.
#[test]
fn real_git_push_delete_completes() {
let dir = tempfile::tempdir().unwrap();
let server = dir.path().join("server.git");
let local = dir.path().join("local");
// A bare repo holding the doomed branch, plus a live one.
git(
dir.path(),
&[
"init",
"--bare",
server.to_str().unwrap(),
"--initial-branch=main",
],
);
let work = dir.path().join("work");
git(
dir.path(),
&["init", work.to_str().unwrap(), "--initial-branch=main"],
);
git(&work, &["config", "user.email", "t@t"]);
git(&work, &["config", "user.name", "t"]);
std::fs::write(work.join("f"), "x").unwrap();
git(&work, &["add", "f"]);
git(&work, &["commit", "-m", "c"]);
git(&work, &["branch", "doomed"]);
git(&work, &["push", server.to_str().unwrap(), "main", "doomed"]);
// The pusher: a local repo whose origin is the gitlawb remote pointing at
// the shim. Nothing needs to be fetched first; git takes the old-oid from
// the receive-pack advertisement.
git(
dir.path(),
&["init", local.to_str().unwrap(), "--initial-branch=main"],
);
git(
&local,
&["remote", "add", "origin", "gitlawb://did:key:z6MkTest/repo"],
);
let shim = start_shim(server.clone(), ShimMode::Normal);
let (completed, out) = push_with_helper(&local, &shim.base_url, &["--delete", "doomed"]);
let posts = shim.posts.load(Ordering::SeqCst);
assert!(
completed,
"git push --delete did not complete within the timeout (deadlock signature). stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success(),
"git push --delete failed. stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(posts, 1, "a delete-only push is exactly one POST");
let gone = Command::new("git")
.arg("-C")
.arg(&server)
.args(["rev-parse", "--verify", "--quiet", "refs/heads/doomed"])
.output()
.unwrap();
assert!(
!gone.status.success(),
"refs/heads/doomed must be deleted in the server repo"
);
}
-17
View File
@@ -504,23 +504,6 @@ mod tests {
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_oversized_method_id_before_decoding() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
// A well-formed prefix on an absurdly long id must fail the length
// bound, not the decoder: base58 decode cost is quadratic in input
// length, so reaching it at all is the defect.
att.signer = format!("did:key:z{}", "1".repeat(256));
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
// Match the bound's own message: any other Did error (a decode or
// length mismatch downstream) means the oversized input was decoded.
match err {
Error::Did(m) => assert!(m.contains("too long"), "unexpected: {m}"),
e => panic!("expected Error::Did, got {e:?}"),
}
}
#[test]
fn verify_rejects_bad_signature_base64() {
let sk = fresh();
+11 -127
View File
@@ -150,48 +150,23 @@ impl Registry {
}
/// Verify a batch, then enforce `RequireAll`.
///
/// Under [`Policy::RequireAll`] this never short-circuits: an attestation
/// that fails to verify — bad signature, cert-hash mismatch, or malformed
/// payload, the actual `Err` paths of [`Registry::verify`] — is dropped
/// from the result rather than aborting the batch. A valid attestation of
/// a type with NO registered verifier is not one of those: under
/// `RequireAll` it verifies to `Ok` with `fully_verified: false` and stays
/// in the result, preserving the distinction between *untrusted* and
/// *invalid* (see `require_all_is_lenient_on_unknown_types_in_the_batch`).
/// Any third party can attach an attestation, so a hard error here would
/// let an unrelated `attacker/spam/v1` deny-of-service the whole cert (the
/// DoS vector the module docstring warns about). The `required_types`
/// check below is the real gate, and it only counts `fully_verified`
/// entries — so a required type represented solely by a malformed
/// attestation still fails with [`Error::RequiredMissing`].
///
/// Under `AcceptKnown` and `RejectUnknown` the batch stays strict: the
/// first verification error is surfaced to the caller.
pub fn verify_all(
&self,
attestations: &[Attestation],
expected_cert_hash: [u8; 32],
) -> Result<Vec<VerifiedAttestation>> {
if self.policy != Policy::RequireAll {
let mut verified = Vec::with_capacity(attestations.len());
for a in attestations {
verified.push(self.verify(a, expected_cert_hash)?);
}
return Ok(verified);
let mut verified = Vec::with_capacity(attestations.len());
for a in attestations {
verified.push(self.verify(a, expected_cert_hash)?);
}
// RequireAll: drop entries that fail to verify instead of aborting.
let verified: Vec<VerifiedAttestation> = attestations
.iter()
.filter_map(|a| self.verify(a, expected_cert_hash).ok())
.collect();
for required in &self.required_types {
let present = verified
.iter()
.any(|v| &v.type_ == required && v.fully_verified);
if !present {
return Err(Error::RequiredMissing(required.clone()));
if self.policy == Policy::RequireAll {
for required in &self.required_types {
let present = verified
.iter()
.any(|v| &v.type_ == required && v.fully_verified);
if !present {
return Err(Error::RequiredMissing(required.clone()));
}
}
}
Ok(verified)
@@ -349,38 +324,6 @@ mod tests {
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
}
/// The strict policies must surface a batch member's verification error
/// through `verify_all`, not launder it into a partial result. This binds
/// the POLICY BOUNDARY itself: only the `RequireAll` arm may drop failing
/// entries, and an apparently harmless cleanup that applies its
/// `filter_map(Result::ok)` shape to the strict arms would silently turn
/// the fail-closed contracts into fail-open — with every per-attestation
/// `verify` test still green, because none of them go through the batch
/// path. The failing entry is a wrong-cert-hash attestation (signed over a
/// different hash than the batch expects), a genuine `Err` under every
/// policy.
#[test]
fn strict_policies_propagate_a_batch_members_error() {
let sk = fresh();
let cert_hash = sample_hash();
let mut wrong_hash = sample_hash();
wrong_hash[0] ^= 0xff;
for policy in [Policy::AcceptKnown, Policy::RejectUnknown] {
let mut reg = Registry::new().with_policy(policy);
reg.register(DemoVerifier);
let good = signed_demo(&sk, cert_hash, "ok");
let bad = signed_demo(&sk, wrong_hash, "ok");
let err = reg
.verify_all(&[good, bad], cert_hash)
.expect_err("a strict policy must fail the whole batch");
assert!(
matches!(err, Error::Signature(_) | Error::CertHashMismatch),
"expected the underlying verification error, got {err:?}"
);
}
}
/// A required type that is present but unverified (no verifier registered
/// even though the type appears in `required_types`) must fail
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
@@ -398,65 +341,6 @@ mod tests {
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
/// A cert-hash that does not match `sample_hash()`, so an attestation
/// signed against it fails `verify_signature` when the batch is checked
/// against `sample_hash()`.
fn other_hash() -> [u8; 32] {
let mut h = sample_hash();
h[0] ^= 0xff;
h
}
/// Under `RequireAll`, a malformed attestation (here: signed against the
/// wrong cert hash, so it fails signature verification) attached alongside a
/// valid required one must not abort the batch. The junk is dropped and the
/// cert still verifies. Before the fix, `verify_all` propagated the error
/// via `?` and the whole batch failed — the DoS vector the docstring warns
/// about.
#[test]
fn require_all_drops_a_malformed_extra_and_still_accepts() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
// Signed against `other_hash()`, so it will not verify under `cert_hash`.
let malformed = signed_demo(&sk, other_hash(), "boom");
let real = signed_demo(&sk, cert_hash, "ok");
let verified = reg
.verify_all(&[malformed, real], cert_hash)
.expect("a malformed extra must not abort the batch under RequireAll");
assert_eq!(verified.len(), 1, "the malformed entry should be dropped");
assert!(verified
.iter()
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
}
/// Leniency must not open a hole: if the *only* attestation of a required
/// type is malformed, dropping it leaves the required type absent and
/// `verify_all` must still fail with `RequiredMissing`. Guards against a fix
/// that skips errors so eagerly it lets an unverified required type pass.
#[test]
fn require_all_rejects_when_only_a_malformed_required_type_is_present() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
// The sole demo/v1 is bound to the wrong cert hash → fails verification.
let malformed_required = signed_demo(&sk, other_hash(), "ok");
let err = reg
.verify_all(&[malformed_required], cert_hash)
.unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
#[test]
fn payload_check_failure_rejects() {
let sk = fresh();
+4 -157
View File
@@ -5,28 +5,6 @@
//!
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
//! Nodes that receive a certificate with an unknown version MUST reject it.
//!
//! ## Replay
//!
//! A certificate proves *who* asserted a ref update and *what* they asserted.
//! It does not prove the assertion is fresh. [`RefUpdateBody::seq`] and
//! [`RefUpdateBody::nonce`] are inside the signed bytes (see
//! [`RefUpdateBody::to_signing_bytes`]), so a verifier can trust the values,
//! but deciding whether a nonce is being reused or a `seq` is going backwards
//! needs state.
//!
//! This crate is deliberately stateless — it is embedded in both nodes and
//! clients — so replay is **not** enforced here and cannot be. Ownership of
//! the check sits with the verifier that accepts and persists certificates,
//! which must:
//!
//! - reject a `nonce` it has already accepted for the same `(repo, ref_name)`;
//! - reject a `seq` that does not advance past the last accepted `seq` for
//! that `(repo, ref_name)`.
//!
//! Until such a verifier exists, replay of ref-update certificates is not
//! enforced anywhere in this workspace. This contract exists so the first one
//! inherits the requirement instead of re-deriving it.
use std::collections::HashSet;
@@ -63,19 +41,11 @@ pub struct RefUpdateBody {
pub from: String,
/// The new commit hash (SHA-256 hex).
pub to: String,
/// Monotonically increasing sequence number, scoped to `(repo, ref_name)`.
///
/// Signed, but not enforced by this crate: core holds no state. The
/// verifier that persists certificates must reject a `seq` that does not
/// advance for the same repo and ref. See the module docs on replay.
/// Monotonically increasing sequence number. Prevents replay.
pub seq: u64,
/// RFC 3339 timestamp of this update.
pub timestamp: DateTime<Utc>,
/// Random nonce, unique per certificate.
///
/// Signed, but not enforced by this crate: core holds no state. The
/// verifier that persists certificates must reject a nonce it has already
/// accepted for the same repo and ref. See the module docs on replay.
/// Random nonce for deduplication.
pub nonce: String,
}
@@ -170,16 +140,7 @@ impl RefUpdateCert {
///
/// Counts distinct signer DIDs, not signature entries: a repeated
/// signature from the same maintainer counts once.
///
/// `threshold` must be at least 1. Zero is rejected instead of being
/// treated as "no signatures required": a caller that derives the
/// threshold from configuration and gets 0 would otherwise accept any
/// certificate, whether its signatures verify or not.
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
if threshold == 0 {
return Err(Error::RefCert("threshold must be at least 1".to_string()));
}
let valid = self.verify_all()?;
let distinct_signers: HashSet<&Did> =
valid.iter().filter(|d| maintainers.contains(d)).collect();
@@ -417,7 +378,7 @@ mod tests {
}
#[test]
fn threshold_zero_is_rejected() {
fn threshold_zero_is_always_satisfied() {
let kp = Keypair::generate();
let cert = RefUpdateCert::new(
kp.did(),
@@ -428,120 +389,6 @@ mod tests {
&kp,
)
.unwrap();
// Zero must never read as "no signatures required", even with an empty
// maintainer set.
let err = cert.satisfies_threshold(&[], 0).unwrap_err();
assert!(
err.to_string().contains("threshold must be at least 1"),
"zero must be refused as an invalid threshold"
);
}
/// The threshold is checked before any signature work, so a zero is
/// reported as a threshold error even when the certificate's signatures
/// are also invalid. Otherwise a broken certificate would let a caller
/// mistake the failure for a legitimate verification result.
#[test]
fn threshold_zero_is_rejected_before_signature_verification() {
let kp = Keypair::generate();
let other = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.signatures[0].sig = other.sign_b64(b"unrelated bytes");
let err = cert.satisfies_threshold(&[kp.did()], 0).unwrap_err();
assert!(
err.to_string().contains("threshold must be at least 1"),
"the invalid threshold must win over the invalid signature"
);
}
/// `seq` and `nonce` are the verifier's replay inputs, so they have to be
/// inside the signed body; tapping either one must invalidate the
/// certificate rather than leave it verifying with different values.
#[test]
fn replay_fields_are_covered_by_the_signature() {
let kp = Keypair::generate();
let cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
7,
&kp,
)
.unwrap();
cert.verify_all().expect("baseline must verify");
let mut bumped = cert.clone();
bumped.body.seq = 8;
assert!(
matches!(bumped.verify_all(), Err(Error::SignatureInvalid)),
"changing seq must invalidate the signature"
);
let mut renamed = cert.clone();
renamed.body.nonce = "different-nonce".to_string();
assert!(
matches!(renamed.verify_all(), Err(Error::SignatureInvalid)),
"changing nonce must invalidate the signature"
);
}
/// A signature minted by a real key must not verify when the signer is
/// spelled in another multibase alphabet. Otherwise the same identity
/// would satisfy a maintainer allowlist under several DID strings.
///
/// Base64url is deliberate: its 47-character method-id fits under the
/// pre-existing length bound, so this test reaches the canonical-prefix
/// rule and fails on the pre-fix code instead of being refused for length.
#[test]
fn verify_all_rejects_a_non_canonical_did_spelling() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
let mut prefixed = vec![0xed, 0x01];
prefixed.extend_from_slice(&kp.verifying_key().to_bytes());
cert.signatures[0].signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base64Url, &prefixed)
)
.parse()
.expect("method is still `key`");
let err = cert
.verify_all()
.expect_err("a non-canonical did:key spelling must not resolve");
assert!(
err.to_string().contains("base58btc"),
"rejection must come from the canonical-prefix rule, not the length guard"
);
assert!(
!matches!(
cert.satisfies_threshold(&[cert.signatures[0].signer.clone()], 1),
Ok(true)
),
"the alternate spelling must not satisfy a threshold"
);
// The canonical spelling of the same key is unaffected.
cert.signatures[0].signer = kp.did();
assert!(cert.satisfies_threshold(&[kp.did()], 1).unwrap());
assert!(cert.satisfies_threshold(&[], 0).unwrap());
}
}
+3 -158
View File
@@ -20,19 +20,6 @@ use crate::{Error, Result};
/// Value: 0xed01 encoded as a varint.
const ED25519_MULTICODEC: &[u8] = &[0xed, 0x01];
/// The only multibase prefix a `did:key` method-specific id may carry.
///
/// The DID:key spec encodes Ed25519 keys as base58btc, which is multibase
/// `z`. `multibase::decode` happily accepts every other alphabet, and each
/// spelling of the same key bytes would produce a distinct DID string while
/// gates, allowlists, and database rows compare DIDs as strings — one
/// identity would read as several. Rejecting the alternatives here keeps the
/// mapping from `did:key` to key single-valued for every caller of
/// [`Did::to_verifying_key`] (HTTP signatures, UCAN, ref certs, peer
/// announces). `gitlawb-attest` enforces the same rule for attestation
/// signers.
const DID_KEY_MULTIBASE_PREFIX: char = 'z';
/// A Decentralized Identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
@@ -84,14 +71,8 @@ impl Did {
/// Resolve the Ed25519 verifying key from a `did:key`.
///
/// Only the canonical base58btc (`z`) spelling is accepted. Other
/// multibase encodings decode to the same key but spell a different DID
/// string, so accepting them would let one identity appear under several
/// identifiers to string-comparing gates and allowlists.
///
/// Returns `Err` if the DID is not a `did:key`, uses a non-canonical
/// multibase prefix, the key bytes are invalid, or the key is a
/// small-order (weak) point.
/// Returns `Err` if the DID is not a `did:key`, the key bytes are malformed,
/// or the key is a small-order (weak) point.
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
if !self.is_did_key() {
return Err(Error::InvalidDid(format!(
@@ -113,27 +94,9 @@ impl Did {
));
}
// Cheap prefix gate before the decode, then the base check on what
// multibase actually parsed. The first keeps a clearly non-canonical id
// off the decoder. The second is belt-and-braces — multibase maps a
// leading `z` only to `Base58Btc` — but it states the requirement
// directly instead of relying on that mapping, and `gitlawb-attest`
// makes the same pair of checks.
if !self.method_id().starts_with(DID_KEY_MULTIBASE_PREFIX) {
return Err(Error::InvalidDid(
"did:key must use base58btc (z-prefix)".to_string(),
));
}
let (base, bytes) =
let (_, bytes) =
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
if base != Base::Base58Btc {
return Err(Error::InvalidDid(
"did:key must use base58btc (z-prefix)".to_string(),
));
}
if !bytes.starts_with(ED25519_MULTICODEC) {
return Err(Error::InvalidDid(
"not an ed25519 multicodec key".to_string(),
@@ -426,124 +389,6 @@ mod tests {
did.to_verifying_key().expect("a real did:key must resolve");
}
/// `multibase::decode` accepts every alphabet, so the same Ed25519 key can
/// be spelled as several `did:key` strings. Only base58btc is canonical;
/// the rest must be refused so one identity cannot appear as several to
/// gates and allowlists that compare DID strings.
#[test]
fn to_verifying_key_rejects_non_base58btc_multibase() {
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(&[9u8; 32]);
let mut prefixed = ED25519_MULTICODEC.to_vec();
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
let canonical = Did::from_verifying_key(&signing.verifying_key());
assert_eq!(
canonical.to_verifying_key().unwrap().to_bytes(),
signing.verifying_key().to_bytes(),
"base58btc is the accepted spelling"
);
// These spellings fit inside the method-id bound, so they reach the
// canonical-prefix check and must be refused there.
for base in [Base::Base64Url, Base::Base32Lower] {
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
let did: Did = alternative.parse().expect("method is still `key`");
assert_ne!(
did.as_str(),
canonical.as_str(),
"the same key must not compare equal under another spelling"
);
let err = did
.to_verifying_key()
.expect_err("non-canonical multibase must be refused");
assert!(
matches!(err, Error::InvalidDid(_)),
"must be an InvalidDid, got {err:?}"
);
assert!(
err.to_string().contains("base58btc"),
"the error must name the canonical encoding: {err}"
);
}
// A base16 spelling of the same key is longer than the method-id
// bound, so it is refused by the length guard before the prefix check.
// Either way it must not resolve.
for base in [Base::Base16Lower, Base::Base16Upper] {
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
let did: Did = alternative.parse().expect("method is still `key`");
assert!(
matches!(did.to_verifying_key(), Err(Error::InvalidDid(_))),
"a base16 spelling of a real key must not resolve"
);
}
}
/// base58flickr (`Z`) is the non-bitcoin sibling alphabet. Its spelling of
/// a real key fits under the length bound, so it must be refused for the
/// prefix, not merely for its size.
#[test]
fn to_verifying_key_rejects_a_base58flickr_spelling() {
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(&[11u8; 32]);
let mut prefixed = ED25519_MULTICODEC.to_vec();
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
let did: Did = format!(
"did:key:{}",
multibase::encode(Base::Base58Flickr, &prefixed)
)
.parse()
.expect("method is still `key`");
let err = did
.to_verifying_key()
.expect_err("base58flickr must be refused");
assert!(
err.to_string().contains("base58btc"),
"the error must name the canonical encoding: {err}"
);
}
/// An empty method-specific id must be refused by the prefix rule without
/// panicking on a missing first character.
#[test]
fn to_verifying_key_rejects_an_empty_method_id() {
let did: Did = "did:key:".parse().expect("method is still `key`");
let err = did
.to_verifying_key()
.expect_err("an empty method id must be refused");
assert!(
err.to_string().contains("base58btc"),
"the error must name the canonical encoding: {err}"
);
}
/// A canonical base58btc spelling is not enough on its own: the bytes must
/// also carry the ed25519-pub multicodec. This pins the check that stops a
/// key of another type from resolving as an Ed25519 key.
#[test]
fn to_verifying_key_rejects_base58btc_with_a_wrong_multicodec() {
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(&[12u8; 32]);
let mut buf = vec![0x12, 0x00]; // sha2-256, not ed25519-pub
buf.extend_from_slice(&signing.verifying_key().to_bytes());
let did: Did = format!("did:key:{}", multibase::encode(Base::Base58Btc, &buf))
.parse()
.expect("method is still `key`");
let err = did
.to_verifying_key()
.expect_err("a non-ed25519 multicodec must be refused");
assert!(
err.to_string().contains("ed25519"),
"the error must name the expected multicodec: {err}"
);
}
/// A `did:key` can encode a small-order (weak) Ed25519 point. Such a key
/// satisfies the verification equation for any message and, converted to
/// Montgomery form for X25519, yields the all-zero shared secret, so an
-89
View File
@@ -85,13 +85,6 @@ use serde::{Deserialize, Serialize};
const MAGIC: &[u8] = b"GLENC";
const VERSION: u8 = 2;
/// Upper bound on the blinded recipient list. The envelope author controls
/// this count and `open_blob` pays one X25519 exchange per entry before it can
/// answer "not a recipient", so the count has to be capped rather than
/// optimized (#419). Legitimate envelopes carry a handful of authorized
/// readers, so 256 is far above real use.
const MAX_RECIPIENTS: usize = 256;
#[derive(Serialize, Deserialize)]
struct Recipient {
eph: String, // base64 ephemeral x25519 pubkey (32B)
@@ -111,14 +104,6 @@ pub fn seal_blob(plaintext: &[u8], recipients: &[VerifyingKey]) -> Result<Vec<u8
if recipients.is_empty() {
return Err(anyhow::anyhow!("seal_blob: no recipients"));
}
// Same bound open_blob enforces: an envelope over the cap could never be
// opened, so refuse to seal it (#419).
if recipients.len() > MAX_RECIPIENTS {
return Err(anyhow::anyhow!(
"seal_blob: {} recipients, over the {MAX_RECIPIENTS} cap",
recipients.len()
));
}
let mut content_key = [0u8; 32];
OsRng.fill_bytes(&mut content_key);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
@@ -177,14 +162,6 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
let header: Header =
serde_json::from_slice(envelope.get(p..p + hlen).context("truncated header")?)
.context("decode header")?;
// Reject before the recipient scan: every entry below is an X25519
// exchange, and a non-recipient would run the whole list (#419).
if header.recipients.len() > MAX_RECIPIENTS {
return Err(anyhow::anyhow!(
"envelope carries {} recipients, over the {MAX_RECIPIENTS} cap",
header.recipients.len()
));
}
let body = &envelope[p + hlen..];
// The raw scalar is kept alongside the box secret so the exchange can be
@@ -361,72 +338,6 @@ mod tests {
assert!(open_blob(&reframe(&header), &reader).is_err());
}
#[test]
fn over_cap_recipient_list_is_rejected_before_the_scan() {
// #419: the envelope author controls the recipient count and each entry
// costs an X25519 exchange, so the list is capped. Reframe a valid
// envelope with MAX_RECIPIENTS + 1 junk entries.
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header_bytes = &env[p..p + hlen];
let body = &env[p + hlen..];
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
let junk = serde_json::json!({
"eph": B64.encode([0u8; 32]),
"nonce": B64.encode([0u8; 24]),
"wrap": B64.encode([0u8; 48]),
});
header["recipients"] = serde_json::Value::Array(vec![junk.clone(); MAX_RECIPIENTS + 1]);
let hj = serde_json::to_vec(&header).unwrap();
let mut fat = Vec::new();
fat.extend_from_slice(MAGIC);
fat.push(VERSION);
fat.extend_from_slice(&(hj.len() as u32).to_le_bytes());
fat.extend_from_slice(&hj);
fat.extend_from_slice(body);
let err = open_blob(&fat, &reader).unwrap_err();
assert!(
err.to_string().contains("recipients"),
"expected the recipient-cap error, got: {err}"
);
// Exactly at the cap the envelope is still processed end to end.
header["recipients"] = serde_json::Value::Array(vec![junk; MAX_RECIPIENTS]);
let hj = serde_json::to_vec(&header).unwrap();
let mut at_cap = Vec::new();
at_cap.extend_from_slice(MAGIC);
at_cap.push(VERSION);
at_cap.extend_from_slice(&(hj.len() as u32).to_le_bytes());
at_cap.extend_from_slice(&hj);
at_cap.extend_from_slice(body);
let err = open_blob(&at_cap, &reader).unwrap_err();
assert!(
!err.to_string().contains("over the"),
"at-cap envelope must reach the scan, got: {err}"
);
}
#[test]
fn seal_refuses_a_recipient_list_over_the_cap() {
// An envelope over the cap can never be opened, so seal must reject it
// instead of minting one.
let keys: Vec<VerifyingKey> = (0..MAX_RECIPIENTS + 1)
.map(|_| Keypair::generate().verifying_key())
.collect();
let err = seal_blob(b"blob", &keys).unwrap_err();
assert!(
err.to_string().contains("recipients"),
"expected the recipient-cap error, got: {err}"
);
}
/// Every standard low-order X25519 encoding must be detected by
/// `yields_all_zero_shared_secret`, including encodings that do not
/// decompress to an Edwards point (notably `u = p - 1`). The open side
-20
View File
@@ -566,26 +566,6 @@ mod tests {
assert!(sig.check_created().is_ok());
}
#[test]
fn extreme_created_timestamps_reject_without_wrapping() {
let kp = Keypair::generate();
let did = kp.did();
let parse = |created: i64| {
let sig_input =
format!(r#"sig1=("@method");keyid="{did}";alg="ed25519";created={created}"#);
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
};
// i64::MIN used to panic in debug (subtraction overflow) and wrap to a
// passing skew in release; now - 2^63 wrapped to i64::MIN, whose abs()
// wrapped back to itself and read as fresh. Both must reject.
for created in [i64::MIN, i64::MAX, Utc::now().timestamp() - i64::MAX] {
assert!(
parse(created).check_created().is_err(),
"created={created} must not pass the freshness gate"
);
}
}
#[test]
fn parse_error_missing_sig1_prefix() {
let err = HttpSignature::parse(
-2
View File
@@ -23,8 +23,6 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
percent-encoding = { workspace = true }
axum = { version = "0.8", features = ["http1", "http2", "json", "ws"] }
async-graphql = { version = "7", features = ["chrono", "uuid", "tracing"] }
async-graphql-axum = "7"
File diff suppressed because it is too large. Load diff
+9 -56
View File
@@ -1,13 +1,12 @@
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
use axum::{
extract::{Extension, Query, State},
extract::{Query, State},
Json,
};
use serde::Deserialize;
use crate::db::normalize_owner_key;
use crate::error::{AppError, Result};
use crate::error::Result;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
@@ -22,62 +21,17 @@ 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>> {
// 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?
};
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?;
Ok(Json(serde_json::json!({
"anchors": anchors,
@@ -106,7 +60,6 @@ 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(),
)
+29 -123
View File
@@ -285,22 +285,11 @@ pub async fn submit_bounty(
Path(id): Path<String>,
Json(req): Json<SubmitBountyRequest>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" {
return Err(AppError::BadRequest(format!(
@@ -336,22 +325,11 @@ pub async fn approve_bounty(
Path(id): Path<String>,
Json(req): Json<ApproveBountyRequest>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
@@ -394,22 +372,11 @@ pub async fn cancel_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
@@ -440,22 +407,11 @@ pub async fn dispute_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state
.db
.get_bounty(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" && bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
@@ -500,68 +456,21 @@ pub async fn dispute_bounty(
Ok(Json(updated))
}
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
/// all deduped repos, batch-load their visibility rules, keep only those that
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
/// per-repo authorization queries.
///
/// Both bounty stats handlers mount behind `optional_signature` (see
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
/// anonymous access. Every caller, signed or not, currently receives the
/// anonymous-scoped aggregates (matching the #104 pattern); per-caller
/// visibility can be threaded through here when that work lands.
///
/// A database error is returned to the caller, never collapsed to an empty
/// set: an empty set would render as HTTP 200 with zero counts, which a client
/// reads as confirmed absence of activity.
async fn visible_repo_pairs(state: &AppState) -> Result<Vec<(String, String)>> {
let rows = state.db.list_all_repos_deduped().await?;
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
let pairs = rows
.iter()
.filter(|r| {
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
// mirror-rows-handled: a mirror with a canonical twin is deduped to the
// canonical row and its rules; a mirror-only repo counts as public, the
// same answer authorize_repo_read and /stats give. Pinned by
// bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone.
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
})
.map(|r| {
(
crate::db::normalize_owner_key(&r.owner_did).to_string(),
r.name.clone(),
)
})
.collect();
Ok(pairs)
}
/// GET /api/v1/bounties/stats
///
/// Aggregates are restricted to anonymously-listable repos so private-repo
/// bounty activity is not exposed (#477). The visible-repo set is resolved
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
/// passed into the filtered aggregate queries.
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
let visible = visible_repo_pairs(&state).await?;
let open = state
.db
.count_bounties_by_status_visible("open", &visible)
.await?;
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
let claimed = state
.db
.count_bounties_by_status_visible("claimed", &visible)
.await?;
.count_bounties_by_status("claimed")
.await
.unwrap_or(0);
let completed = state
.db
.count_bounties_by_status_visible("completed", &visible)
.await?;
.count_bounties_by_status("completed")
.await
.unwrap_or(0);
let leaders = state.db.bounty_leaderboard_visible(10, &visible).await?;
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
let leaderboard = leaders
.into_iter()
.map(|(did, cnt, total)| AgentBountyEntry {
@@ -580,14 +489,11 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
}
/// GET /api/v1/agents/{did}/bounties
///
/// Per-agent earnings restricted to anonymously-listable repos (#477).
pub async fn agent_bounty_stats(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<serde_json::Value>> {
let visible = visible_repo_pairs(&state).await?;
let (count, total) = state.db.agent_bounty_stats_visible(&did, &visible).await?;
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
Ok(Json(serde_json::json!({
"did": did,
"completed_bounties": count,
-2
View File
@@ -52,7 +52,6 @@ pub async fn list_certs(
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
"version": c.version,
})
})
.collect();
@@ -93,6 +92,5 @@ pub async fn get_cert(
"node_did": cert.node_did,
"signature": cert.signature,
"issued_at": cert.issued_at,
"version": cert.version,
})))
}
-1
View File
@@ -437,7 +437,6 @@ mod ref_updates_feed_tests {
node_did: "did:key:z6MkNode".into(),
signature: "sig".into(),
issued_at: Utc::now().to_rfc3339(),
version: 1,
}
}
+1 -10
View File
@@ -2133,15 +2133,7 @@ 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>,
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(),
));
}
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
// 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?;
@@ -2434,7 +2426,6 @@ 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(),
)
+9 -24
View File
@@ -14,13 +14,14 @@ pub struct LabelRequest {
pub label: String,
}
/// Canonicalize and validate a label string. `add_label` and `remove_label`
/// must agree on the form stored in the DB: `add_label` already trims and
/// lowercases before storing, so `remove_label` has to do the same to find
/// the row. Validation is shared so neither handler can drift on charset or
/// length (#344).
fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
let label = raw.trim().to_lowercase();
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = req.label.trim().to_lowercase();
if label.is_empty() || label.len() > 50 {
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
}
@@ -32,17 +33,6 @@ fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
"label must contain only alphanumeric characters, hyphens, and colons".into(),
));
}
Ok(label)
}
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let record = state
.db
@@ -69,8 +59,6 @@ pub async fn remove_label(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let record = state
.db
.get_repo(&owner, &name)
@@ -78,10 +66,7 @@ pub async fn remove_label(
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
let removed = state.db.remove_label(&record.id, &label).await?;
if !removed {
return Err(AppError::NotFound(format!("label '{label}'")));
}
state.db.remove_label(&record.id, &label).await?;
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
}
+3 -585
View File
@@ -22,7 +22,6 @@ pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
pub mod task_cursor;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
@@ -81,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(),
))
}
}
@@ -248,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("),
@@ -309,577 +298,6 @@ mod authz_guard {
}
}
/// Completeness fence over the GraphQL mutation surface: the mirror of
/// `every_in_scope_mutation_has_its_gate` for `graphql/mutation.rs`. Kept a
/// SEPARATE guard with its own table (never folded into the REST rows above):
/// a guard over a heterogeneous set must classify per member, and these
/// same-named task rows belong to the GraphQL surface, not `api/tasks.rs`.
///
/// COMPLETENESS comes from the engine's own SDL (`schema.sdl()`), parsed as a
/// structured AST (#219), NOT introspection and NOT a text parse of source.
/// This matters: async-graphql OMITS `#[graphql(visible = false)]` fields from
/// introspection while leaving them executable, so an introspection-sourced fence
/// could be defeated by a hidden mutation. SDL includes those fields, so no
/// visibility flag (and, via the AST parse, no brace-in-description quirk) can hide
/// a live mutation. The SDL field set must equal the registered set: a new field with
/// no row fails (a repo-write cannot slip in unlisted); a registered name absent from
/// the schema fails (renamed or removed). The set is also cross-checked against
/// introspection, so any field present in SDL but hidden from introspection fails
/// named as a forbidden hidden mutation.
///
/// The MARKER check is the only source-scraped part, and it is not the
/// completeness guarantee. Three honest limits, so a green check is not misread
/// as full authorization coverage:
/// 1. A present marker is not load-bearingness (the gate may not redden on a
/// hostile probe: the #203 present-but-vacuous case). The adversarial tests
/// in `graphql/mutation.rs` are the load-bearing layer.
/// 2. A present marker is not gate-correctness (it may be the wrong bucket for
/// the operation's resource: a repo-write registered as signer-self would
/// pass, so INV-1 review remains the defense).
/// 3. The marker check scrapes the comment-masked, brace-bounded resolver body;
/// a `{`/`}` or a `did_matches(` inside a STRING literal is not masked, so it
/// could mis-slice that body or satisfy the marker vacuously (the same
/// vacuous-marker class as limit 1). Completeness is unaffected: it comes
/// from the SDL AST, not from this scrape.
#[test]
fn every_graphql_mutation_has_its_gate() {
// (Rust snake name, schema field in camelCase, expected gate markers). The
// four are Bucket C signer-self: `/graphql` uses optional_signature, so each
// resolver must call `require_signer(ctx)?` and bind the acting DID with
// `did_matches(`. A repo-write would be Bucket A (`require_repo_owner(`).
// Same-named REST rows live in the guard above against `api/tasks.rs`.
let registered: &[(&str, &str, &[&str])] = &[
(
"create_task",
"createTask",
&[
"require_signer(ctx)?",
"did_matches(",
"require_repo_owner(",
],
),
(
"claim_task",
"claimTask",
&["require_signer(ctx)?", "did_matches("],
),
(
"complete_task",
"completeTask",
&["require_signer(ctx)?", "did_matches("],
),
(
"fail_task",
"failTask",
&["require_signer(ctx)?", "did_matches("],
),
];
// Completeness + hidden-mutation cross-check against the REAL schema (#219),
// delegated to the generic `assert_mutation_fence` so the SDL sourcing and the
// cross-check are ALSO exercised by a synthetic hidden schema in
// `fence_rejects_a_hidden_mutation` (INV-21). Because the real MutationRoot has no
// hidden field today, that synthetic test — not a hypothetical production canary —
// is what turns reverting the SDL source or deleting the cross-check RED.
{
use crate::graphql::mutation::MutationRoot;
use crate::graphql::query::QueryRoot;
use crate::graphql::subscription::SubscriptionRoot;
let schema =
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
let registered_camel: Vec<&str> = registered.iter().map(|(_, c, _)| *c).collect();
assert_mutation_fence(&schema, &registered_camel);
}
// Marker present (source scrape; the vacuity limit, backstopped by the
// adversarial tests): each resolver body carries every required gate marker.
let masked = mask_comments(include_str!("../graphql/mutation.rs"));
for (snake, _, markers) in registered {
let body = resolver_body(&masked, snake);
for marker in *markers {
assert!(
body.contains(marker),
"GraphQL mutation `{snake}` is missing its gate marker `{marker}`: gate removed"
);
}
}
}
/// Version-safety self-check (#219, INV-21): the fence's completeness guarantee rests
/// on `schema.sdl()` INCLUDING `visible = false` fields while introspection omits
/// them. That is observed async-graphql behavior, not a documented contract, so pin
/// it here: a synthetic schema with a hidden mutation must have that field in its
/// SDL-derived set and NOT in its introspection-derived set. If a future
/// async-graphql upgrade ever filters SDL by visibility, the first assertion flips
/// RED — the assumption is load-bearing, not trusted.
#[test]
fn sdl_source_sees_hidden_mutations_that_introspection_omits() {
use async_graphql::{EmptySubscription, Object, Schema};
struct Q;
#[Object]
impl Q {
async fn ping(&self) -> i32 {
0
}
}
struct M;
#[Object]
impl M {
async fn visible_mut(&self) -> i32 {
1
}
#[graphql(visible = false)]
async fn hidden_mut(&self) -> i32 {
2
}
}
let schema = Schema::build(Q, M, EmptySubscription).finish();
// SDL source (the fix): includes the hidden field.
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
assert!(
sdl_fields.iter().any(|f| f == "hiddenMut"),
"SDL-sourced set MUST include the visible=false mutation; if this is RED, \
async-graphql started filtering SDL by visibility and the fence's \
completeness guarantee is broken: {sdl_fields:?}"
);
// Introspection (the fence's own helper): OMITS the hidden field. This is the
// exact gap the fix closes — an introspection-sourced fence never sees `hiddenMut`.
let introspected = introspection_mutation_fields(&schema);
assert!(
!introspected.iter().any(|f| f == "hiddenMut"),
"introspection is expected to OMIT the visible=false mutation (that is the \
gap the SDL source closes); got {introspected:?}"
);
// The exact diff the fence computes: hidden ∈ (sdl \ introspection).
let hidden: Vec<&String> = sdl_fields
.iter()
.filter(|f| !introspected.iter().any(|i| i == *f))
.collect();
assert_eq!(
hidden,
vec!["hiddenMut"],
"the SDL-vs-introspection diff must surface exactly the hidden mutation"
);
}
/// INV-21 wiring guard (#219): drives the generic `assert_mutation_fence` — the SAME
/// helper the real fence calls — against a synthetic schema whose mutation is
/// `visible = false` and IS registered, so completeness passes and ONLY the
/// hidden-from-introspection cross-check can fail. This is what makes the fence's SDL
/// sourcing load-bearing without a production canary: reverting the source to
/// introspection (the hidden field then absent from the SDL set) or deleting the
/// cross-check removes this panic (or changes its message), turning the
/// `should_panic` test RED. The real MutationRoot has no hidden field, so nothing on
/// the real schema could catch that regression.
#[test]
#[should_panic(expected = "hidden from introspection")]
fn fence_rejects_a_hidden_mutation() {
use async_graphql::{EmptySubscription, Object, Schema};
struct Q;
#[Object]
impl Q {
async fn ping(&self) -> i32 {
0
}
}
struct M;
#[Object]
impl M {
async fn visible_mut(&self) -> i32 {
1
}
#[graphql(visible = false)]
async fn hidden_mut(&self) -> i32 {
2
}
}
let schema = Schema::build(Q, M, EmptySubscription).finish();
// Both fields registered, so completeness passes and only the cross-check fires.
assert_mutation_fence(&schema, &["visibleMut", "hiddenMut"]);
}
/// Deprecated-mutation guard (#219): a `#[graphql(deprecation = "...")]` mutation is
/// executable and present in SDL, but GraphQL introspection omits it by default. The
/// fence's introspection helper uses `includeDeprecated: true`, so a deprecated (but
/// visible) mutation is NOT mistaken for a hidden one. With it registered, the fence
/// must NOT panic. Remove `includeDeprecated: true` and this goes RED — the deprecated
/// field would look hidden and false-red as a forbidden visible=false mutation.
#[test]
fn fence_admits_a_deprecated_mutation() {
use async_graphql::{EmptySubscription, Object, Schema};
struct Q;
#[Object]
impl Q {
async fn ping(&self) -> i32 {
0
}
}
struct M;
#[Object]
impl M {
async fn visible_mut(&self) -> i32 {
1
}
#[graphql(deprecation = "use visibleMut")]
async fn deprecated_mut(&self) -> i32 {
2
}
}
let schema = Schema::build(Q, M, EmptySubscription).finish();
// Must not panic: the deprecated field is visible (present in introspection with
// includeDeprecated) and registered, so it is neither hidden nor unclassified.
assert_mutation_fence(&schema, &["visibleMut", "deprecatedMut"]);
}
/// Completeness + hidden-mutation cross-check for a BUILT schema (#219). Generic over
/// the schema type so the real fence AND the synthetic `fence_rejects_a_hidden_mutation`
/// / `fence_admits_a_deprecated_mutation` tests drive the SAME sourcing and cross-check.
/// Completeness is sourced from SDL (which includes `visible = false` fields); the
/// cross-check fails any SDL field hidden from introspection. This genericity is the
/// INV-21 fix: because the real MutationRoot has no hidden field, only a synthetic
/// hidden schema through THIS helper makes reverting the SDL source or removing the
/// cross-check RED, rather than leaving the class latent behind a production canary.
fn assert_mutation_fence<Q, M, S>(
schema: &async_graphql::Schema<Q, M, S>,
registered_camel: &[&str],
) where
Q: async_graphql::ObjectType + 'static,
M: async_graphql::ObjectType + 'static,
S: async_graphql::SubscriptionType + 'static,
{
let sdl_fields = mutation_field_names_from_sdl(&schema.sdl());
let introspected = introspection_mutation_fields(schema);
// No SDL field may be hidden from introspection (a visible=false mutation).
for f in &sdl_fields {
assert!(
introspected.iter().any(|i| i == f),
"GraphQL mutation `{f}` is in the schema but hidden from introspection \
(a `#[graphql(visible = false)]` field): hidden mutations are forbidden \
by this fence — make it visible so its classification is enforceable, or \
remove it"
);
}
// Completeness both directions against the registered (camelCase) set.
for f in &sdl_fields {
assert!(
registered_camel.contains(&f.as_str()),
"unclassified GraphQL mutation `{f}`: add it to \
every_graphql_mutation_has_its_gate with a deliberate gate \
(`require_repo_owner(` for a repo-write, `did_matches(` for a task op)"
);
}
for c in registered_camel {
assert!(
sdl_fields.iter().any(|f| f == c),
"registered GraphQL mutation `{c}` is not in the schema \
(renamed or removed); update the table"
);
}
}
/// The mutation field names via the engine's introspection, run against a passed
/// schema. STRUCTURED (names arrive as data). Uses `fields(includeDeprecated: true)`:
/// GraphQL's default omits `@deprecated` fields, but a `#[graphql(deprecation = "...")]`
/// mutation is still executable and present in SDL, so without this the cross-check
/// above would false-red a deprecated mutation as a hidden (visible=false) one. Only a
/// real visibility filter should trip that arm; deprecation is a lifecycle marker.
fn introspection_mutation_fields<Q, M, S>(
schema: &async_graphql::Schema<Q, M, S>,
) -> Vec<String>
where
Q: async_graphql::ObjectType + 'static,
M: async_graphql::ObjectType + 'static,
S: async_graphql::SubscriptionType + 'static,
{
use async_graphql::Value;
let resp = tokio::runtime::Runtime::new()
.expect("tokio runtime")
.block_on(schema.execute(
"{ __schema { mutationType { fields(includeDeprecated: true) { name } } } }",
));
assert!(
resp.errors.is_empty(),
"schema introspection failed: {:?}",
resp.errors
);
let mut names = Vec::new();
if let Value::Object(root) = &resp.data {
if let Some(Value::Object(sc)) = root.get("__schema") {
if let Some(Value::Object(mt)) = sc.get("mutationType") {
if let Some(Value::List(field_list)) = mt.get("fields") {
for field in field_list {
if let Value::Object(fo) = field {
if let Some(Value::String(n)) = fo.get("name") {
names.push(n.clone());
}
}
}
}
}
}
}
names
}
/// The mutation field names sourced from the built schema's SDL — the completeness
/// source (#219). Builds the same DB-free schema as the introspection helper and runs
/// its `.sdl()` (sync, no runtime needed) through [`mutation_field_names_from_sdl`].
/// SDL includes `#[graphql(visible = false)]` fields, so this source cannot be
/// defeated by hiding a mutation from introspection.
fn graphql_mutation_fields_from_sdl() -> Vec<String> {
use crate::graphql::mutation::MutationRoot;
use crate::graphql::query::QueryRoot;
use crate::graphql::subscription::SubscriptionRoot;
let schema =
async_graphql::Schema::build(QueryRoot, MutationRoot, SubscriptionRoot).finish();
mutation_field_names_from_sdl(&schema.sdl())
}
/// The mutation type's field names extracted from an async-graphql SDL string via
/// the engine's OWN parser AST (`async_graphql::parser::parse_schema`), NOT
/// introspection. This is the completeness source (#219): unlike introspection,
/// which async-graphql filters by `visible` (a `#[graphql(visible = false)]`
/// mutation is executable but ABSENT from introspection), `schema.sdl()` includes
/// every registered field, so a hidden mutation still appears here and must carry a
/// classified row. Parsing the AST (not a brace/line text scan) means a `}` inside a
/// field description cannot mis-slice the type block. camelCase, as async-graphql
/// emits. An empty result on a navigation miss is safe: the set-equality in
/// `every_graphql_mutation_has_its_gate` turns "no fields" into a loud failure for
/// every registered name, never a vacuous pass.
fn mutation_field_names_from_sdl(sdl: &str) -> Vec<String> {
use async_graphql::parser::parse_schema;
use async_graphql::parser::types::{TypeKind, TypeSystemDefinition};
let doc = parse_schema(sdl).expect("async-graphql SDL must parse");
// The mutation root type name: from the `schema { mutation: X }` block if
// present, else the GraphQL default "Mutation".
let mutation_type = doc
.definitions
.iter()
.find_map(|def| match def {
TypeSystemDefinition::Schema(s) => s
.node
.mutation
.as_ref()
.map(|m| m.node.as_str().to_string()),
_ => None,
})
.unwrap_or_else(|| "Mutation".to_string());
// The fields of that object type.
doc.definitions
.iter()
.find_map(|def| match def {
TypeSystemDefinition::Type(t) if t.node.name.node.as_str() == mutation_type => {
match &t.node.kind {
TypeKind::Object(obj) => Some(
obj.fields
.iter()
.map(|f| f.node.name.node.as_str().to_string())
.collect::<Vec<_>>(),
),
_ => None,
}
}
_ => None,
})
.unwrap_or_default()
}
#[test]
fn sdl_extractor_includes_a_hidden_visible_false_field() {
// A field that would be hidden from introspection must still be listed from SDL.
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
type QueryRoot { ping: Int! }\n\
type MutationRoot { createTask: Int! hiddenMut: Int! }\n";
let fields = mutation_field_names_from_sdl(sdl);
assert!(
fields.iter().any(|f| f == "createTask"),
"visible field must be listed: {fields:?}"
);
assert!(
fields.iter().any(|f| f == "hiddenMut"),
"a visible=false field (present in SDL, hidden from introspection) MUST be \
listed by the completeness source: {fields:?}"
);
}
#[test]
fn sdl_extractor_is_not_fooled_by_a_brace_in_a_field_description() {
// A `}` inside a field description must not truncate the type block (the AST
// parse is immune to the text-parse quirk the old design feared).
let sdl = "schema { query: QueryRoot mutation: MutationRoot }\n\
type QueryRoot { ping: Int! }\n\
type MutationRoot {\n\
\"\"\"a description with a } brace and { another\"\"\"\n\
createTask: Int!\n\
claimTask: Int!\n\
}\n";
let fields = mutation_field_names_from_sdl(sdl);
assert!(
fields.iter().any(|f| f == "createTask") && fields.iter().any(|f| f == "claimTask"),
"both fields must be extracted despite a brace in a description: {fields:?}"
);
}
#[test]
fn sdl_extractor_returns_empty_for_a_query_only_schema() {
let sdl = "schema { query: QueryRoot }\ntype QueryRoot { ping: Int! }\n";
assert!(
mutation_field_names_from_sdl(sdl).is_empty(),
"a schema with no mutation root yields no mutation fields (no panic)"
);
}
/// The comment-masked body of `async fn <snake>` in `graphql/mutation.rs`, bounded
/// to the resolver's OWN braces (its `{ .. }`), for the marker check. Brace-matching
/// on the comment-masked source means a `}` in a trailing helper cannot extend the
/// body and a comment marker does not count. A `{`/`}` inside a STRING literal is
/// not masked, a documented marker-check-only residual (limit 3); completeness does
/// not use this.
fn resolver_body(masked_src: &str, snake: &str) -> String {
let needle = format!("async fn {snake}(");
let start = masked_src.find(&needle).unwrap_or_else(|| {
panic!("resolver `async fn {snake}(` not found in graphql/mutation.rs")
});
let open = start
+ masked_src[start..]
.find('{')
.expect("resolver body opening brace");
let mut depth = 0i32;
let mut end = masked_src.len();
for (i, c) in masked_src[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i + 1;
break;
}
}
_ => {}
}
}
masked_src[open..end].to_string()
}
/// `src` with `//` line comments and `/* */` block comments (nested) blanked to
/// spaces, WITHOUT opening a comment inside a `"..."` string (so a `"https://"` does
/// not erase the rest of its line). String CONTENTS are left intact, so a brace or
/// a gate marker inside a string is not masked; for the marker check that is a
/// documented vacuity residual (limit 3), and completeness does not use this at
/// all. Char literals and raw/byte strings are not tracked (absent in resolver
/// bodies); a `'"'` char or a raw string is the remaining narrow residual.
fn mask_comments(src: &str) -> String {
let chars: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut i = 0;
let mut block_depth = 0usize;
let mut in_string = false;
while i < chars.len() {
let c = chars[i];
let next = chars.get(i + 1).copied();
if block_depth > 0 {
if c == '/' && next == Some('*') {
block_depth += 1;
out.push_str(" ");
i += 2;
} else if c == '*' && next == Some('/') {
block_depth -= 1;
out.push_str(" ");
i += 2;
} else {
out.push(if c == '\n' { '\n' } else { ' ' });
i += 1;
}
} else if in_string {
out.push(c);
if c == '\\' {
if let Some(n) = next {
out.push(n);
i += 2;
} else {
i += 1;
}
} else {
if c == '"' {
in_string = false;
}
i += 1;
}
} else if c == '"' {
in_string = true;
out.push('"');
i += 1;
} else if c == '/' && next == Some('/') {
while i < chars.len() && chars[i] != '\n' {
out.push(' ');
i += 1;
}
} else if c == '/' && next == Some('*') {
block_depth = 1;
out.push_str(" ");
i += 2;
} else {
out.push(c);
i += 1;
}
}
out
}
/// The SDL field set (the completeness source of truth, #219) is exactly the
/// registered mutations. If async-graphql adds or renames a field, this reddens
/// and forces the `every_graphql_mutation_has_its_gate` table to be updated. Uses
/// SDL, not introspection: introspection omits `visible = false` fields, so an
/// introspection-based pin here would go green while a hidden mutation existed.
#[test]
fn schema_lists_exactly_the_registered_mutations() {
let mut fields = graphql_mutation_fields_from_sdl();
fields.sort();
assert_eq!(
fields,
vec!["claimTask", "completeTask", "createTask", "failTask"],
"the SDL mutation field set is the authoritative discovered set"
);
}
/// The marker check is bounded to a resolver's OWN body and is comment- and
/// string-aware: a `//` inside a `"https://"` string does not erase a real gate on
/// that line (no false-red), and a trailing module helper's `did_matches(` does not
/// rescue a resolver whose own gate was removed (no false-green).
#[test]
fn resolver_body_is_bounded_and_comment_and_string_aware() {
let src = "\
impl MutationRoot {
async fn ok(&self) -> Result<u32> {
let _ = (\"see https://x/y\", did_matches(a, b)); // URL string then gate, one line
Ok(0)
}
async fn last(&self) -> Result<u32> {
// did_matches( only in a comment
Ok(0)
}
}
fn helper() { let _ = did_matches(z, z); }
#[cfg(test)]
mod tests { async fn t() { let _ = did_matches(x, y); } }
";
let masked = mask_comments(src);
assert!(
resolver_body(&masked, "ok").contains("did_matches("),
"a `//` inside a string must not erase the real gate on that line"
);
assert!(
!resolver_body(&masked, "last").contains("did_matches("),
"the marker check must be bounded to the resolver's own body (a trailing \
helper's marker must not rescue a resolver whose gate was removed)"
);
}
/// Proves the comment-stripping that GUARD-1 added: a marker that appears only
/// in a full-line comment (the real `replicas.rs` false-pass shape) must NOT
/// satisfy a row.
+10 -42
View File
@@ -70,8 +70,6 @@ pub struct PeerResponse {
pub reachable: bool,
}
pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)";
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
@@ -106,8 +104,8 @@ fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
/// and `localhost` / `.localhost` / `.local` / `.internal` hostnames. Used at
/// announce time and by the boot-time prune of already-poisoned rows.
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
/// and by the boot-time prune of already-poisoned rows.
pub fn is_public_http_url(raw: &str) -> bool {
let url = match reqwest::Url::parse(raw) {
Ok(u) => u,
@@ -127,7 +125,6 @@ pub fn is_public_http_url(raw: &str) -> bool {
host.truncate(host.trim_end_matches('.').len());
if host.is_empty()
|| host == "localhost"
|| host.ends_with(".localhost")
|| host.ends_with(".local")
|| host.ends_with(".internal")
{
@@ -263,9 +260,9 @@ pub async fn announce(
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
// the real peers under junk so node-origin repos stop replicating.
if !is_public_http_url(&req.http_url) {
return Err(AppError::BadRequest(format!(
"http_url {PUBLIC_HTTP_URL_REQUIREMENT}"
)));
return Err(AppError::BadRequest(
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
));
}
// Reject self-announcements: a peer row whose http_url is our own public
@@ -544,7 +541,6 @@ mod tests {
fn rejects_loopback_private_and_internal() {
for bad in [
"http://localhost:7545",
"http://node.localhost:7545",
"http://127.0.0.1:5432/",
"http://localhost:22/",
"http://0.0.0.0:7545",
@@ -624,14 +620,11 @@ mod tests {
#[test]
fn rejects_repeated_trailing_root_dots() {
// Stripping a single root dot leaves `localhost.`, which matches
// neither the equality nor the suffix check. The subdomain form now
// exercises the `.localhost` suffix rejection together with the extra
// trailing dot.
for bad in [
"http://localhost../",
"http://localhost...:7545",
"http://node.localhost../",
] {
// neither the equality nor the suffix check. The subdomain form
// (`node.localhost..`) is not asserted here: `.localhost` is not yet a
// rejected suffix on this base, so that case belongs to whichever
// change adds it rather than to this one.
for bad in ["http://localhost../", "http://localhost...:7545"] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
@@ -1589,31 +1582,6 @@ mod tests {
)
}
#[sqlx::test]
async fn announce_rejects_localhost_subdomains_with_an_accurate_error(pool: PgPool) {
let state = test_state(pool).await;
let did = Keypair::generate().did().to_string();
let resp = announce_only(state.clone())
.oneshot(announce_as(
&did,
&announce_body(&did, "https://node.localhost:7545"),
))
.await
.unwrap();
let (status, error, message) = status_and_error(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(error, "bad_request");
assert_eq!(
message,
"http_url must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)"
);
assert!(
snapshot(&state.db, &did).await.is_none(),
"a rejected .localhost announce must leave no row behind"
);
}
/// U4 scenario 1, and the first test the keyid branch has ever had: a caller
/// who proved control of one DID must not announce another's. Kills
/// neutralizing the handler's keyid comparison, and kills demoting this
+10 -9
View File
@@ -39,11 +39,11 @@ pub async fn register_replica(
) -> Result<(StatusCode, Json<serde_json::Value>)> {
validate_replica_url(&req.url)?;
// The mutation response carries repo metadata (name, replica count), so
// registration applies the same read-visibility decision as the listing:
// a caller who may not read the repo gets the same 404 as a missing one.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replica_did = &auth.0;
@@ -93,10 +93,11 @@ pub async fn unregister_replica(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
// Same gate as register_replica: removal mutates repo-scoped metadata and
// its response discloses the replica count.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replica_did = &auth.0;
state.db.unregister_replica(&record.id, replica_did).await?;
File diff suppressed because it is too large. Load diff
-782
View File
@@ -1,782 +0,0 @@
//! Opaque, integrity-protected continuation tokens for the task read surfaces.
//!
//! The task list pages by keyset over `(created_at, id)`, but the rows a
//! caller may *see* are a filtered subset of the rows the query has to
//! *examine*. Handing the caller a raw `(created_at, id)` cursor therefore
//! forced a choice between two broken options (#327 review): anchor the cursor
//! on the last visible row, and a window of denied tasks longer than the scan
//! budget stalls paging forever; or anchor it on the last examined row, and a
//! denied read leaks the id and timestamp of a task `GET /tasks/{id}` other-
//! wise 404s.
//!
//! A server-issued token removes the choice. The position it carries is the
//! last *examined* candidate, so paging always advances, and the payload is
//! opaque and MAC'd, so the caller learns nothing from it and cannot forge one
//! naming a row of their choosing.
//!
//! The position must be *confidential*, not merely unforgeable: a token that
//! merely signed a base64 payload would still let its holder read the id and
//! timestamp of the denied row it names, which is the disclosure the token
//! exists to prevent. The payload is therefore encrypted under a
//! synthetic-IV construction (SIV): the tag is an HMAC over the filter and the
//! plaintext, and it doubles as the IV seeding the keystream the plaintext is
//! XORed with. That needs no randomness source and no dependency beyond the
//! `hmac`/`sha2` pair already used for webhook signatures and blob recipient
//! tags, and it is decrypt-last: nothing is parsed until the tag verifies.
//!
//! Making the token the *only* accepted cursor also fixes the ordering-domain
//! bug the same review found. `agent_tasks.created_at` is TEXT and compared as
//! TEXT, so `...Z` and `...+00:00` denote one instant but sort differently. A
//! caller-typed timestamp could silently skip or repeat same-time rows. The
//! token instead carries the stored string verbatim, so the value compared is
//! always a value the server wrote.
//!
//! A token is bound to the *caller* as well as the filter. The position it
//! names is the last examined candidate, not the last visible one, so it
//! encodes how far a scan got under one caller's visibility. Resuming it as a
//! different caller would start the scan past rows that caller is entitled to
//! read, silently dropping them from the answer (#327 review). The presenting
//! caller's normalized DID (empty and flagged absent when anonymous) is
//! therefore part of the MAC input, so a token presented by anyone else fails
//! to verify rather than under-reporting.
//!
//! Wire form: `v1.<iv>.<body>`, both parts base64url unpadded: the 16-byte
//! synthetic IV (the truncated MAC, which also seeds the keystream) and the
//! padded, encrypted JSON payload.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::error::AppError;
type HmacSha256 = Hmac<Sha256>;
const CURSOR_PREFIX: &str = "v1";
const KEY_DERIVATION_LABEL: &[u8] = b"gitlawb/tasks-cursor-key/v1";
const TAG_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-tag/v1";
const STREAM_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-stream/v1";
/// Truncated MAC length, and the synthetic IV width. 128 bits is far beyond
/// forgery reach for a token that carries no authority of its own (every page
/// re-runs the visibility gate against the presenting caller), and keeps the
/// token short enough to sit in a query string.
const TAG_LEN: usize = 16;
/// Padding bucket for the serialized cursor payload, in bytes. The keystream
/// XORs the plaintext byte for byte, so token length would otherwise track
/// payload length and leak the named row's field widths to a holder who
/// cannot decrypt anything. 128 comfortably covers the widest realistic
/// payload: a 35-char RFC3339 timestamp (`to_rfc3339()` renders 29 chars at
/// millisecond precision and 32 at microsecond, 35 only with all nine
/// fractional digits), a 36-char server-minted UUID, and the fixed JSON
/// envelope plus a 10-digit expiry, which together total 101 bytes. Should a future payload ever exceed the bucket, the length
/// rounds up to the next multiple instead of failing, so `encode` never
/// breaks — it just mints a longer token.
const PAYLOAD_BUCKET: usize = 128;
/// How long an issued cursor stays acceptable. Keyset positions never go stale
/// on their own — `created_at`/`id` are immutable — so this is not a
/// correctness bound. It bounds how long a token stays valid across a node
/// restart-and-rotate and keeps an abandoned page from being resumed
/// indefinitely.
const CURSOR_TTL_SECS: i64 = 24 * 60 * 60;
/// Node-keyed MAC key for continuation tokens, derived from the node keypair
/// seed so it needs no configuration and survives restarts of the same node.
/// Derived rather than used directly so a token forgery oracle could not
/// bear on the signing key itself.
#[derive(Clone)]
pub struct TaskCursorKey([u8; 32]);
impl std::fmt::Debug for TaskCursorKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TaskCursorKey(<redacted>)")
}
}
impl TaskCursorKey {
pub fn derive(node_seed: &[u8; 32]) -> Self {
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
mac.update(KEY_DERIVATION_LABEL);
let mut key = [0u8; 32];
key.copy_from_slice(&mac.finalize().into_bytes());
Self(key)
}
}
/// The keyset position a token carries: the last candidate row the previous
/// request examined, whether or not the caller was allowed to see it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskPosition {
pub created_at: String,
pub id: String,
}
impl TaskPosition {
pub fn new(created_at: impl Into<String>, id: impl Into<String>) -> Self {
Self {
created_at: created_at.into(),
id: id.into(),
}
}
pub fn as_pair(&self) -> (&str, &str) {
(self.created_at.as_str(), self.id.as_str())
}
}
#[derive(Serialize, Deserialize)]
struct CursorPayload<'a> {
/// `created_at` of the last examined candidate, verbatim as stored.
t: &'a str,
/// `id` of the last examined candidate.
i: &'a str,
/// Unix expiry.
e: i64,
}
/// The filter a page was issued under. A cursor is only meaningful against the
/// same `status`/`assignee_did` filter that produced it: resuming a filtered
/// scan from an unfiltered position (or the reverse) silently skips rows.
/// Bound into the MAC rather than stored in the payload, so it costs no token
/// length and cannot be edited without invalidating the tag.
#[derive(Debug, Clone, Copy)]
pub struct TaskFilter<'a> {
pub status: Option<&'a str>,
pub assignee_did: Option<&'a str>,
}
/// The visibility identity a token was issued to. Normalized through
/// `normalize_owner_key` so the two spellings of one `did:key` identity
/// (`did:key:X` and bare `X`) bind identically, matching `did_matches` on the
/// read path: a caller who authenticates with the other spelling of their own
/// DID must be able to resume their own page.
fn caller_binding(caller: Option<&str>) -> &str {
caller.map(crate::db::normalize_owner_key).unwrap_or("")
}
/// The assignee filter a token was issued under. Normalized through
/// `normalize_owner_key` so the two spellings of one `did:key` identity
/// (`did:key:X` and bare `X`) bind identically, matching `list_tasks_keyset`
/// in SQL: minting under `did:key:X` and resuming with bare `X` (or the reverse)
/// hits the same rows and must verify under the MAC.
fn assignee_binding(assignee: Option<&str>) -> &str {
assignee.map(crate::db::normalize_owner_key).unwrap_or("")
}
fn cursor_mac(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
plaintext: &[u8],
) -> HmacSha256 {
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
mac.update(TAG_DOMAIN);
// Length-prefix every field so no two distinct filter/caller/plaintext
// tuples can produce the same MAC input.
for field in [
filter.status.unwrap_or("").as_bytes(),
assignee_binding(filter.assignee_did).as_bytes(),
caller_binding(caller).as_bytes(),
plaintext,
] {
mac.update(&(field.len() as u64).to_be_bytes());
mac.update(field);
}
// Presence is distinct from emptiness for all three optional fields, so an
// anonymous token cannot collide with one issued to a caller whose
// normalized DID is the empty string.
mac.update(&[
u8::from(filter.status.is_some()),
u8::from(filter.assignee_did.is_some()),
u8::from(caller.is_some()),
]);
mac
}
/// Synthetic IV: the authentication tag over the filter and plaintext, which
/// also seeds the keystream. Deterministic by construction, so no randomness
/// source is needed and two tokens for the same page are byte-identical.
fn siv(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
plaintext: &[u8],
) -> [u8; TAG_LEN] {
let mut out = [0u8; TAG_LEN];
out.copy_from_slice(
&cursor_mac(key, filter, caller, plaintext)
.finalize()
.into_bytes()[..TAG_LEN],
);
out
}
/// XOR `buf` with the keystream for `iv`. Its own inverse, so encrypt and
/// decrypt are the same call.
fn apply_keystream(key: &TaskCursorKey, iv: &[u8; TAG_LEN], buf: &mut [u8]) {
for (block_index, chunk) in buf.chunks_mut(32).enumerate() {
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
mac.update(STREAM_DOMAIN);
mac.update(iv);
mac.update(&(block_index as u64).to_be_bytes());
let block = mac.finalize().into_bytes();
for (byte, k) in chunk.iter_mut().zip(block.iter()) {
*byte ^= k;
}
}
}
/// Mint a token resuming at `position` for `filter`, usable only by `caller`.
///
/// The serialized payload is padded with trailing whitespace to a fixed
/// bucket before the SIV tag is computed: the keystream preserves plaintext
/// length, so an unpadded payload would let the token's length leak the
/// named row's field widths (a 25-char `to_rfc3339()` rendering mints a
/// shorter token than a 35-char one). `decode` tolerates the padding because
/// `serde_json` ignores trailing whitespace, and the tag covers the padded
/// bytes, so a token minted before padding landed still verifies.
pub fn encode(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
position: &TaskPosition,
) -> String {
let mut payload = serde_json::to_vec(&CursorPayload {
t: &position.created_at,
i: &position.id,
e: chrono::Utc::now().timestamp() + CURSOR_TTL_SECS,
})
.expect("cursor payload is plain strings and an integer");
let padded_len = payload.len().next_multiple_of(PAYLOAD_BUCKET);
payload.resize(padded_len, b' ');
let iv = siv(key, filter, caller, &payload);
apply_keystream(key, &iv, &mut payload);
format!(
"{CURSOR_PREFIX}.{}.{}",
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(&payload)
)
}
/// One rejection message for every way a token can fail to verify. A caller
/// who mangled a token, replayed an expired one, or tried to move one to a
/// different filter or a different presenting identity learns only that the
/// cursor is not usable — never which of those it was, and never anything
/// about the row it named.
const INVALID_CURSOR: &str = "invalid or expired cursor";
fn reject() -> AppError {
AppError::BadRequest(INVALID_CURSOR.into())
}
/// Verify a token against `filter` and the presenting `caller`, and return the
/// position it carries.
pub fn decode(
key: &TaskCursorKey,
filter: TaskFilter<'_>,
caller: Option<&str>,
token: &str,
) -> crate::error::Result<TaskPosition> {
let mut parts = token.split('.');
let (Some(version), Some(iv_b64), Some(body_b64), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(reject());
};
if version != CURSOR_PREFIX {
return Err(reject());
}
let iv_bytes = URL_SAFE_NO_PAD.decode(iv_b64).map_err(|_| reject())?;
let mut plaintext = URL_SAFE_NO_PAD.decode(body_b64).map_err(|_| reject())?;
let iv: [u8; TAG_LEN] = iv_bytes.try_into().map_err(|_| reject())?;
apply_keystream(key, &iv, &mut plaintext);
// Authenticate before parsing: until the tag matches, `plaintext` is just
// attacker-chosen bytes run through a keystream. `verify_truncated_left`
// is the constant-time compare, so a forgery attempt cannot be steered by
// timing the first differing byte.
cursor_mac(key, filter, caller, &plaintext)
.verify_truncated_left(&iv)
.map_err(|_| reject())?;
let decoded: CursorPayload<'_> = serde_json::from_slice(&plaintext).map_err(|_| reject())?;
if decoded.e < chrono::Utc::now().timestamp() {
return Err(reject());
}
Ok(TaskPosition::new(decoded.t, decoded.i))
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> TaskCursorKey {
TaskCursorKey::derive(&[7u8; 32])
}
fn unfiltered() -> TaskFilter<'static> {
TaskFilter {
status: None,
assignee_did: None,
}
}
#[test]
fn round_trips_position_verbatim() {
let k = key();
// A stored timestamp the server wrote, fractional digits and all.
let pos = TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a");
let token = encode(&k, unfiltered(), None, &pos);
assert_eq!(decode(&k, unfiltered(), None, &token).unwrap(), pos);
// A sub-bucket payload pads to exactly one bucket: the padding
// claim is an exact width, and every other width assertion here is
// bucket-relative, so only an absolute check catches a pad that
// skips "next multiple" or a drifted bucket constant.
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
assert_eq!(
raw.len(),
PAYLOAD_BUCKET,
"normal token must pad to exactly one bucket"
);
}
/// The whole point of the token is that the caller may hold it without
/// learning the denied row it names. A signed-but-plaintext payload would
/// pass every other test here and still fail this one.
#[test]
fn token_does_not_expose_the_row_it_names() {
let k = key();
let token = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00+00:00", "denied-task-id"),
);
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
let as_text = String::from_utf8_lossy(&raw);
for secret in ["denied-task-id", "2026-01-03", "\"t\"", "\"i\""] {
assert!(
!as_text.contains(secret),
"token body must not carry {secret:?} in the clear: {as_text:?}"
);
}
// Also assert it is not merely reordered or whitespace-mangled JSON.
assert!(
serde_json::from_slice::<serde_json::Value>(&raw).is_err(),
"token body must not be parseable as JSON"
);
}
/// The keystream preserves plaintext length, so without the fixed-width
/// padding a token naming a 35-char timestamp would be measurably longer
/// than one naming a 32-char one: the holder would learn the withheld
/// row's field widths without decrypting anything. Mint the same shape
/// both ways and require identical token length.
#[test]
fn token_length_does_not_vary_with_position_width() {
let k = key();
// Ids are server-minted fixed-length UUIDs; the timestamp is the
// width that varies in production (`to_rfc3339()` renders 35 chars
// with nanoseconds, 25 without fractional seconds).
let id = "b3f7a1c2-4d5e-4f6a-8b9c-0d1e2f3a4b5c";
let narrow = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00+00:00", id),
);
let wide = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id),
);
assert_eq!(
narrow.len(),
wide.len(),
"tokens must not leak the named row's field widths: {narrow} vs {wide}"
);
// The padded token still decodes to the exact stored string.
assert_eq!(
decode(&k, unfiltered(), None, &wide).unwrap(),
TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id)
);
}
/// A token minted before the fixed-width padding landed (unpadded payload,
/// tag over the raw bytes) must still verify: `decode` MACs whatever
/// plaintext the keystream recovers and `serde_json` tolerates the
/// missing trailing whitespace. This pins the back-compat claim in the
/// `encode` docstring with a frozen v1 artifact, so a change to the v1
/// recipe pins (domains, MAC input, keystream, segment layout, field
/// names) breaks this test instead of silently re-deriving under the
/// new internals. It does not pin the padding: `decode` never consults
/// `PAYLOAD_BUCKET` and the fixture is unpadded, so the padding-width
/// claim belongs to `oversized_payload_rounds_up_to_next_bucket`.
///
/// Fixture expiry is fixed at 2100-01-01 (`4102444800`): it must stay in
/// the future whenever the test runs.
#[test]
fn unpadded_legacy_token_still_verifies() {
let k = key();
let filter = unfiltered();
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", "legacy-task");
// Pre-padding recipe minted once and frozen: serialize with no
// padding, tag, and encrypt. Deterministic by construction (SIV needs
// no randomness), so this literal is stable across runs.
let token = "v1.ILJyy7JZScm_h-HM1-CggA.l4kJUoHTbqSGTVKL0DP4hORnbOS1_gHYV2fwJYXd2QtM-6TBMCKh-wvluIiaEYC9rUSUPVSteAKkp7jBCmK_bR2D";
assert_eq!(
decode(&k, filter, None, token).unwrap(),
position,
"pre-padding token must verify under the current decode"
);
// The fixture is a genuine pre-padding artifact, so its decoded
// body must stay below one bucket: a literal re-minted under the
// padded recipe would retire this back-compat pin silently.
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
assert!(
raw.len() < PAYLOAD_BUCKET,
"legacy fixture must be the unpadded shape: {} bytes",
raw.len()
);
}
/// The `PAYLOAD_BUCKET` docstring promises an oversized payload rounds up
/// to the next multiple instead of failing. Mint a position whose id
/// pushes the serialized payload past one bucket and require the token
/// to round-trip (at two buckets).
#[test]
fn oversized_payload_rounds_up_to_next_bucket() {
let k = key();
// ~140-char id: payload crosses the 128-byte bucket.
let long_id = "a".repeat(140);
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", long_id.clone());
let probe = serde_json::to_vec(&CursorPayload {
t: &position.created_at,
i: &position.id,
e: 1_700_000_000,
})
.expect("cursor payload is plain strings and an integer");
assert!(
probe.len() > PAYLOAD_BUCKET,
"fixture payload must exceed one bucket: {} bytes",
probe.len()
);
assert!(
probe.len() <= 2 * PAYLOAD_BUCKET,
"fixture payload must fit in two buckets: {} bytes",
probe.len()
);
let token = encode(&k, unfiltered(), None, &position);
assert_eq!(
decode(&k, unfiltered(), None, &token).unwrap(),
position,
"oversized payload must round-trip at two buckets"
);
// The padded body must be exactly two buckets, not merely longer
// than one: the padding claim is an exact width, not a direction.
let body = token.split('.').nth(2).expect("token has a body part");
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
assert_eq!(
raw.len(),
2 * PAYLOAD_BUCKET,
"oversized payload must pad to exactly two buckets"
);
}
/// Callers paste the token straight into a query string, so it must carry
/// no character that needs percent-encoding.
#[test]
fn token_is_url_safe_verbatim() {
let token = encode(
&key(),
TaskFilter {
status: Some("pending"),
assignee_did: Some("did:key:z6MkAssignee"),
},
None,
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a"),
);
assert!(
token
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')),
"token must be query-safe without encoding: {token}"
);
}
/// Flipping any byte of a token must fail the tag, not decode to a
/// different position: the keystream is malleable on its own, the tag is
/// what stops a chosen-position forgery.
#[test]
fn rejects_any_tampered_byte() {
let k = key();
let token = encode(
&k,
unfiltered(),
None,
&TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a"),
);
let parts: Vec<&str> = token.split('.').collect();
for part in [1usize, 2] {
for position in [0usize, 3] {
let mut bytes = parts[part].as_bytes().to_vec();
bytes[position] = if bytes[position] == b'A' { b'B' } else { b'A' };
let mut mangled: Vec<String> = parts.iter().map(|p| p.to_string()).collect();
mangled[part] = String::from_utf8(bytes).unwrap();
assert!(
decode(&k, unfiltered(), None, &mangled.join(".")).is_err(),
"byte {position} of part {part} must not be malleable"
);
}
}
// Sanity: the untouched token still decodes, so the loop above is not
// passing because every token is rejected.
assert!(decode(&k, unfiltered(), None, &token).is_ok());
}
#[test]
fn rejects_token_from_another_node() {
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&TaskCursorKey::derive(&[1u8; 32]), unfiltered(), None, &pos);
assert!(decode(
&TaskCursorKey::derive(&[2u8; 32]),
unfiltered(),
None,
&token
)
.is_err());
}
#[test]
fn rejects_cursor_moved_to_a_different_filter() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(
&k,
TaskFilter {
status: Some("pending"),
assignee_did: None,
},
None,
&pos,
);
assert!(decode(&k, unfiltered(), None, &token).is_err());
assert!(decode(
&k,
TaskFilter {
status: Some("claimed"),
assignee_did: None
},
None,
&token
)
.is_err());
assert!(decode(
&k,
TaskFilter {
status: Some("pending"),
assignee_did: None
},
None,
&token
)
.is_ok());
}
/// A filter of `Some("")` must not verify a token minted with `None`.
#[test]
fn distinguishes_absent_filter_from_empty_filter() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&k, unfiltered(), None, &pos);
assert!(decode(
&k,
TaskFilter {
status: Some(""),
assignee_did: None
},
None,
&token
)
.is_err());
}
/// A cursor names how far a scan got under one caller's visibility. Moved
/// to a different presenting identity it would start that caller's scan
/// past rows they are entitled to read, so it must fail to verify rather
/// than silently under-report (#327 review).
#[test]
fn rejects_cursor_moved_to_a_different_caller() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let alice = Some("did:key:z6MkAlice");
let bob = Some("did:key:z6MkBob");
let anon_token = encode(&k, unfiltered(), None, &pos);
assert!(
decode(&k, unfiltered(), alice, &anon_token).is_err(),
"an anonymously minted cursor must not resume an authenticated scan"
);
assert!(decode(&k, unfiltered(), None, &anon_token).is_ok());
let alice_token = encode(&k, unfiltered(), alice, &pos);
assert!(
decode(&k, unfiltered(), bob, &alice_token).is_err(),
"one caller's cursor must not resume another caller's scan"
);
assert!(
decode(&k, unfiltered(), None, &alice_token).is_err(),
"an authenticated cursor must not resume an anonymous scan"
);
assert!(decode(&k, unfiltered(), alice, &alice_token).is_ok());
}
/// The binding is on identity, not spelling: `did:key:X` and bare `X` are
/// one caller everywhere else on the read path (`did_matches`), so a caller
/// who presents the other form of their own DID must keep their own page.
#[test]
fn caller_binding_is_normalized_across_did_key_spellings() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&k, unfiltered(), Some("did:key:z6MkAlice"), &pos);
assert_eq!(
decode(&k, unfiltered(), Some("z6MkAlice"), &token).unwrap(),
pos
);
// A different method sharing the base58 tail is a different principal.
assert!(decode(&k, unfiltered(), Some("did:web:z6MkAlice"), &token).is_err());
}
/// The filter binding is on identity, not spelling: `assignee_did` matches
/// normalized keys in SQL (`normalize_owner_key`), so minting under
/// `did:key:X` and resuming under bare `X` (or vice versa) must succeed,
/// while `did:web:X` remains distinct.
#[test]
fn assignee_filter_binding_is_normalized_across_did_key_spellings() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let filter_did_key = TaskFilter {
status: Some("pending"),
assignee_did: Some("did:key:z6MkAssignee"),
};
let filter_bare = TaskFilter {
status: Some("pending"),
assignee_did: Some("z6MkAssignee"),
};
let filter_web = TaskFilter {
status: Some("pending"),
assignee_did: Some("did:web:z6MkAssignee"),
};
let token_from_did_key = encode(&k, filter_did_key, None, &pos);
assert_eq!(
decode(&k, filter_bare, None, &token_from_did_key).unwrap(),
pos
);
assert!(decode(&k, filter_web, None, &token_from_did_key).is_err());
let token_from_bare = encode(&k, filter_bare, None, &pos);
assert_eq!(
decode(&k, filter_did_key, None, &token_from_bare).unwrap(),
pos
);
assert!(decode(&k, filter_web, None, &token_from_bare).is_err());
}
/// Anonymous is a distinct binding from a caller whose normalized DID is
/// the empty string, the same way `Some("")` is distinct from `None` for
/// the filter fields.
#[test]
fn distinguishes_anonymous_from_empty_caller() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(&k, unfiltered(), None, &pos);
assert!(decode(&k, unfiltered(), Some(""), &token).is_err());
}
/// Length-prefixing must stop a status/assignee pair from being re-split.
#[test]
fn rejects_field_boundary_shift() {
let k = key();
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
let token = encode(
&k,
TaskFilter {
status: Some("pend"),
assignee_did: Some("ing"),
},
None,
&pos,
);
assert!(decode(
&k,
TaskFilter {
status: Some("pending"),
assignee_did: Some("")
},
None,
&token
)
.is_err());
}
#[test]
fn rejects_expired_token() {
let k = key();
// Minted the same way `encode` does, but already past its expiry.
let mut payload = serde_json::to_vec(&CursorPayload {
t: "2026-01-03T00:00:00+00:00",
i: "task-a",
e: chrono::Utc::now().timestamp() - 1,
})
.unwrap();
let iv = siv(&k, unfiltered(), None, &payload);
apply_keystream(&k, &iv, &mut payload);
let expired = format!(
"v1.{}.{}",
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(&payload)
);
let err = decode(&k, unfiltered(), None, &expired).unwrap_err();
assert!(err.to_string().contains(INVALID_CURSOR));
}
#[test]
fn rejects_malformed_shapes() {
let k = key();
for bad in [
"",
"v1",
"v1.",
"v1.abc",
"v2.abc.def",
"v1.abc.def.ghi",
"v1.!!!.def",
] {
assert!(
decode(&k, unfiltered(), None, bad).is_err(),
"must reject {bad:?}"
);
}
}
/// Every rejection path renders the same text, so a caller cannot tell a
/// forged token from an expired one from a filter mismatch.
#[test]
fn every_rejection_is_indistinguishable() {
let k = key();
for bad in ["v1.abc.def", "not-a-cursor", "v1..", "v9.a.b"] {
assert_eq!(
decode(&k, unfiltered(), None, bad).unwrap_err().to_string(),
format!("invalid request: {INVALID_CURSOR}")
);
}
}
}
File diff suppressed because it is too large. Load diff
+4 -67
View File
@@ -46,10 +46,9 @@ pub async fn create_webhook(
// endpoints (SSRF). Delivery runs on the shared no-redirect client
// (main.rs), which closes the 3xx-to-internal bounce.
if !crate::api::peers::is_public_http_url(&req.url) {
return Err(AppError::BadRequest(format!(
"webhook URL {}",
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
)));
return Err(AppError::BadRequest(
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
));
}
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
@@ -142,19 +141,7 @@ pub async fn delete_webhook(
#[cfg(test)]
mod tests {
use axum::extract::{Extension, Path, State};
use axum::Json;
use chrono::Utc;
use gitlawb_core::identity::Keypair;
use sqlx::PgPool;
use uuid::Uuid;
use super::{create_webhook, CreateWebhookRequest};
use crate::api::peers::{is_public_http_url, PUBLIC_HTTP_URL_REQUIREMENT};
use crate::auth::AuthenticatedDid;
use crate::db::RepoRecord;
use crate::error::AppError;
use crate::test_support::test_state;
use crate::api::peers::is_public_http_url;
// create_webhook gates req.url through is_public_http_url. Pin the exact
// SSRF targets from issue #81 so the webhook path can never regress to the
@@ -165,7 +152,6 @@ mod tests {
"http://127.0.0.1:5432/",
"http://169.254.169.254/latest/meta-data/",
"http://localhost/",
"http://node.localhost/",
"http://10.0.0.5/",
"http://[::1]/",
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
@@ -183,53 +169,4 @@ mod tests {
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
assert!(is_public_http_url("http://203.0.113.10:7545/"));
}
#[sqlx::test]
async fn webhook_localhost_rejection_uses_shared_public_url_contract(pool: PgPool) {
let state = test_state(pool).await;
let owner = Keypair::generate().did().to_string();
let repo_id = Uuid::new_v4().to_string();
state
.db
.create_repo(&RepoRecord {
id: repo_id.clone(),
name: "webhook-contract".to_string(),
owner_did: owner.clone(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: "/tmp/webhook-contract.git".to_string(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
let result = create_webhook(
State(state.clone()),
Extension(AuthenticatedDid(owner.clone())),
Path((owner, "webhook-contract".to_string())),
Json(CreateWebhookRequest {
url: "https://node.localhost/hook".to_string(),
secret: None,
events: None,
}),
)
.await;
match result {
Err(AppError::BadRequest(message)) => assert_eq!(
message,
format!("webhook URL {PUBLIC_HTTP_URL_REQUIREMENT}")
),
Err(error) => panic!("unexpected webhook rejection: {error}"),
Ok(_) => panic!("a .localhost webhook must be rejected"),
}
assert!(
state.db.list_webhooks(&repo_id).await.unwrap().is_empty(),
"a rejected .localhost webhook must leave no row behind"
);
}
}
-62
View File
@@ -1,62 +0,0 @@
//! #26 Split PR 2 — three-outcome recovery policy types.
//!
//! This module owns the recovery classification policy the reviewer
//! demanded: only a trustworthy, protocol-defined absence authorizes
//! a paid re-upload. Everything else stays non-terminal.
//!
//! Deliberately narrow: the ANS-104 codec lives in `ans104`, and the
//! gateway probe, the `verify_anchor` path, and the public verify
//! endpoint are deferred until the uploader, retrieval proof, and
//! recovery consumer can be proven as one vertical slice (reviewer 2,
//! round 5). A probe against a real provider contract — same-network
//! upload/read pair, redirect-safe client, envelope-supplying read
//! API, provider-documented absence evidence — belongs in that slice,
//! not here. What ships here is the policy type plus the rule that
//! only `DefinitivelyAbsent` permits spending another upload.
/// Outcome of a gateway probe for a persisted `item_id`.
// Policy surface for the recovery slice: no construction on this
// head (the probe arrives with the provider contract); the unit
// test pins the rule meanwhile.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeOutcome {
/// The persisted item was served back with a verifiable envelope
/// bound to the requested id. No re-upload allowed.
Present,
/// Trustworthy, protocol-defined proof the item was never
/// accepted. Authorizes re-upload. The exact evidence that
/// establishes this is defined by the provider contract in the
/// vertical slice that introduces the probe — never by a
/// mock-only body shape.
DefinitivelyAbsent,
/// Anything else: transport failure, oversized body, bad
/// signature, id mismatch, ambiguous gateway response. The
/// outbox stays non-terminal.
Indeterminate,
}
impl ProbeOutcome {
/// True iff the recovery code is allowed to spend another paid
/// upload request. Only `DefinitivelyAbsent` qualifies.
#[allow(dead_code)] // the recovery consumer lives in a later slice
pub fn permits_reupload(self) -> bool {
matches!(self, ProbeOutcome::DefinitivelyAbsent)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Only `DefinitivelyAbsent` authorizes a paid re-upload. This is
/// the policy the reviewer named: collapsing any ambiguous outcome
/// to absent spends a second immutable upload for the same
/// transition.
#[test]
fn only_definitively_absent_authorizes_reupload() {
assert!(!ProbeOutcome::Present.permits_reupload());
assert!(ProbeOutcome::DefinitivelyAbsent.permits_reupload());
assert!(!ProbeOutcome::Indeterminate.permits_reupload());
}
}
-5
View File
@@ -634,12 +634,10 @@ mod tests {
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
.expect("lazy pool creation should not fail");
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
let task_cursor_key = crate::api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
let schema = Arc::new(graphql::build_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
task_cursor_key.clone(),
));
crate::state::AppState {
config: Arc::new(Config::parse_from(["gitlawb-node"])),
@@ -651,7 +649,6 @@ mod tests {
ref_update_tx: ref_tx,
task_event_tx: task_tx,
graphql_schema: schema,
task_cursor_key,
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
@@ -671,7 +668,6 @@ mod tests {
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
federated_rate_limiter: RateLimiter::new_bounded(12, Duration::from_secs(60), 10_000),
shutdown_tx: tokio::sync::watch::channel(false).0,
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
@@ -687,7 +683,6 @@ mod tests {
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_ipfs_walk_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
task_read_rate_limiter: RateLimiter::new(1200, Duration::from_secs(3600)),
git_bin: "git".to_string(),
}
}
+10 -416
View File
@@ -11,74 +11,6 @@ use uuid::Uuid;
use crate::db::RefCertificate;
use crate::state::AppState;
/// Build the v1 signing payload — the EXACT bytes the node signs and
/// gl's `verify_signature` reconstructs. This is the single source
/// of truth shared by `issue_ref_certificate` and the frozen
/// canonical-form tests below; drift here is what would break every
/// existing cert.
///
/// Field order and key set are fixed: a default `serde_json::Value`
/// serializes `Map<String, Value>` with sorted keys, so any change to
/// the literal is observable as a different byte sequence on the
/// wire.
///
/// v1 is the unversioned legacy shape: no `version` key in the signed
/// JSON. From v2 onward the version belongs INSIDE the signed bytes
/// (see `v2_signing_payload`): leaving it as an unsigned sibling
/// would let a future v2 cert be downgraded to v1 and still verify
/// cleanly, since the bytes would be identical.
pub(crate) fn v1_signing_payload(
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher_did: &str,
node_did: &str,
issued_at: &str,
) -> serde_json::Value {
serde_json::json!({
"repo_id": repo_id,
"ref": ref_name,
"old": old_sha,
"new": new_sha,
"pusher": pusher_did,
"node": node_did,
"ts": issued_at,
})
}
/// Build the v2 signing payload — the future shape for versioned
/// certs. Identical to v1 except the wire `version` is part of the
/// signed bytes, so stripping or flipping the `version` column
/// invalidates the signature instead of verifying cleanly under the
/// other version's path.
///
/// NOT yet issued: `issue_ref_certificate` still stamps v1 because
/// the shipped `gl` verifier only supports v1. This builder exists
/// to pin the downgrade-resistant shape now, before any v2 cert is
/// ever signed.
#[allow(dead_code)]
pub(crate) fn v2_signing_payload(
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher_did: &str,
node_did: &str,
issued_at: &str,
) -> serde_json::Value {
serde_json::json!({
"repo_id": repo_id,
"ref": ref_name,
"old": old_sha,
"new": new_sha,
"pusher": pusher_did,
"node": node_did,
"ts": issued_at,
"version": 2,
})
}
/// Issue a signed ref-update certificate for a successful push.
///
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
@@ -94,12 +26,16 @@ pub async fn issue_ref_certificate(
let node_did = state.node_did.to_string();
let issued_at = Utc::now().to_rfc3339();
// Build the canonical signing payload via the shared builder so
// the frozen-vector test below and the live signer cannot drift
// apart — a regression in either side fails both.
let payload = v1_signing_payload(
repo_id, ref_name, old_sha, new_sha, pusher_did, &node_did, &issued_at,
);
// Build the canonical signing payload.
let payload = serde_json::json!({
"repo_id": repo_id,
"ref": ref_name,
"old": old_sha,
"new": new_sha,
"pusher": pusher_did,
"node": node_did,
"ts": issued_at,
});
let payload_bytes = serde_json::to_vec(&payload)?;
let signature = state.node_keypair.sign_b64(&payload_bytes);
@@ -114,351 +50,9 @@ pub async fn issue_ref_certificate(
node_did,
signature,
issued_at,
// #26 Split PR 3: the wire-format version. New certs stamp
// v1 — the pre-versioning 7-field payload with no `version`
// key in the signed JSON — because the shipped `gl`
// verifier only supports v1. Stamping v2 here would issue
// certificates the shipped client refuses (round-3
// finding: node pinned `version == 2` while gl pinned
// "2 is refused", with nothing exercising the
// composition). v2 is reserved for the future shape
// defined by `v2_signing_payload`, which carries the
// version INSIDE the signed bytes; land the v2 verify
// path before stamping v2.
version: 1,
};
// Persist and return the row as it exists in the database (on a
// conflict the existing row survives when it is newer).
state.db.insert_ref_certificate(&cert).await
}
#[cfg(test)]
mod v1_payload_tests {
//! #26 Split PR 3 — cert payload version compat.
//!
//! The v1 cert payload is the frozen canonical byte form the
//! node signs. gl's `verify_signature` reconstructs the same
//! payload and verifies. Any drift in field order, key set, or
//! whitespace breaks every existing cert. The tests here pin
//! the v1 payload shape, the round-trip, the cert id
//! determinism per the v1 version, and the v2 read forward
//! compat (an unknown version refuses to verify rather than
//! guessing).
//!
//! Reviewer 1 finding: the live signer (`issue_ref_certificate`)
//! and the frozen-vector test (`v1_payload_matches_frozen_canonical_form`)
//! previously held two separate `serde_json::json!` literals,
//! so a regression in the signer could not fail this test.
//! Both now go through the shared `v1_signing_payload` builder.
use super::{v1_signing_payload, v2_signing_payload};
use crate::db::RefCertificate;
use gitlawb_core::identity::Keypair;
/// v1 payload is the same shape the legacy code signed, byte
/// for byte. Reverting this assertion to the v2 shape (which
/// includes a `version` key) is what the versioned format
/// forbids: an old client reading a v1 cert must not see a
/// `version` key in the signed JSON.
///
/// The literal is built via the shared `v1_signing_payload`
/// helper that `issue_ref_certificate` also uses, so the live
/// signer and the frozen vector share one source of truth.
#[test]
fn v1_payload_matches_frozen_canonical_form() {
let payload = v1_signing_payload(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
"did:key:z6MkNode",
"2026-07-22T00:00:00+00:00",
);
let frozen = format!(
r#"{{"new":"{}","node":"did:key:z6MkNode","old":"{}","pusher":"did:key:z6MkPusher","ref":"refs/heads/main","repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}}"#,
"a".repeat(40),
"0".repeat(40),
);
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
}
/// Round-trip: sign the v1 payload with a keypair, build a
/// RefCertificate with version: 1, and verify the structure.
/// This is the construction the live code does on every push.
///
/// Uses the shared builder for both halves so a regression in
/// the live signer (adding/removing/renaming a field) cannot
/// pass this test by accident.
#[test]
fn v1_ref_certificate_structure_is_well_formed() {
let kp = Keypair::generate();
let node_did = kp.did().as_str().to_string();
let payload = v1_signing_payload(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
);
let payload_bytes = serde_json::to_vec(&payload).unwrap();
let sig = kp.sign_b64(&payload_bytes);
let cert = RefCertificate {
id: "cert-id".into(),
repo_id: "repo-1".into(),
ref_name: "refs/heads/main".into(),
old_sha: "0".repeat(40),
new_sha: "a".repeat(40),
pusher_did: "did:key:z6MkPusher".into(),
node_did: node_did.clone(),
signature: sig.clone(),
issued_at: "2026-07-22T00:00:00+00:00".into(),
version: 1,
};
assert_eq!(cert.version, 1, "new certs carry version: 1");
// The signed payload reconstructs identically: gl's
// verify_signature would build the same JSON, hash the
// same bytes, and verify the same signature.
let reconstructed = v1_signing_payload(
&cert.repo_id,
&cert.ref_name,
&cert.old_sha,
&cert.new_sha,
&cert.pusher_did,
&cert.node_did,
&cert.issued_at,
);
let reconstructed_bytes = serde_json::to_vec(&reconstructed).unwrap();
assert_eq!(
reconstructed_bytes, payload_bytes,
"the round-trip serialization must be byte-identical; \
this is what makes gl's verify_signature succeed"
);
// Round 3 (P3 reviewer): byte-equality alone never calls
// `identity::verify`, so a bad signature helper would pass.
// Verify the computed signature against the reconstructed
// bytes — the same check gl performs.
let vk = node_did
.parse::<gitlawb_core::did::Did>()
.unwrap()
.to_verifying_key()
.unwrap();
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
.decode(&cert.signature)
.unwrap()
.try_into()
.unwrap();
gitlawb_core::identity::verify(&vk, &reconstructed_bytes, &sig_bytes)
.expect("well-formed v1 cert signature must verify");
}
/// Reviewer 1 finding: the live signer must share the
/// canonical-form literal so a regression in either side
/// fails both. This test pins the shared builder's output
/// against the literal a future v2 byte stream would have
/// to break — if someone adds a `version` key to
/// `v1_signing_payload`, every existing cert breaks verify,
/// and this test is one of the canaries that fires.
#[test]
fn v1_signing_payload_has_no_version_key() {
let payload = v1_signing_payload(
"repo-1",
"refs/heads/main",
"oldsha",
"newsha",
"did:key:z6MkPusher",
"did:key:z6MkNode",
"2026-07-22T00:00:00+00:00",
);
let obj = payload.as_object().expect("payload is a JSON object");
assert!(
!obj.contains_key("version"),
"v1 signing payload must not carry a `version` key — adding \
one changes the bytes and breaks every existing cert's \
signature. v2 certs build a different payload, not this one"
);
// Pin the exact set so a future field addition is caught.
let expected: std::collections::BTreeSet<&str> =
["new", "node", "old", "pusher", "ref", "repo_id", "ts"]
.into_iter()
.collect();
let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
assert_eq!(actual, expected, "v1 payload key set is frozen");
}
/// Round 3 (P2 reviewer): from v2 onward the version belongs
/// INSIDE the signed bytes. v1 stays the unversioned legacy
/// shape; v2 carries `"version": 2` in the payload so a
/// version flip without re-signing invalidates the signature
/// instead of verifying cleanly under the other path.
#[test]
fn v2_signing_payload_binds_version_and_resists_downgrade() {
let kp = Keypair::generate();
let node_did = kp.did().as_str().to_string();
let ts = "2026-07-22T00:00:00+00:00";
let v1 = v1_signing_payload(
"repo-1",
"refs/heads/main",
"oldsha",
"newsha",
"did:key:z6MkPusher",
&node_did,
ts,
);
let v2 = v2_signing_payload(
"repo-1",
"refs/heads/main",
"oldsha",
"newsha",
"did:key:z6MkPusher",
&node_did,
ts,
);
let v2_obj = v2.as_object().expect("v2 payload is a JSON object");
assert_eq!(
v2_obj.get("version"),
Some(&serde_json::json!(2)),
"v2 payload must carry the version inside the signed bytes"
);
let v1_bytes = serde_json::to_vec(&v1).unwrap();
let v2_bytes = serde_json::to_vec(&v2).unwrap();
assert_ne!(
v1_bytes, v2_bytes,
"v1 and v2 payloads must differ, otherwise a version flip is cryptographically invisible"
);
// A signature over v1 must NOT verify as v2 and vice versa.
let vk = node_did
.parse::<gitlawb_core::did::Did>()
.unwrap()
.to_verifying_key()
.unwrap();
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let sig_v1 = kp.sign_b64(&v1_bytes);
let sig_v1_bytes: [u8; 64] = URL_SAFE_NO_PAD.decode(&sig_v1).unwrap().try_into().unwrap();
assert!(
gitlawb_core::identity::verify(&vk, &v1_bytes, &sig_v1_bytes).is_ok(),
"v1 signature must verify under v1 bytes"
);
assert!(
gitlawb_core::identity::verify(&vk, &v2_bytes, &sig_v1_bytes).is_err(),
"a v1 signature presented as v2 (downgrade/upgrade flip) must not verify"
);
}
/// The live issuer stamps the version the shipped client can
/// verify. The gl client refuses to verify v2 certs explicitly;
/// this test pins that the version the ISSUING PATH claims
/// agrees with the payload shape it actually signs.
///
/// Round 2: the prior form built a `RefCertificate` literal with
/// `version: 1` and asserted `version == 1` — true no matter what the
/// signer does, so flipping the issuer's version (or its payload shape)
/// left it green. This one drives the actual
/// `issue_ref_certificate` so a regression in the issuer flips the test.
/// The DB row is created against `test_support::test_state`, which
/// gives us a real `AppState` with a real node keypair.
///
/// Round 3: the issuer stamps v1 because the shipped `gl`
/// verifier only supports v1 (stamping v2 shipped certs the
/// client refuses). The binding to the live issuer is kept —
/// flipping the stamp still turns this test red — without
/// changing what production issues.
#[sqlx::test]
#[allow(clippy::async_yields_async)]
async fn issuer_stamps_v1_over_v1_payload(pool: sqlx::PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let repo_id = "repo-issuer-observe";
let ref_name = "refs/heads/main";
let old = "0".repeat(40);
let new = "a".repeat(40);
let pusher = "did:key:z6MkPusher";
// Drive the live issuer and read the persisted row back by
// id, so the test covers the stored `version` column and
// not just the in-process return value.
let cert =
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
.await
.expect("issue_ref_certificate must succeed");
// The cert returned by the issuer is the source of truth
// for both the version claim and the signed bytes.
assert_eq!(cert.repo_id, repo_id);
assert_eq!(cert.ref_name, ref_name);
// Cross-check 1: the issuer's claimed version must match
// what the live function stamps. This assertion is bound
// to the live function, not a hand-built literal:
// flipping the stamp in `issue_ref_certificate` breaks it
// through the actual call path.
assert_eq!(
cert.version, 1,
"the live issuer must stamp v1 until a v2 verifier ships; \
flipping the stamp breaks this assertion through the actual call path"
);
// Cross-check 1b: the persisted row carries the same stamp.
let stored = state
.db
.get_ref_certificate(&cert.id)
.await
.expect("persisted cert must be readable")
.expect("persisted cert must exist");
assert_eq!(
stored.version, cert.version,
"the stored version column must match the issued claim"
);
// Cross-check 2: the signature on the cert must verify
// against the v1 payload builder's bytes, not against some
// other shape the issuer might have introduced. This is
// what gl's `verify_signature` does for a v1 cert: rebuild
// the payload from the cert's own fields and verify the
// signature. If the issuer ever signs a different shape
// while still claiming v1, every shipped client breaks —
// and so does this.
let rebuilt = v1_signing_payload(
&cert.repo_id,
&cert.ref_name,
&cert.old_sha,
&cert.new_sha,
&cert.pusher_did,
&cert.node_did,
&cert.issued_at,
);
let vk = cert
.node_did
.parse::<gitlawb_core::did::Did>()
.unwrap()
.to_verifying_key()
.unwrap();
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
.decode(&cert.signature)
.unwrap()
.try_into()
.unwrap();
gitlawb_core::identity::verify(&vk, &serde_json::to_vec(&rebuilt).unwrap(), &sig_bytes)
.expect("a v1 cert's signature must verify against the v1 payload shape");
// Cross-check 3: the v1 signed payload must NOT contain a
// version key — v2 is the future shape that adds one inside
// the signed bytes (see `v2_signing_payload`). The v1
// payload (which the cert is signed over) is frozen at 7
// fields. A change that adds `version` to the v1 payload
// breaks every existing cert and this test.
let obj = rebuilt
.as_object()
.expect("rebuilt v1 payload is a JSON object");
assert!(
!obj.contains_key("version"),
"the v1 signing payload must not carry a version key — \
adding one changes the bytes and breaks every existing \
cert's signature"
);
}
}
+8 -291
View File
@@ -55,10 +55,7 @@ pub struct Config {
#[arg(long, env = "GITLAWB_KEY", default_value = "~/.gitlawb/identity.pem")]
pub key_path: String,
/// Reserved and currently inert: read access is enforced per repository
/// through `is_public` and path-scoped visibility rules, not this flag.
/// Setting it to false changes nothing; make a repo private through its
/// own visibility instead.
/// Reserved for private-read mode; per-repo read enforcement is not wired in alpha
#[arg(long, env = "GITLAWB_PUBLIC_READ", default_value_t = true)]
pub public_read: bool,
@@ -66,12 +63,6 @@ pub struct Config {
#[arg(long, env = "GITLAWB_PUBLIC_URL")]
pub public_url: Option<String>,
/// Base URL for the web view of repos on this node (e.g. https://gitlawb.com).
/// When set, `GET /` advertises it as `web_url` so the CLI can print a
/// working `View:` link. Omit for nodes that have no web front-end.
#[arg(long, env = "GITLAWB_WEB_URL")]
pub web_url: Option<String>,
/// Comma-separated list of bootstrap peer URLs to announce to on startup
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
pub bootstrap_peers: Vec<String>,
@@ -762,21 +753,6 @@ pub struct Config {
value_parser = clap::builder::RangedU64ValueParser::<u64>::new().range(0..=86_400)
)]
pub pin_repair_sweep_delay_secs: u64,
/// Per-client-IP rate limit for the anonymous task read routes
/// (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`), in requests per hour.
/// Both are publicly reachable (`optional_signature`). `GET /api/v1/tasks`
/// runs `collect_visible_tasks` and returns a visibility-filtered page;
/// `GET /api/v1/tasks/{id}` runs `get_visible_task` and returns an opaque 404
/// when the task is hidden or missing. These reads can require task,
/// repository, and visibility-rule queries even when no task is returned,
/// so the brake bounds the cost of anonymous probes. Keyed on the resolved
/// client IP via `GITLAWB_TRUSTED_PROXY`. `0` disables. Default: 1200 (a list page
/// followed by per-task reads is a normal client pattern, so this sits above
/// the `/ipfs` budget). GraphQL `tasks` / `task` queries, including WebSocket
/// operations, share this per-IP budget with REST task reads.
#[arg(long, env = "GITLAWB_TASK_READ_RATE_LIMIT", default_value_t = 1200)]
pub task_read_rate_limit: usize,
}
impl Config {
@@ -824,13 +800,7 @@ impl Config {
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
/// catches combinations that ship a denial-of-service under otherwise-valid
/// values. Call once at startup and fail fast on `Err`.
///
/// `&mut self` is required so the validator can normalize `web_url` to
/// its canonical form after validation: the configured value is
/// advertised on public `GET /`, and any consumer reading
/// `config.web_url` later must see the same string that was actually
/// validated.
pub fn validate(&mut self) -> Result<(), String> {
pub fn validate(&self) -> Result<(), String> {
// Concurrent git writes pin the dedicated lock pool for their whole
// duration (connection-affine advisory lock in acquire_write). The main
// pool no longer carries that occupancy.
@@ -947,101 +917,10 @@ impl Config {
.map_err(|e| e.to_string())?;
}
// GITLAWB_WEB_URL is advertised in GET / so the CLI can print a working
// View: link. Clap maps "" to Some("") which would produce a broken URL;
// reject early instead of serving a malformed link. Non-blank values must
// additionally parse as an absolute http(s) URL — the CLI appends
// `/{owner}/{repo}` to it, so scheme-less hosts ("gitlawb.com") and other
// garbage would render links no browser can follow.
if let Some(raw) = &self.web_url {
match validate_web_url(raw) {
Ok(()) => {}
Err(reason) => {
return Err(format!(
"GITLAWB_WEB_URL {reason} — \
set it to an absolute URL like https://gitlawb.com or leave it unset."
));
}
}
// Normalize to one canonical form so every consumer (the regular
// and degraded `GET /` handlers, anyone who reads
// `config.web_url` directly) sees the same string that was
// actually validated. The CLI's own defensive trim would paper
// over the divergence on the `View:` line, but that hides the
// bug from any other consumer that parses the value verbatim.
let canonical = raw
.trim()
.parse::<url::Url>()
.map(|u| u.to_string())
.map_err(|_| "is not a valid absolute URL".to_string())?;
if canonical != *raw {
self.web_url = Some(canonical);
}
}
Ok(())
}
}
/// Validate a `web_url` value for use as a browser-reachable base URL.
/// Blank values are rejected (clap maps `--web-url ""` to `Some("")`), and
/// non-blank values must parse as absolute `http`/`https` URLs with no
/// query, fragment, or userinfo — the CLI treats this as a string prefix
/// to append paths to, so anything else produces links no browser can
/// follow (`?a=1/owner/repo` puts the repo path inside the query string);
/// and userinfo is rejected outright because the configured value is
/// published on public `GET /` and reprinted on the CLI's `View:` line,
/// so an accidentally configured password would land in public metadata,
/// terminal scrollback, and CI logs.
pub(crate) fn validate_web_url(raw: &str) -> Result<(), String> {
if raw.trim().is_empty() {
return Err("must not be empty or whitespace-only".into());
}
let parsed: url::Url = raw
.trim()
.parse()
.map_err(|_| "is not a valid absolute URL".to_string())?;
match parsed.scheme() {
"http" | "https" => {}
other => return Err(format!("must use http or https, got '{other}:'")),
}
// url::Url cannot represent a URL without a host for http/https schemes, so
// reaching here guarantees an absolute browser-usable base.
if parsed.query().is_some() {
return Err(
"must not contain a query string — it is used as a path prefix for View links".into(),
);
}
if parsed.fragment().is_some() {
return Err(
"must not contain a fragment — it is used as a path prefix for View links".into(),
);
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(
"must not contain a username or password — it is advertised publicly and printed on the View: line"
.into(),
);
}
// Bidi/control bytes can hide behind percent-encoding: a literal scan of
// the wire form misses `.../%E2%80%AE` even though a browser decodes it
// into a reordering character on the rendered View: link. Check the
// decoded host and path before the value is advertised on GET / (INV-6).
let host = parsed.host_str().unwrap_or("");
let decoded_path = percent_encoding::percent_decode_str(parsed.path()).decode_utf8_lossy();
if host
.chars()
.chain(decoded_path.chars())
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
{
return Err(
"must not contain control or bidi characters, even percent-encoded — the View: line is terminal output"
.into(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1684,7 +1563,7 @@ mod tests {
.expect("default config must validate");
// An under-sized lock pool relative to the push cap is rejected.
let mut under_lock = Config::parse_from([
let under_lock = Config::parse_from([
"gitlawb-node",
"--db-lock-pool-max-connections",
"16",
@@ -1697,7 +1576,7 @@ mod tests {
);
// Exactly at the push cap without non-push headroom is rejected.
let mut push_only = Config::parse_from([
let push_only = Config::parse_from([
"gitlawb-node",
"--db-lock-pool-max-connections",
"32",
@@ -1710,7 +1589,7 @@ mod tests {
);
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
let mut split = Config::parse_from([
let split = Config::parse_from([
"gitlawb-node",
"--db-max-connections",
"16",
@@ -1725,153 +1604,13 @@ mod tests {
);
// Main pool still needs the app headroom floor.
let mut under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
assert!(
under_main.validate().is_err(),
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
);
}
#[test]
fn web_url_rejects_empty_and_whitespace() {
// Unset is fine.
Config::parse_from(["gitlawb-node"])
.validate()
.expect("no web_url must validate");
// A real URL validates.
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some("https://gitlawb.com".into());
assert!(cfg.validate().is_ok());
// Empty string is rejected.
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some("".into());
assert!(cfg.validate().is_err(), "empty web_url must be rejected");
// Whitespace-only is rejected.
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(" ".into());
assert!(
cfg.validate().is_err(),
"whitespace-only web_url must be rejected"
);
}
#[test]
fn web_url_rejects_non_absolute_or_non_browser_urls() {
// Scheme-less host: parses nowhere, renders a broken View: link.
for bad in [
"gitlawb.com",
"not a url",
"ftp://files.example.com",
"javascript:alert(1)",
"https://", // no host
] {
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(bad.into());
assert!(cfg.validate().is_err(), "web_url {bad:?} must be rejected");
}
// Query strings and fragments corrupt the appended repo path, since
// web_url is used as a raw string prefix (`?a=1` would swallow
// `/{owner}/{repo}` into the query).
for bad in [
"https://gitlawb.com?a=1",
"https://gitlawb.com/?utm_source=docs",
"https://gitlawb.com#section",
"https://gitlawb.com/#faq",
] {
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(bad.into());
assert!(
cfg.validate().is_err(),
"web_url {bad:?} (query or fragment) must be rejected"
);
}
// Surrounding whitespace around a valid URL is tolerated.
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(" https://gitlawb.com ".into());
assert!(
cfg.validate().is_ok(),
"padded-but-valid web_url must validate"
);
// Percent-encoded bidi must not boot: the wire form carries no Cf
// code point, but a browser decodes %E2%80%AE into a reordering
// character on the rendered View: link (INV-6). Literal bidi and
// control bytes are rejected the same way.
for bad in [
"https://example.com/%E2%80%AE",
"https://example.com/\u{202e}spoof",
"https://example.com/%1b[31m",
] {
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(bad.into());
assert!(
cfg.validate().is_err(),
"web_url {bad:?} (control or bidi, even encoded) must be rejected"
);
}
// Non-default ports and paths are fine.
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some("http://localhost:8080/ui".into());
assert!(cfg.validate().is_ok(), "port+path web_url must validate");
// Userinfo must be rejected: `web_url` is published on public
// `GET /` and reprinted on the CLI's `View:` line, so an
// accidentally configured password would land in public metadata,
// terminal scrollback, and CI logs. Reject username-only and
// user+password forms.
for bad in [
"https://[email protected]",
"https://user:[email protected]",
"https://:[email protected]",
] {
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(bad.into());
assert!(
cfg.validate().is_err(),
"web_url {bad:?} (userinfo) must be rejected"
);
}
}
/// `validate()` accepts whitespace-padded input but the stored value
/// must match the canonical form actually validated, so every consumer
/// (the regular and degraded `GET /` handlers, anyone who reads
/// `config.web_url` directly) sees the same string. Without this, an
/// operator that sets `GITLAWB_WEB_URL=" https://git.example "` boots
/// a node that advertises `" https://git.example "` on public
/// metadata — different from what was validated, different from what
/// any other consumer would parse.
#[test]
fn web_url_is_normalized_to_canonical_form_after_validation() {
// Padded input is rewritten to the canonical form. `url::Url`'s
// own `to_string` is what we use for the canonicalization, and
// it always appends a path-separator slash to a bare-host URL,
// so the stored value is `https://git.example/` (which is also
// what every browser would normalize the operator-typed form to).
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some(" https://git.example ".into());
cfg.validate().unwrap();
assert_eq!(
cfg.web_url.as_deref(),
Some("https://git.example/"),
"stored web_url must be the canonical form, not the operator-typed one"
);
// Already-canonical input is left alone after a no-op
// re-normalization — same form comes back out, so any consumer
// reading the stored value sees a stable string across calls.
let mut cfg = Config::parse_from(["gitlawb-node"]);
cfg.web_url = Some("https://git.example/".into());
cfg.validate().unwrap();
assert_eq!(cfg.web_url.as_deref(), Some("https://git.example/"));
}
/// The DECLARED default, read off the parser rather than out of a parse.
///
/// `Config::parse_from` consults the process environment, so on a host that
@@ -1895,28 +1634,6 @@ mod tests {
);
}
/// #338: GITLAWB_PUBLIC_READ is inert, so its help must not claim read
/// enforcement is missing; it must tell the operator the flag changes
/// nothing and where the real control lives.
#[test]
fn public_read_help_describes_the_flag_as_inert() {
use clap::CommandFactory;
let cmd = Config::command();
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == "public_read")
.expect("the argument must exist");
let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
assert!(
!help.contains("not wired"),
"the help still claims read enforcement is unwired: {help}"
);
assert!(
help.contains("inert"),
"the help must say the flag is inert so an operator does not rely on it: {help}"
);
}
/// The flip must not strand an operator mid-upgrade.
///
/// Turning the gate on is a breaking change for any deployment whose pushers are
@@ -2136,7 +1853,7 @@ mod tests {
std::os::unix::fs::symlink(&target, &link).unwrap();
let key_path = link.join("p2p.key");
let mut config = Config::parse_from([
let config = Config::parse_from([
"gitlawb-node",
"--p2p-port",
"0",
@@ -2231,7 +1948,7 @@ mod tests {
let key_path = build(base.path());
let before = snapshot(base.path());
let mut config = Config::parse_from([
let config = Config::parse_from([
"gitlawb-node",
"--p2p-port",
"7546",
File diff suppressed because it is too large. Load diff
+1 -15
View File
@@ -12,9 +12,6 @@ pub enum AppError {
#[error("repo already exists: {0}")]
RepoExists(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("not found: {0}")]
NotFound(String),
@@ -104,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
@@ -192,7 +189,6 @@ impl IntoResponse for AppError {
"repo_exists",
format!("repository '{r}' already exists"),
),
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
@@ -325,16 +321,6 @@ pub type Result<T> = std::result::Result<T, AppError>;
mod tests {
use super::*;
#[test]
fn conflict_maps_to_409() {
assert_eq!(
AppError::Conflict("task not claimable".into())
.into_response()
.status(),
StatusCode::CONFLICT
);
}
#[test]
fn timeout_maps_to_504_distinct_from_git_500() {
assert_eq!(
+29 -76
View File
@@ -141,10 +141,19 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
return Ok(None);
}
// List all refs that start with the given string; the glob also matches the exact
// ref. The id handed back is always git's own refname, never the request input.
// Try exact match first — fast path for callers passing the full UUID.
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
let prefix_glob = format!("{exact_ref}*");
let check = Command::new("git")
.args(["cat-file", "-e", &exact_ref])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
if check.status.success() {
return Ok(Some(id_or_prefix.to_string()));
}
// Prefix search: list all refs that start with the given string.
let prefix_glob = format!("refs/gitlawb/issues/{id_or_prefix}*");
let list = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", &prefix_glob])
.current_dir(repo_path)
@@ -156,44 +165,25 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
}
let output = String::from_utf8_lossy(&list.stdout);
let matches: Vec<&str> = output
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
// An exact id wins over longer ids that share it as a prefix, but only if its
// object exists: for-each-ref also lists a ref whose blob is missing, so a
// dangling exact ref falls through to the prefix rules below.
let exact = match matches.iter().find(|m| **m == exact_ref) {
Some(m) => {
let check = Command::new("git")
.args(["cat-file", "-e", m])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
check.status.success().then_some(*m)
match matches.len() {
0 => Ok(None),
1 => {
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = matches[0]
.trim()
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(matches[0].trim())
.to_string();
Ok(Some(full_id))
}
None => None,
};
let found = match exact {
Some(m) => m,
None => match matches.as_slice() {
[] => return Ok(None),
[m] => *m,
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
},
};
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = found
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(found)
.to_string();
Ok(Some(full_id))
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
}
}
/// Close an issue by updating its status to "closed" in the git ref.
@@ -277,43 +267,6 @@ mod tests {
assert_eq!(resolved, Some(full_id.to_string()));
}
#[test]
fn test_resolve_exact_id_wins_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some("abc12345".to_string()));
}
#[test]
fn test_resolve_dangling_exact_id_does_not_win_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open","n":1}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open","n":2}"#).unwrap();
// Dangle the exact ref: the ref stays listed by for-each-ref, but its blob is gone.
let out = Command::new("git")
.args(["rev-parse", "refs/gitlawb/issues/abc12345"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8(out.stdout).unwrap();
let hash = hash.trim();
std::fs::remove_file(
dir.path()
.join(".git/objects")
.join(&hash[..2])
.join(&hash[2..]),
)
.unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.unwrap_err().to_string().contains("ambiguous"));
assert!(close_issue(dir.path(), "abc12345").is_err());
}
#[test]
fn test_resolve_prefix_matches_unique() {
let dir = TempDir::new().unwrap();
+2 -73
View File
@@ -60,7 +60,6 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
// Curated client-safe variants — `Display` is intentional API text.
safe @ (crate::error::AppError::RepoNotFound(_)
| crate::error::AppError::RepoExists(_)
| crate::error::AppError::Conflict(_)
| crate::error::AppError::NotFound(_)
| crate::error::AppError::Unauthorized(_)
| crate::error::AppError::Forbidden(_)
@@ -77,81 +76,15 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
}
}
/// Classify a failed `claim_task` for GraphQL exactly as REST's claim handler
/// classifies it: a lost race is a client-safe conflict, a real sqlx fault
/// stays opaque. Lives here rather than at the three call sites so the
/// classification cannot drift between transports (#327 review), and so
/// `every_graphql_map_err_uses_opaque_helpers` can keep whitelisting by name.
pub(crate) fn graphql_claim_conflict(e: anyhow::Error) -> async_graphql::Error {
graphql_app_err(crate::api::tasks::task_write_conflict(
e,
"task not claimable: not found or already claimed",
))
}
/// The `finish_task` half of [`graphql_claim_conflict`], covering both
/// `completeTask` and `failTask`.
pub(crate) fn graphql_finish_conflict(e: anyhow::Error) -> async_graphql::Error {
graphql_app_err(crate::api::tasks::task_write_conflict(
e,
"task not found or not in claimed state",
))
}
pub struct TaskReadBrakeExtension;
impl async_graphql::extensions::ExtensionFactory for TaskReadBrakeExtension {
fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
Arc::new(TaskReadBrakeExtensionImpl)
}
}
use async_graphql::async_trait::async_trait;
struct TaskReadBrakeExtensionImpl;
#[async_trait]
impl async_graphql::extensions::Extension for TaskReadBrakeExtensionImpl {
async fn prepare_request(
&self,
ctx: &async_graphql::extensions::ExtensionContext<'_>,
mut request: async_graphql::Request,
next: async_graphql::extensions::NextPrepareRequest<'_>,
) -> async_graphql::ServerResult<async_graphql::Request> {
if let Some(session_brake) = ctx
.session_data
.get(&std::any::TypeId::of::<crate::rate_limit::TaskReadBrake>())
.and_then(|d| d.downcast_ref::<crate::rate_limit::TaskReadBrake>())
{
request = request.data(crate::rate_limit::TaskReadBrake {
limiter: session_brake.limiter.clone(),
key: session_brake.key.clone(),
request_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
});
}
if let Some(did) = ctx
.session_data
.get(&std::any::TypeId::of::<crate::auth::AuthenticatedDid>())
.and_then(|d| d.downcast_ref::<crate::auth::AuthenticatedDid>())
{
request = request.data(did.clone());
}
next.run(ctx, request).await
}
}
pub fn build_schema(
db: Arc<Db>,
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
task_cursor_key: crate::api::task_cursor::TaskCursorKey,
) -> GitlawbSchema {
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
.data(db)
.data(ref_update_tx)
.data(task_event_tx)
.data(task_cursor_key)
.extension(TaskReadBrakeExtension)
.finish()
}
@@ -228,10 +161,8 @@ mod tests {
}
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
/// through one of the curated helpers in this module, or discard the error
/// (`|_|`). Same source-scrape pattern as `api::authz_guard` (#255
/// review). The list is deliberately a whitelist of names rather than a
/// prefix match, so adding a new mapper is a decision a reviewer sees.
/// through the opaque helpers, or discard the error (`|_|`). Same source-
/// scrape pattern as `api::authz_guard` (#255 review).
#[test]
fn every_graphql_map_err_uses_opaque_helpers() {
for (file, src) in [
@@ -246,8 +177,6 @@ mod tests {
let after = code[idx + ".map_err(".len()..].trim_start();
let ok = after.starts_with("crate::graphql::graphql_db_err")
|| after.starts_with("crate::graphql::graphql_app_err")
|| after.starts_with("crate::graphql::graphql_claim_conflict")
|| after.starts_with("crate::graphql::graphql_finish_conflict")
|| after.starts_with("|_|")
|| after.starts_with("|_ ");
assert!(
+88 -800
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(),
@@ -79,14 +52,10 @@ impl MutationRoot {
updated_at: now,
deadline: input.deadline,
};
// Return the stored row, not the pre-insert input: create_task
// normalizes the assignee (whitespace-only becomes NULL/open), and a
// response that shows the raw input lies about the reservation.
let stored = db
.create_task(&task)
db.create_task(&task)
.await
.map_err(crate::graphql::graphql_db_err)?;
Ok(AgentTaskType::from(stored))
Ok(AgentTaskType::from(task))
}
async fn claim_task(
@@ -104,39 +73,20 @@ impl MutationRoot {
let assignee_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
crate::api::tasks::get_claimable_task(db, &id, caller)
.await
.map_err(crate::graphql::graphql_app_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
// Same classification REST's claim handler applies: a claim race is a
// client-safe conflict, a genuine sqlx failure stays opaque. Routing
// both transports through `task_write_conflict` is what stops a normal
// race from reaching a GraphQL caller as a generic database error
// (#327 review).
let task = db
.claim_task(&id, &assignee_did)
.await
.map_err(crate::graphql::graphql_claim_conflict)?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.unwrap_or(assignee_did.as_str())
.to_string();
crate::api::tasks::announce_task_event(
db,
tx,
TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
},
)
.await;
.map_err(crate::graphql::graphql_db_err)?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did: assignee_did,
at: Utc::now().to_rfc3339(),
});
Ok(AgentTaskType::from(task))
}
async fn complete_task(
&self,
ctx: &Context<'_>,
@@ -153,43 +103,29 @@ impl MutationRoot {
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: the task must be visible to the caller (returning
// not found for invisible tasks so existence is not leaked), and only
// the task's assignee may finish it.
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
// Authorize the actor: binding by_did to the signer is necessary but not
// sufficient — only the task's assignee may finish it.
let existing = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_app_err)?
.map_err(crate::graphql::graphql_db_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(
caller,
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
) {
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
return Err(async_graphql::Error::new(
"only the task assignee can complete it",
));
}
let task = db
.finish_task(&id, "completed", input.result.as_deref(), caller)
.finish_task(&id, "completed", input.result.as_deref())
.await
.map_err(crate::graphql::graphql_finish_conflict)?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.unwrap_or(by_did.as_str())
.to_string();
crate::api::tasks::announce_task_event(
db,
tx,
TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "completed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
},
)
.await;
.map_err(crate::graphql::graphql_db_err)?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "completed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
});
Ok(AgentTaskType::from(task))
}
@@ -209,44 +145,29 @@ impl MutationRoot {
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: the task must be visible to the caller (returning
// not found for invisible tasks so existence is not leaked), and only
// the task's assignee may fail it.
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
// Authorize the actor: only the task's assignee may fail it.
let existing = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_app_err)?
.map_err(crate::graphql::graphql_db_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(
caller,
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
) {
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
return Err(async_graphql::Error::new(
"only the task assignee can fail it",
));
}
let reason = input.reason.unwrap_or_default();
let task = db
.finish_task(&id, "failed", Some(&reason), caller)
.finish_task(&id, "failed", Some(&reason))
.await
.map_err(crate::graphql::graphql_finish_conflict)?;
let by_did = task
.assignee_did
.as_deref()
.map(crate::db::trim_assignee_did)
.unwrap_or(by_did.as_str())
.to_string();
crate::api::tasks::announce_task_event(
db,
tx,
TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "failed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
},
)
.await;
.map_err(crate::graphql::graphql_db_err)?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
new_status: "failed".to_string(),
by_did,
at: Utc::now().to_rfc3339(),
});
Ok(AgentTaskType::from(task))
}
}
@@ -265,94 +186,57 @@ 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
/// N2: GraphQL mutations require a verified signer and bind the acting DID to
/// it. Unsigned is rejected; a signer other than the claimed actor is
/// rejected; the matching signer passes the auth gate.
#[sqlx::test]
async fn each_mutation_requires_and_binds_signer(pool: PgPool) {
async fn mutation_requires_and_binds_signer(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let schema = state.graphql_schema.as_ref();
let actor = "did:key:zGQLACTORAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let other = "did:key:zGQLOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let cases: Vec<(&str, String)> = vec![
(
"createTask",
format!(
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write" }}) {{ id }} }}"#
),
),
(
"claimTask",
format!(
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{actor}") {{ id }} }}"#
),
),
(
"completeTask",
format!(
r#"mutation {{ completeTask(id: "no-such-task", byDid: "{actor}", input: {{}}) {{ id }} }}"#
),
),
(
"failTask",
format!(
r#"mutation {{ failTask(id: "no-such-task", byDid: "{actor}", input: {{}}) {{ id }} }}"#
),
),
];
let assignee = "did:key:zASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let q = format!(
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{assignee}") {{ id }} }}"#
);
for (name, q) in &cases {
let resp = schema.execute(Request::new(q.as_str())).await;
assert!(
errors(&resp).contains("authentication required"),
"{name}: unsigned mutation must be rejected: {}",
errors(&resp)
);
// 1. Unsigned → rejected before any DB work.
let resp = schema.execute(Request::new(&q)).await;
assert!(
errors(&resp).contains("authentication required"),
"unsigned mutation must be rejected: {}",
errors(&resp)
);
let resp = schema
.execute(Request::new(q.as_str()).data(AuthenticatedDid(other.into())))
.await;
assert!(
errors(&resp).contains("authenticated signer"),
"{name}: DID mismatch must be rejected: {}",
errors(&resp)
);
// 2. Signed as someone other than the claimed assignee → rejected.
let resp = schema
.execute(Request::new(&q).data(AuthenticatedDid(
"did:key:zOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
)))
.await;
assert!(
errors(&resp).contains("authenticated signer"),
"DID mismatch must be rejected: {}",
errors(&resp)
);
let resp = schema
.execute(Request::new(q.as_str()).data(AuthenticatedDid(actor.into())))
.await;
let errs = errors(&resp);
assert!(
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
"{name}: matching signer must pass the auth gate: {errs}"
);
if *name == "claimTask" {
assert!(
errs.contains("task not found"),
"claim race / missing task must keep its business message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"business error must not be rewritten as opaque DB error: {errs}"
);
}
}
// 3. Signed as the claimed assignee → passes the auth gate. The missing
// task is a business error from claim_task, not a sqlx fault, so the
// actionable message must survive (not the opaque DB string) (#250).
let resp = schema
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
"matching signer must pass the auth gate: {errs}"
);
assert!(
errs.contains("task not claimable"),
"claim race / missing task must keep its business message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"business error must not be rewritten as opaque DB error: {errs}"
);
}
/// #250: mutation DB faults must be opaque; create_task hits agent_tasks.
@@ -388,210 +272,6 @@ mod tests {
);
}
/// #327 review: `claimTask`/`completeTask`/`failTask` called
/// `graphql_db_err` directly on db-layer business failures, so an ordinary
/// claim race or stale finish reached GraphQL clients as a generic
/// database error while REST clients got an actionable conflict. All three
/// now route through the shared `task_write_conflict` classifier.
///
/// Deleting either `map_err` from a mutation turns this red: the message
/// becomes the raw anyhow text instead of the fixed conflict form.
#[sqlx::test]
async fn task_write_races_surface_as_conflicts_not_db_errors(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let schema = state.graphql_schema.as_ref();
let assignee = "did:key:zGQLRACEASSIGNEEAAAAAAAAAAAAAAAAAAAAAAA";
let rival = "did:key:zGQLRACERIVALBBBBBBBBBBBBBBBBBBBBBBBBBB";
let delegator = "did:key:zGQLRACEDELEGATORCCCCCCCCCCCCCCCCCCCCC";
let now = chrono::Utc::now().to_rfc3339();
state
.db
.create_repo(&crate::db::RepoRecord {
id: "race-repo".into(),
name: "race".into(),
owner_did: delegator.into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/tmp/race-repo".into(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
state
.db
.create_task(&crate::db::AgentTask {
id: "race-task".into(),
repo_id: Some("race-repo".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: delegator.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
})
.await
.unwrap();
// The assignee wins the claim.
let claim = |did: &str| {
format!(r#"mutation {{ claimTask(id: "race-task", assigneeDid: "{did}") {{ id }} }}"#)
};
let resp = schema
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
.await;
assert!(resp.errors.is_empty(), "first claim: {}", errors(&resp));
// The rival loses the race: ineligible caller receives opaque not-found.
let resp = schema
.execute(Request::new(claim(rival)).data(AuthenticatedDid(rival.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not found"),
"ineligible caller after task is claimed must receive opaque not-found: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"ineligible claim is not a database failure: {errs}"
);
// A second claim that reaches the conditional SQL write (e.g. assignee claiming
// again on an already-claimed task) exercises the conflict mapper:
let resp = schema
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not claimable: not found or already claimed"),
"a lost claim race must render the fixed conflict message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a claim race is not a database failure: {errs}"
);
// A stale finish on an already-completed task is the same class.
let complete = format!(
r#"mutation {{ completeTask(id: "race-task", byDid: "{assignee}", input: {{ result: "ok" }}) {{ id }} }}"#
);
let resp = schema
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
.await;
assert!(resp.errors.is_empty(), "first complete: {}", errors(&resp));
let resp = schema
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not found or not in claimed state"),
"a stale complete must render the fixed conflict message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a stale complete is not a database failure: {errs}"
);
let fail = format!(
r#"mutation {{ failTask(id: "race-task", byDid: "{assignee}", input: {{ reason: "nope" }}) {{ id }} }}"#
);
let resp = schema
.execute(Request::new(&fail).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains("task not found or not in claimed state"),
"a stale fail must render the fixed conflict message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a stale fail is not a database failure: {errs}"
);
}
/// The other half of the same classification: a genuine SQL fault on the
/// write itself must stay opaque. Without this, routing conflicts through
/// `task_write_conflict` could be "fixed" by making every db failure a
/// client-visible conflict message.
#[sqlx::test]
async fn task_write_sql_faults_stay_opaque(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
let assignee = "did:key:zGQLOPAQUEASSIGNEEAAAAAAAAAAAAAAAAAAAAA";
let delegator = "did:key:zGQLOPAQUEDELEGATORBBBBBBBBBBBBBBBBBBB";
let now = chrono::Utc::now().to_rfc3339();
state
.db
.create_task(&crate::db::AgentTask {
id: "opaque-task".into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: delegator.into(),
assignee_did: Some(assignee.into()),
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
})
.await
.unwrap();
// Fault the write itself, not the schema. Dropping a column the
// visibility pre-check also SELECTs would fail in `get_visible_task`
// and never reach `graphql_claim_conflict`, so this test would pass
// against a claim mapper that leaks (#327 review). A BEFORE UPDATE
// trigger keeps every read valid and faults only inside
// `Db::claim_task`.
sqlx::raw_sql(
"CREATE FUNCTION gl_test_fault_task_update() RETURNS trigger
LANGUAGE plpgsql AS $fn$
BEGIN RAISE EXCEPTION 'gl_test_forced_update_fault'; END;
$fn$;
CREATE TRIGGER gl_test_fault_task_update
BEFORE UPDATE ON agent_tasks
FOR EACH ROW EXECUTE FUNCTION gl_test_fault_task_update();",
)
.execute(&pool)
.await
.unwrap();
let resp = state
.graphql_schema
.execute(
Request::new(format!(
r#"mutation {{ claimTask(id: "opaque-task", assigneeDid: "{assignee}") {{ id }} }}"#
))
.data(AuthenticatedDid(assignee.into())),
)
.await;
let errs = errors(&resp);
assert!(
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"a real sqlx fault on the write must stay opaque: {errs}"
);
assert!(
!errs.contains("task not claimable"),
"a write-time sqlx fault must not be reclassified as a claim race: {errs}"
);
assert!(
!errs.contains("gl_test_forced_update_fault") && !errs.contains("trigger"),
"database text leaked through the conflict mapper: {errs}"
);
}
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
/// assignee, not merely signing as the by_did you pass. A signer who is not
/// the assignee is rejected even though the by_did binding passes; the
@@ -614,7 +294,7 @@ mod tests {
payload: None,
result: None,
created_at: now.clone(),
updated_at: now.clone(),
updated_at: now,
deadline: None,
};
state.db.create_task(&task).await.expect("seed task");
@@ -631,69 +311,14 @@ mod tests {
)
};
// Stranger signs as themselves on a repo-less task they cannot see:
// invisible task returns "task not found" so existence is not leaked.
// Stranger signs as themselves and passes byDid=self (so the signer
// binding passes), but is not the assignee → rejected by authorization.
let resp = schema
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).contains("task not found"),
"an invisible task must return not found, got: {}",
errors(&resp)
);
// Seed a task on a public repo that stranger CAN see, but is not assignee of:
let pub_repo = crate::db::RepoRecord {
id: "pub-r".into(),
name: "pub-r".into(),
owner_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/tmp/pub-r".into(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&pub_repo).await.expect("create repo");
let pub_task = crate::db::AgentTask {
id: "task-pub".into(),
repo_id: Some("pub-r".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
};
state
.db
.create_task(&pub_task)
.await
.expect("seed pub task");
state
.db
.claim_task("task-pub", assignee)
.await
.expect("claim pub task");
let q_pub = |actor: &str| {
format!(
r#"mutation {{ completeTask(id: "task-pub", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
)
};
let resp = schema
.execute(Request::new(q_pub(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).contains("assignee"),
"a non-assignee signer on a visible task must be rejected: {}",
"a non-assignee signer must be rejected: {}",
errors(&resp)
);
@@ -707,341 +332,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_keyset(None, None, 50, None)
.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}"
);
}
/// Pre-assigned GraphQL claimTask must reject a stranger (no UCAN leak)
/// and admit the reserved assignee.
#[sqlx::test]
async fn claim_task_honors_preassigned_assignee(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let reserved = "did:key:zGQLRESERVEDAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let thief = "did:key:zGQLTHIEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let now = chrono::Utc::now().to_rfc3339();
let task = crate::db::AgentTask {
id: "task-gql-reserved".into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
assignee_did: Some(reserved.into()),
capability: "repo:write".into(),
ucan_token: Some("gql-ucan-secret".into()),
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
};
state.db.create_task(&task).await.expect("seed");
let schema = state.graphql_schema.as_ref();
let q = |actor: &str| {
format!(
r#"mutation {{ claimTask(id: "task-gql-reserved", assigneeDid: "{actor}") {{ id status ucanToken }} }}"#
)
};
let resp = schema
.execute(Request::new(q(thief)).data(AuthenticatedDid(thief.into())))
.await;
let errs = errors(&resp);
// The claim gate hides existence: an ineligible claimer gets the same
// not-found as a missing task, not a reserved deny that would leak it.
assert!(
errs.contains("task not found"),
"stranger claim must read as not-found, got: {errs}"
);
assert!(
!errs.contains("gql-ucan-secret"),
"UCAN must not leak in GraphQL errors: {errs}"
);
if let Ok(data) = resp.data.into_json() {
let s = data.to_string();
assert!(
!s.contains("gql-ucan-secret"),
"UCAN must not leak in GraphQL data on failed claim: {s}"
);
}
// Parity with REST: failed steal must leave the row untouched.
let still = state
.db
.get_task("task-gql-reserved")
.await
.expect("get")
.expect("task exists");
assert_eq!(
still.status, "pending",
"status must stay pending after steal"
);
assert_eq!(
still.assignee_did.as_deref(),
Some(reserved),
"assignee_did must stay reserved after steal"
);
let resp = schema
.execute(Request::new(q(reserved)).data(AuthenticatedDid(reserved.into())))
.await;
assert!(
errors(&resp).is_empty(),
"reserved assignee must claim: {}",
errors(&resp)
);
let data = resp.data.into_json().expect("json data");
assert_eq!(data["claimTask"]["status"], "claimed");
assert_eq!(data["claimTask"]["ucanToken"], "gql-ucan-secret");
}
/// create_task normalizes a whitespace-only assignee to NULL (open task),
/// so the create response must show the stored row, not echo the input: a
/// client trusting the echoed "reservation" would believe a claimable task
/// is reserved. Before the fix the response was built from the pre-insert
/// struct and returned the padded input verbatim.
#[sqlx::test]
async fn create_task_reports_stored_assignee_not_input(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let caller = "did:key:zGQLCREATORAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let schema = state.graphql_schema.as_ref();
let q = format!(
r#"mutation {{ createTask(delegatorDid: "{caller}", input: {{ kind: "build", capability: "repo:write", assigneeDid: " " }}) {{ id assigneeDid }} }}"#
);
let resp = schema
.execute(Request::new(q).data(AuthenticatedDid(caller.into())))
.await;
let data = resp.data.into_json().expect("json data");
assert!(
data["createTask"]["assigneeDid"].is_null(),
"whitespace-only assigneeDid must come back null (stored shape): {data}"
);
}
/// A task seeded with a whitespace-padded assignee must still be
/// completable by that assignee over GraphQL: REST and Db::finish_task
/// trim the stored value before comparing, and the GraphQL gate must do
/// the same or padded reservations finish only through REST.
#[sqlx::test]
async fn graphql_finish_accepts_padded_stored_assignee(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let assignee = "did:key:zGQLPADDEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let now = chrono::Utc::now().to_rfc3339();
let task = crate::db::AgentTask {
id: "task-gql-padded".into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: "did:key:zGQLDELEGATORDDDDDDDDDDDDDDDDDDDDDDDDDD".into(),
assignee_did: Some(format!("\t{assignee}\n")),
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
};
state.db.create_task(&task).await.expect("seed");
let schema = state.graphql_schema.as_ref();
let claim = format!(
r#"mutation {{ claimTask(id: "task-gql-padded", assigneeDid: "{assignee}") {{ id status }} }}"#
);
let resp = schema
.execute(Request::new(claim).data(AuthenticatedDid(assignee.into())))
.await;
assert!(
errors(&resp).is_empty(),
"reserved assignee must claim a padded reservation: {}",
errors(&resp)
);
let finish = format!(
r#"mutation {{ completeTask(id: "task-gql-padded", byDid: "{assignee}", input: {{}}) {{ id status }} }}"#
);
let resp = schema
.execute(Request::new(finish).data(AuthenticatedDid(assignee.into())))
.await;
let errs = errors(&resp);
assert!(
errs.is_empty(),
"assignee must complete a padded reservation: {errs}"
);
let data = resp.data.into_json().expect("json data");
assert_eq!(data["completeTask"]["status"], "completed");
}
}
+23 -479
View File
@@ -3,27 +3,10 @@ use std::sync::Arc;
use crate::db::Db;
use super::types::{AgentTaskReadType, RefUpdateType, RepoType, TaskPageType};
use super::types::{AgentTaskType, RefUpdateType, RepoType};
pub struct QueryRoot;
/// Debit the per-IP task-read brake for one task field, mirroring the
/// `rate_limit_by_ip` layer on `task_read_routes`. Both surfaces run the same
/// #268 visibility gate and pay the same queries before answering, so an
/// anonymous prober must not get an unbraked lane by asking over GraphQL
/// (#327 review).
///
/// A schema built without the brake (unit tests, subscriptions) is not braked,
/// exactly as `rate_limit_by_ip` is a no-op without its extension.
async fn task_read_brake(ctx: &Context<'_>, field: &str) -> Result<()> {
match ctx.data::<crate::rate_limit::TaskReadBrake>() {
Ok(brake) if !brake.check(field).await => Err(async_graphql::Error::new(
crate::rate_limit::RATE_LIMIT_MESSAGE,
)),
_ => Ok(()),
}
}
#[Object]
impl QueryRoot {
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
@@ -129,73 +112,29 @@ impl QueryRoot {
assignee_did: Option<String>,
#[graphql(
default = 50,
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0. Use `nextCursor` to read past the clamp."
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
)]
limit: i64,
#[graphql(
desc = "Opaque continuation token from a previous page's `nextCursor`. Must be presented with the same `status`/`assigneeDid` filter that issued it."
)]
cursor: Option<String>,
) -> Result<TaskPageType> {
use crate::api::task_cursor::{self, TaskCursorKey, TaskFilter};
task_read_brake(ctx, "tasks").await?;
) -> Result<Vec<AgentTaskType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// #268: gate rows via the same collector the REST list route uses (like
// `ref_updates` shares `collect_visible_ref_updates` with its REST feed),
// so the two surfaces cannot drift. The collector clamps `limit` itself,
// including the negative-LIMIT case #250 called out for this resolver.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let key = ctx.data_unchecked::<TaskCursorKey>();
let filter = TaskFilter {
status: status.as_deref(),
assignee_did: assignee_did.as_deref(),
};
let resume = cursor
.as_deref()
.map(|token| task_cursor::decode(key, filter, caller, token))
.transpose()
.map_err(crate::graphql::graphql_app_err)?;
let result = crate::api::tasks::collect_visible_tasks(
db,
status.as_deref(),
assignee_did.as_deref(),
limit,
resume.as_ref(),
caller,
)
.await
.map_err(crate::graphql::graphql_app_err)?;
Ok(TaskPageType {
next_cursor: result
.next_position
.as_ref()
.map(|pos| task_cursor::encode(key, filter, caller, pos)),
items: result
.tasks
.into_iter()
.map(AgentTaskReadType::from)
.collect(),
has_more: result.has_more,
incomplete: result.incomplete,
})
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
// rejects with 2201W, which would otherwise trip the opaque DB path
// and write an error-level log on every probe (#250 review).
let limit = limit.clamp(0, 200);
let tasks = db
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
.await
.map_err(crate::graphql::graphql_db_err)?;
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
}
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskReadType>> {
task_read_brake(ctx, "task").await?;
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// #268: same gate as the REST get route, via the shared helper.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let t = crate::api::tasks::get_visible_task(db, &id, caller)
let t = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_app_err)?;
Ok(t.map(AgentTaskReadType::from))
.map_err(crate::graphql::graphql_db_err)?;
Ok(t.map(AgentTaskType::from))
}
}
@@ -214,14 +153,10 @@ mod tests {
Arc::new(db)
}
fn cursor_key() -> crate::api::task_cursor::TaskCursorKey {
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32])
}
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
let (task_tx, _) = tokio::sync::broadcast::channel(16);
super::super::build_schema(db, ref_tx, task_tx, cursor_key())
super::super::build_schema(db, ref_tx, task_tx)
}
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
@@ -531,7 +466,7 @@ mod tests {
async fn tasks_negative_limit_clamped(pool: PgPool) {
let db = db(pool).await;
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: -1) { items { id } } }").await;
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
assert!(
resp.errors.is_empty(),
"negative limit must clamp, not fail: {:?}",
@@ -565,409 +500,18 @@ mod tests {
.unwrap();
}
let schema = schema(db);
// Queried as the delegator, not anonymously: since #268 the task read
// surface is visibility-gated, and an anonymous caller sees none of
// these repo-less tasks at all. The clamp is what this test pins, so it
// needs a caller who can legitimately see all 201 rows.
let resp = authed(&schema, "{ tasks(limit: 5000) { items { id } } }", OWNER).await;
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
}
/// Seed one repo-less task carrying a `ucan_token`, so a leak on any read
/// surface is visible in the response body.
async fn seed_task(db: &Db, id: &str, delegator: &str) {
let now = Utc::now().to_rfc3339();
db.create_task(&crate::db::AgentTask {
id: id.into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: delegator.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: Some("SECRET-UCAN-TOKEN".into()),
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
})
.await
.unwrap();
}
/// #268: the `tasks` resolver must delegate to the gated collector, not
/// query the DB directly. A repo-less task belonging to someone else is
/// invisible to an anonymous caller. `tasks_negative_limit_clamped` cannot
/// catch a resolver that stops calling `collect_visible_tasks` because it
/// seeds no rows — this seeds one, so the gate is load-bearing here.
#[sqlx::test]
async fn tasks_repo_less_task_hidden_from_anon(pool: PgPool) {
let db = db(pool).await;
seed_task(&db, "t1", OWNER).await;
let schema = schema(db);
let resp = anon(&schema, "{ tasks { items { id } } }").await;
assert_eq!(
count_tasks(&resp),
0,
"anon must not enumerate another party's repo-less task"
);
assert!(
!format!("{:?}", resp.data).contains("SECRET-UCAN-TOKEN"),
"no ucan token may reach an anonymous caller"
);
}
/// #268 sibling for the single-task resolver: an invisible task reads as
/// `null`, indistinguishable from one that does not exist.
#[sqlx::test]
async fn task_by_id_is_null_for_anon(pool: PgPool) {
let db = db(pool).await;
seed_task(&db, "t1", OWNER).await;
let schema = schema(db);
let resp = anon(&schema, r#"{ task(id: "t1") { id } }"#).await;
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);
};
assert_eq!(
obj.get("task"),
Some(&async_graphql::Value::Null),
"an invisible task must read as null, got {:?}",
obj.get("task")
);
}
/// #268: `ucanToken` is absent from the read type's schema entirely, so the
/// delegator cannot request it either. Asking for it is a validation error,
/// which pins the redaction at the schema level rather than per-resolver.
#[sqlx::test]
async fn task_read_schema_has_no_ucan_token_field(pool: PgPool) {
let db = db(pool).await;
seed_task(&db, "t1", OWNER).await;
let schema = schema(db);
let resp = authed(&schema, r#"{ task(id: "t1") { id ucanToken } }"#, OWNER).await;
assert!(
!resp.errors.is_empty(),
"ucanToken must not exist on the task read type"
);
}
#[sqlx::test]
async fn tasks_find_older_visible_row_behind_denied_window(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("public-repo", OWNER, "public", true))
.await
.unwrap();
let visible = crate::db::AgentTask {
id: "visible".into(),
repo_id: Some("public-repo".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: OWNER.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
db.create_task(&visible).await.unwrap();
for i in 0..200 {
let mut hidden = visible.clone();
hidden.id = format!("hidden-{i:03}");
hidden.repo_id = None;
hidden.created_at = "2026-01-02T00:00:00Z".into();
hidden.updated_at = hidden.created_at.clone();
db.create_task(&hidden).await.unwrap();
}
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: 1) { items { id } incomplete } }").await;
assert_eq!(count_tasks(&resp), 1);
assert!(!task_incomplete(&resp));
assert!(format!("{:?}", resp.data).contains("visible"));
}
/// The GraphQL surface must page past a denied window on server-issued
/// cursors alone, exactly as REST does (#327 review). Before this, the
/// only cursor a caller could hold named the last row they saw, so the
/// query stalled at `incomplete: true` forever and never reached
/// `past-ceiling`.
#[sqlx::test]
async fn tasks_page_past_candidate_ceiling_on_server_cursors(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("public-repo", OWNER, "public", true))
.await
.unwrap();
let visible_newer = crate::db::AgentTask {
id: "newer-visible".into(),
repo_id: Some("public-repo".into()),
kind: "build".into(),
status: "pending".into(),
delegator_did: OWNER.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: "2026-01-03T00:00:00Z".into(),
updated_at: "2026-01-03T00:00:00Z".into(),
deadline: None,
};
db.create_task(&visible_newer).await.unwrap();
for i in 0..1500 {
let mut hidden = visible_newer.clone();
hidden.id = format!("hidden-{i:04}");
hidden.repo_id = None;
hidden.created_at = "2026-01-02T00:00:00Z".into();
hidden.updated_at = hidden.created_at.clone();
db.create_task(&hidden).await.unwrap();
}
let mut visible_older = visible_newer.clone();
visible_older.id = "past-ceiling".into();
visible_older.created_at = "2026-01-01T00:00:00Z".into();
visible_older.updated_at = visible_older.created_at.clone();
db.create_task(&visible_older).await.unwrap();
let schema = schema(db);
let mut seen: Vec<String> = Vec::new();
let mut cursor: Option<String> = None;
for request in 0..10 {
let query = match &cursor {
Some(c) => format!(
r#"{{ tasks(limit: 1, cursor: "{c}") {{ items {{ id }} hasMore incomplete nextCursor }} }}"#
),
None => {
"{ tasks(limit: 1) { items { id } hasMore incomplete nextCursor } }".to_string()
}
};
let resp = anon(&schema, &query).await;
let rendered = format!("{:?}", resp.data);
assert!(
!rendered.contains("hidden-"),
"request {request} disclosed a denied row: {rendered}"
);
for item in task_items(&resp) {
let async_graphql::Value::Object(row) = item else {
panic!("task item not an object");
};
seen.push(row.get("id").unwrap().to_string().replace('"', ""));
}
// Short page with more behind it is the scan wall, and says so.
if task_page_bool(&resp, "hasMore") && task_items(&resp).is_empty() {
assert!(
task_incomplete(&resp),
"request {request}: an empty page with rows behind it is a paused scan"
);
}
match task_page_cursor(&resp) {
Some(c) => cursor = Some(c),
None => {
assert!(!task_page_bool(&resp, "hasMore"));
assert!(!task_incomplete(&resp));
break;
}
}
}
assert_eq!(
seen,
vec!["newer-visible".to_string(), "past-ceiling".to_string()],
"both visible rows must be reachable using only server-issued cursors"
);
}
/// REST and GraphQL mint the same tokens from the same node key, so a
/// cursor must not be usable against a filter it was not issued for, and
/// must render the same single rejection every other bad cursor renders.
#[sqlx::test]
async fn tasks_rejects_unusable_cursor(pool: PgPool) {
use crate::api::task_cursor::{self, TaskFilter, TaskPosition};
let db = db(pool).await;
let schema = schema(db);
let wrong_filter = task_cursor::encode(
&cursor_key(),
TaskFilter {
status: Some("pending"),
assignee_did: None,
},
None,
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
);
let foreign = task_cursor::encode(
&crate::api::task_cursor::TaskCursorKey::derive(&[9u8; 32]),
TaskFilter {
status: None,
assignee_did: None,
},
None,
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
);
for (label, query) in [
(
"garbage",
r#"{ tasks(cursor: "not-a-cursor") { items { id } } }"#.to_string(),
),
(
"another node's key",
format!(r#"{{ tasks(cursor: "{foreign}") {{ items {{ id }} }} }}"#),
),
(
"different filter",
format!(r#"{{ tasks(cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#),
),
] {
let resp = anon(&schema, &query).await;
assert_eq!(
resp.errors.len(),
1,
"{label}: must be rejected, not treated as page one"
);
assert!(
resp.errors[0].message.contains("invalid or expired cursor"),
"{label}: unexpected message {:?}",
resp.errors[0].message
);
}
let resp = anon(
&schema,
&format!(
r#"{{ tasks(status: "pending", cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#
),
)
.await;
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
}
#[sqlx::test]
async fn tasks_anonymous_denial_hides_repoless_task_and_leaks_no_token(pool: PgPool) {
let db = db(pool).await;
let task = crate::db::AgentTask {
id: "t1".into(),
repo_id: None,
kind: "code-review".into(),
status: "pending".into(),
delegator_did: "did:key:z6MkDelegator".into(),
assignee_did: None,
capability: "agent:task".into(),
ucan_token: Some("secret-ucan-token".into()),
payload: Some("payload".into()),
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
db.create_task(&task).await.unwrap();
let schema = schema(db);
let query = "{ tasks { items { id } } }";
let resp = anon(&schema, query).await;
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
assert_eq!(count_tasks(&resp), 0);
let rendered = format!("{:?}", resp.data);
assert!(!rendered.contains("secret-ucan-token"));
}
#[sqlx::test]
async fn task_anonymous_denial_returns_null_and_leaks_no_token(pool: PgPool) {
let db = db(pool).await;
let task = crate::db::AgentTask {
id: "t1".into(),
repo_id: None,
kind: "code-review".into(),
status: "pending".into(),
delegator_did: "did:key:z6MkDelegator".into(),
assignee_did: None,
capability: "agent:task".into(),
ucan_token: Some("secret-ucan-token".into()),
payload: Some("payload".into()),
result: None,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
deadline: None,
};
db.create_task(&task).await.unwrap();
let schema = schema(db);
let query = r#"{ task(id: "t1") { id } }"#;
let resp = anon(&schema, query).await;
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);
};
assert_eq!(obj.get("task"), Some(&async_graphql::Value::Null));
let rendered = format!("{:?}", resp.data);
assert!(!rendered.contains("secret-ucan-token"));
}
fn task_page_bool(resp: &async_graphql::Response, field: &str) -> bool {
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(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
let async_graphql::Value::Boolean(value) = page.get(field).expect("field present") else {
panic!("{field} not a bool");
};
*value
}
fn task_page_cursor(resp: &async_graphql::Response) -> Option<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(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
match page.get("nextCursor").expect("nextCursor key") {
async_graphql::Value::Null => None,
async_graphql::Value::String(c) => Some(c.clone()),
other => panic!("nextCursor not a string: {other:?}"),
}
}
fn task_items(resp: &async_graphql::Response) -> &Vec<async_graphql::Value> {
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(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
};
let async_graphql::Value::List(rows) = page.get("items").expect("items key") else {
panic!("items not a list");
};
rows
}
fn count_tasks(resp: &async_graphql::Response) -> usize {
task_items(resp).len()
}
fn task_incomplete(resp: &async_graphql::Response) -> bool {
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(page) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not an object");
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not a list");
};
let async_graphql::Value::Boolean(incomplete) =
page.get("incomplete").expect("incomplete key")
else {
panic!("incomplete not a bool");
};
*incomplete
rows.len()
}
}
@@ -11,9 +11,9 @@ pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
/// Live ref-update stream. `/graphql/ws` is mounted under the
/// `optional_signature` layer and carries optional caller identity, but this
/// resolver does not inspect it — it relays whatever enters the broadcast
/// Live ref-update stream. `/graphql/ws` is mounted outside the
/// `optional_signature` layer, so this resolver has NO caller identity and
/// cannot gate per-subscriber — it relays whatever enters the broadcast
/// channel to any anonymous client. Its visibility safety therefore rests
/// entirely on the WRITE side: the push handler only sends a
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
-59
View File
@@ -48,65 +48,6 @@ impl From<AgentTask> for AgentTaskType {
}
}
/// Read-only projection of `AgentTask` for the `tasks`/`task` queries, as
/// opposed to `AgentTaskType`, which the task mutations (`createTask`,
/// `claimTask`, `completeTask`, `failTask` — all `require_signer`-gated)
/// return. Identical except for the missing `ucan_token` (#268): a read
/// surface never needs to echo it back, since the assignee already received it
/// at delegation/claim time via the mutation response.
#[derive(SimpleObject, Clone)]
pub struct AgentTaskReadType {
pub id: String,
pub repo_id: Option<String>,
pub kind: String,
pub status: String,
pub delegator_did: String,
pub assignee_did: Option<String>,
pub capability: String,
pub payload: Option<String>,
pub result: Option<String>,
pub created_at: String,
pub updated_at: String,
pub deadline: Option<String>,
}
/// Wraps a `tasks` page with the same completion signals the REST list route
/// exposes, so the two surfaces answer pagination identically.
///
/// `hasMore` and `incomplete` are separate because they are separate facts
/// (#327 review): `hasMore` says more candidates remain, `incomplete` says
/// this page is short *only* because the authorization scan hit its safety
/// wall. `nextCursor` is present exactly when `hasMore` is true and is an
/// opaque MAC'd token — it can name the last examined candidate, denied or
/// not, without disclosing it, which is what lets a caller page past a denied
/// window instead of stalling on it.
#[derive(SimpleObject, Clone)]
pub struct TaskPageType {
pub items: Vec<AgentTaskReadType>,
pub has_more: bool,
pub incomplete: bool,
pub next_cursor: Option<String>,
}
impl From<AgentTask> for AgentTaskReadType {
fn from(t: AgentTask) -> Self {
Self {
id: t.id,
repo_id: t.repo_id,
kind: t.kind,
status: t.status,
delegator_did: t.delegator_did,
assignee_did: t.assignee_did,
capability: t.capability,
payload: t.payload,
result: t.result,
created_at: t.created_at,
updated_at: t.updated_at,
deadline: t.deadline,
}
}
}
#[derive(SimpleObject, Clone)]
pub struct RefUpdateType {
pub repo: String,
+16 -97
View File
@@ -1508,6 +1508,9 @@ pub async fn pin_git_object(
return Ok(String::new());
}
// Compute the expected CIDv1 from the content bytes
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('/')
@@ -1554,10 +1557,6 @@ pub async fn pin_git_object(
.text()
.await
.map_err(|e| anyhow::anyhow!("IPFS add response body read failed: {e}"))?;
// A 200 without a parseable Hash is not a successful pin. Fabricating the
// caller-computed CID would record an address the backend never confirmed;
// above its chunk threshold that raw CID is not even a stored block, so
// every later cat fails permanently.
let cid = body
.lines()
.filter(|l| !l.trim().is_empty())
@@ -1566,17 +1565,7 @@ pub async fn pin_git_object(
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
)
})?;
// 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| {
anyhow::anyhow!("IPFS /api/v0/add returned a Hash that is not a CID ({cid:.200}): {e}")
})?;
.unwrap_or(expected_cid.clone());
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
Ok(cid)
@@ -2234,16 +2223,16 @@ mod tests {
endpoint
}
/// A sleeping-but-live endpoint. Answers `200` with a conformant NDJSON add
/// response after `delays[i]` for the i-th request it accepts (the last
/// entry repeats), so a test can make one add slow and the next fast.
/// Drains the full request, headers plus the declared `Content-Length`
/// body, before sleeping: exactly as in `rejecting_endpoint`, answering
/// early and closing would surface as a write failure on the client and
/// turn a slow-but-healthy add into a different failure shape.
/// A sleeping-but-live endpoint. Answers `200` with an empty body after
/// `delays[i]` for the i-th request it accepts (the last entry repeats), so
/// a test can make one add slow and the next fast. Drains the full request,
/// headers plus the declared `Content-Length` body, before sleeping: exactly
/// as in `rejecting_endpoint`, answering early and closing would surface as
/// a write failure on the client and turn a slow-but-healthy add into a
/// different failure shape.
///
/// The Hash is a fixed, real CID: the timing tests assert on pin counts,
/// not on the recorded address.
/// An empty body is a successful pin: `pin_git_object` falls back to the CID
/// it computed from the bytes when the response carries no `Hash`.
async fn delaying_endpoint(delays: Vec<Duration>) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2280,14 +2269,9 @@ mod tests {
}
}
tokio::time::sleep(delay).await;
let body = br#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4","Size":"12"}
"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\n\r\n",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.write_all(body).await;
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
let _ = sock.flush().await;
});
}
@@ -2389,71 +2373,6 @@ mod tests {
);
}
/// A 200 add response with no parseable Hash must surface as an add
/// failure. Falling back to the caller-computed CID records an
/// unverifiable address: above the backend's chunk threshold that raw
/// CID is not a stored block, so every later cat 500s.
#[tokio::test]
async fn pin_git_object_rejects_a_200_without_a_hash() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
.with_status(200)
.with_body(r#"{"Name":"object","Size":"19"}"#)
.create_async()
.await;
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
assert!(
result.is_err(),
"a 200 without a Hash must be an add failure, not a fabricated CID: {result:?}"
);
}
/// A Hash that is a string but not a CID must also fail. It would be
/// recorded into `encrypted_blobs.cid` and every later cat would miss.
#[tokio::test]
async fn pin_git_object_rejects_a_malformed_hash() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
.with_status(200)
.with_body(r#"{"Hash":"not-a-cid","Size":"19"}"#)
.create_async()
.await;
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
assert!(
result.is_err(),
"a Hash that is not a CID must be an add failure: {result:?}"
);
}
/// The Hash text goes into the error and `encrypted_pin` logs that error,
/// so a backend answering with a huge invalid Hash must not produce an
/// unbounded log entry.
#[tokio::test]
async fn pin_git_object_bounds_an_oversized_invalid_hash_in_the_error() {
let long = "x".repeat(5000);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
.with_status(200)
.with_body(format!(r#"{{"Hash":"{long}","Size":"19"}}"#))
.create_async()
.await;
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
let err = result.expect_err("an invalid Hash must be an add failure");
let msg = err.to_string();
assert!(
msg.len() < 1000,
"the error must bound the echoed Hash, got {} bytes",
msg.len()
);
assert!(
!msg.contains(&long),
"the error must not carry the full invalid Hash"
);
}
/// The permit-hold bound. `pin_new_objects` runs under a deferring
/// `pin_semaphore`, so without a batch deadline the hold is O(N) with N
/// chosen by the pusher. Five objects against an endpoint that takes 2s
+3 -39
View File
@@ -1,7 +1,5 @@
mod ans104;
mod api;
mod arweave;
mod arweave_v2;
mod auth;
mod bootstrap;
mod cert;
@@ -51,7 +49,6 @@ use state::AppState;
struct DegradedState {
node_did: String,
db_startup: Arc<DbStartupStatus>,
web_url: Option<String>,
}
/// Two independent counters with no cross-field invariant — atomics, not a
@@ -87,7 +84,7 @@ async fn main() -> Result<()> {
if !config.public_read {
warn!(
"GITLAWB_PUBLIC_READ=false has no effect; reads are gated per repository by is_public and path-scoped visibility rules, not this flag"
"GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha"
);
}
@@ -160,7 +157,6 @@ async fn main() -> Result<()> {
degraded_listener,
node_did.to_string(),
Arc::clone(&db_startup),
config.web_url.clone(),
db_ready_rx,
shutdown_tx.subscribe(),
));
@@ -306,15 +302,10 @@ async fn main() -> Result<()> {
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
// Node-keyed, so continuation tokens this node mints are only accepted by
// this node and survive its restarts without any configured secret.
let task_cursor_key = api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
let graphql_schema = Arc::new(graphql::build_schema(
Arc::clone(&db),
ref_update_tx.clone(),
task_event_tx.clone(),
task_cursor_key.clone(),
));
let machine_id = std::env::var("FLY_MACHINE_ID").ok();
@@ -436,8 +427,6 @@ async fn main() -> Result<()> {
std::time::Duration::from_secs(3600),
200_000,
);
let federated_rate_limiter =
rate_limit::RateLimiter::new_bounded(12, std::time::Duration::from_secs(60), 10_000);
if config.sync_trigger_rate_limit == 0 {
tracing::warn!(
"GITLAWB_SYNC_TRIGGER_RATE_LIMIT=0 — /sync/trigger IP rate limiting disabled"
@@ -455,7 +444,6 @@ async fn main() -> Result<()> {
db,
node_did: node_did.clone(),
node_keypair: keypair,
task_cursor_key,
p2p: p2p_handle,
http_client,
ref_update_tx,
@@ -477,7 +465,6 @@ async fn main() -> Result<()> {
push_limiter_trust,
sync_trigger_rate_limiter,
peer_write_rate_limiter,
federated_rate_limiter,
shutdown_tx: shutdown_tx.clone(),
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_git_ops)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(
@@ -554,19 +541,11 @@ async fn main() -> Result<()> {
std::time::Duration::from_secs(3600),
200_000,
),
task_read_rate_limiter: rate_limit::RateLimiter::new_bounded(
config.task_read_rate_limit,
std::time::Duration::from_secs(3600),
200_000,
),
git_bin: "git".to_string(),
};
if config.ipfs_rate_limit == 0 {
tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled");
}
if config.task_read_rate_limit == 0 {
tracing::warn!("GITLAWB_TASK_READ_RATE_LIMIT=0 — per-IP task read rate limiting disabled");
}
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
// we still set the gauge to 0 so dashboards don't show "no data".
@@ -888,12 +867,11 @@ async fn run_degraded_server(
listener: TcpListener,
node_did: String,
db_startup: Arc<DbStartupStatus>,
web_url: Option<String>,
mut db_ready_rx: watch::Receiver<bool>,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<()> {
let addr = listener.local_addr().ok();
let router = build_degraded_router(node_did, db_startup, web_url);
let router = build_degraded_router(node_did, db_startup);
info!(?addr, "degraded HTTP server ready");
axum::serve(listener, router)
@@ -910,15 +888,10 @@ async fn run_degraded_server(
Ok(())
}
fn build_degraded_router(
node_did: String,
db_startup: Arc<DbStartupStatus>,
web_url: Option<String>,
) -> Router {
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
let state = DegradedState {
node_did,
db_startup,
web_url,
};
// Everything answers 503 with the same body — including /health and
// /ready, so peer readiness probes and uptime monitors correctly see a
@@ -950,9 +923,6 @@ async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResp
obj.insert("name".into(), "gitlawb-node".into());
obj.insert("version".into(), env!("CARGO_PKG_VERSION").into());
obj.insert("did".into(), state.node_did.clone().into());
if let Some(web_url) = &state.web_url {
obj.insert("web_url".into(), web_url.clone().into());
}
}
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
}
@@ -1199,10 +1169,7 @@ mod rate_limiter_sweep_tests {
state.close_issue_rate_limiter = RateLimiter::new(10, window);
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
state.peer_write_rate_limiter = RateLimiter::new(10, window);
state.federated_rate_limiter = RateLimiter::new(10, window);
state.ipfs_rate_limiter = RateLimiter::new(10, window);
state.ipfs_work_rate_limiter = RateLimiter::new(10, window);
state.task_read_rate_limiter = RateLimiter::new(10, window);
let limiters = |s: &crate::state::AppState| {
[
@@ -1212,10 +1179,7 @@ mod rate_limiter_sweep_tests {
s.close_issue_rate_limiter.clone(),
s.sync_trigger_rate_limiter.clone(),
s.peer_write_rate_limiter.clone(),
s.federated_rate_limiter.clone(),
s.ipfs_rate_limiter.clone(),
s.ipfs_work_rate_limiter.clone(),
s.task_read_rate_limiter.clone(),
]
};
for l in limiters(&state) {
+1 -3
View File
@@ -314,9 +314,7 @@ pub fn record_sync_processed(status: &str) {
}
/// Record one webhook delivery attempt outcome. `result` ∈
/// {ok, http_error, network_error, skipped, invalid_url}.
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
/// check at delivery time and the POST was never sent.
/// {ok, http_error, network_error, skipped}.
pub fn record_webhook_delivery(result: &str) {
if let Some(c) = WEBHOOK_DELIVERIES.get() {
c.with_label_values(&[result]).inc();
+3 -692
View File
@@ -7,11 +7,11 @@
//! The node's PeerId comes from an Ed25519 keypair loaded from a persistent
//! key file, so the PeerId is stable across restarts.
use std::collections::{hash_map::DefaultHasher, HashMap, HashSet, VecDeque};
use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::{Hash, Hasher};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
@@ -315,17 +315,6 @@ fn is_zero(v: &u32) -> bool {
*v == 0
}
// Identify is an untrusted address source. Keep enough entries for normal
// multi-homed peers while bounding both one peer and the whole routing table.
const IDENTIFY_ADDRESS_LIMIT: usize = 8;
// Bound canonicalization and sorting too, including rejected/duplicate entries.
const IDENTIFY_REPORT_ADDRESS_LIMIT: usize = 64;
const IDENTIFY_NEW_ADDRESS_LIMIT: usize = 8;
const IDENTIFY_GLOBAL_ADDRESS_LIMIT: usize = 1024;
const IDENTIFY_ADDRESS_WINDOW: Duration = Duration::from_secs(60);
const IDENTIFY_ADDRESS_TTL: Duration = Duration::from_secs(30 * 60);
const IDENTIFY_ADDRESS_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
/// A ref-update event published to Gossipsub when a push lands.
///
/// The signing bytes are this struct serialized with `sig` set to None (see
@@ -1642,200 +1631,6 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
kad::RecordKey::new(&format!("/gitlawb/did/{did}").as_bytes())
}
/// Returns true when `address` was explicitly configured for `peer` through
/// AddKnownPeer. Explicit addresses are retained in Kademlia even when an
/// overlapping Identify lease expires or is evicted.
fn is_explicit_address(
explicit_addresses: &HashMap<PeerId, HashSet<Multiaddr>>,
peer: &PeerId,
address: &Multiaddr,
) -> bool {
explicit_addresses
.get(peer)
.is_some_and(|addresses| addresses.contains(address))
}
#[derive(Debug)]
struct IdentifyAddress {
address: Multiaddr,
expires_at: Instant,
}
#[derive(Debug)]
struct IdentifyPeerAddresses {
addresses: VecDeque<IdentifyAddress>,
window_started: Instant,
new_addresses: usize,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct IdentifyAddressChanges {
added: Vec<Multiaddr>,
refreshed: Vec<Multiaddr>,
removed: Vec<(PeerId, Multiaddr)>,
}
#[derive(Debug, Default)]
struct IdentifyAddressBook {
peers: HashMap<PeerId, IdentifyPeerAddresses>,
address_count: usize,
}
impl IdentifyAddressBook {
fn update(
&mut self,
peer_id: PeerId,
now: Instant,
addresses: &[Multiaddr],
) -> IdentifyAddressChanges {
let mut changes = IdentifyAddressChanges::default();
if self.peers.contains_key(&peer_id) {
changes.removed.extend(self.expire_peer(peer_id, now));
}
let reported = addresses
.iter()
.take(IDENTIFY_REPORT_ADDRESS_LIMIT)
.filter_map(|address| address.clone().with_p2p(peer_id).ok())
.collect::<HashSet<_>>();
if reported.is_empty() && !self.peers.contains_key(&peer_id) {
return changes;
}
let state = self
.peers
.entry(peer_id)
.or_insert_with(|| IdentifyPeerAddresses {
addresses: VecDeque::new(),
window_started: now,
new_addresses: 0,
});
if now.saturating_duration_since(state.window_started) >= IDENTIFY_ADDRESS_WINDOW {
state.window_started = now;
state.new_addresses = 0;
}
// Refresh every retained address in the bounded report before admission.
// Refreshed addresses are reported so the Identify handler can re-admit
// them to Kademlia. Kademlia may have dropped a retained address
// after a failed dial or never kept it for a full k-bucket, and a
// TTL refresh alone would never re-add it.
for existing in &mut state.addresses {
if reported.contains(&existing.address) {
existing.expires_at = now + IDENTIFY_ADDRESS_TTL;
changes.refreshed.push(existing.address.clone());
}
}
let mut new_addresses: Vec<Multiaddr> = reported
.iter()
.filter(|address| {
!state
.addresses
.iter()
.any(|existing| &existing.address == *address)
})
.cloned()
.collect();
// HashSet iteration is deliberately unordered. Sort new candidates so
// an oversized Identify report has deterministic admission behavior.
new_addresses.sort();
for address in new_addresses {
if state.new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT {
break;
}
let evicted = if state.addresses.len() < IDENTIFY_ADDRESS_LIMIT {
None
} else {
state
.addresses
.iter()
.position(|existing| !reported.contains(&existing.address))
.and_then(|index| state.addresses.remove(index))
};
if let Some(evicted) = evicted {
self.address_count -= 1;
changes.removed.push((peer_id, evicted.address));
} else if state.addresses.len() >= IDENTIFY_ADDRESS_LIMIT {
// Every retained address was refreshed in this report. Keep
// that stable subset and drop surplus new candidates.
break;
}
if self.address_count >= IDENTIFY_GLOBAL_ADDRESS_LIMIT {
// Reject the admission instead of evicting another peer's
// entry: cross-peer eviction lets a flood of new identities
// push honest peers' addresses out of the book and, when the
// evicted address is their last one, out of the Kademlia
// routing table. Per-peer eviction above still applies to the
// reporting peer's own addresses.
break;
}
state.new_addresses += 1;
state.addresses.push_back(IdentifyAddress {
address: address.clone(),
expires_at: now + IDENTIFY_ADDRESS_TTL,
});
self.address_count += 1;
changes.added.push(address);
}
// Do not keep a row when every candidate was rejected: a zero-address
// row is invisible to address_count and would otherwise linger until
// the expiry tick. This matches expire_peer, which drops empty rows.
if state.addresses.is_empty() {
self.peers.remove(&peer_id);
}
changes
}
fn expire(&mut self, now: Instant) -> Vec<(PeerId, Multiaddr)> {
let mut removed = Vec::new();
let peers = self.peers.keys().copied().collect::<Vec<_>>();
for peer_id in peers {
removed.extend(self.expire_peer(peer_id, now));
}
removed
}
fn expire_peer(&mut self, peer_id: PeerId, now: Instant) -> Vec<(PeerId, Multiaddr)> {
let expired = self
.peers
.get_mut(&peer_id)
.map(|state| Self::expire_state(state, now))
.unwrap_or_default();
self.address_count -= expired.len();
if self
.peers
.get(&peer_id)
.is_some_and(|state| state.addresses.is_empty())
{
self.peers.remove(&peer_id);
}
expired
.into_iter()
.map(|address| (peer_id, address))
.collect()
}
fn expire_state(state: &mut IdentifyPeerAddresses, now: Instant) -> Vec<Multiaddr> {
let mut expired = Vec::new();
let mut retained = VecDeque::with_capacity(state.addresses.len());
while let Some(address) = state.addresses.pop_front() {
if address.expires_at <= now {
expired.push(address.address);
} else {
retained.push_back(address);
}
}
state.addresses = retained;
expired
}
}
/// Combined libp2p behaviour.
#[derive(NetworkBehaviour)]
#[behaviour(prelude = "libp2p_swarm::derive_prelude")]
@@ -4165,14 +3960,6 @@ pub async fn start(
// Track in-flight GetRecord queries → reply channels
let mut pending_get_did: HashMap<kad::QueryId, oneshot::Sender<Option<DidRecord>>> =
HashMap::new();
// Keep Identify-derived entries across disconnects so a bootstrap address
// learned through Identify remains usable for redial. The TTL cleanup below
// removes entries that are no longer refreshed without touching explicit
// AddKnownPeer addresses.
let mut identify_addresses = IdentifyAddressBook::default();
let mut explicit_addresses: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
let mut identify_cleanup = tokio::time::interval(IDENTIFY_ADDRESS_CLEANUP_INTERVAL);
identify_cleanup.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
// Start the event loop as a background task
tokio::spawn(async move {
@@ -4192,13 +3979,6 @@ pub async fn start(
_ = ingest_sweep.tick() => {
run_ingest_sweep(&ingest_limiters, &replay_guard, ingest_now()).await;
}
_ = identify_cleanup.tick() => {
for (peer_id, address) in identify_addresses.expire(Instant::now()) {
if !is_explicit_address(&explicit_addresses, &peer_id, &address) {
swarm.behaviour_mut().kademlia.remove_address(&peer_id, &address);
}
}
}
// Graceful shutdown: exit the swarm loop when the
// process-wide signal flips. This drops the Swarm
// which closes all libp2p connections cleanly.
@@ -4333,21 +4113,7 @@ pub async fn start(
identify::Event::Received { peer_id, info, .. }
)) => {
debug!(peer = %peer_id, "identify received");
let changes = identify_addresses.update(
peer_id,
Instant::now(),
&info.listen_addrs,
);
for (removed_peer, addr) in changes.removed {
if !is_explicit_address(&explicit_addresses, &removed_peer, &addr)
{
swarm
.behaviour_mut()
.kademlia
.remove_address(&removed_peer, &addr);
}
}
for addr in changes.refreshed.into_iter().chain(changes.added) {
for addr in info.listen_addrs {
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
}
}
@@ -4384,14 +4150,6 @@ pub async fn start(
}
}
P2pCommand::AddKnownPeer { peer_id, addr } => {
let Ok(addr) = addr.clone().with_p2p(peer_id) else {
warn!(%peer_id, %addr, "dropping known-peer address with a foreign peer suffix");
continue;
};
explicit_addresses
.entry(peer_id)
.or_default()
.insert(addr.clone());
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
}
P2pCommand::Dial(addr) => {
@@ -4443,453 +4201,6 @@ pub async fn start(
mod tests {
use super::*;
fn identify_address(peer_id: PeerId, octet: u8, port: u16) -> Multiaddr {
format!("/ip4/192.0.2.{octet}/udp/{port}/quic-v1")
.parse::<Multiaddr>()
.unwrap()
.with_p2p(peer_id)
.unwrap()
}
fn identify_address_without_peer(octet: u8, port: u16) -> Multiaddr {
format!("/ip4/192.0.2.{octet}/udp/{port}/quic-v1")
.parse::<Multiaddr>()
.unwrap()
}
#[test]
fn identify_empty_reports_do_not_retain_peer_entries() {
let now = Instant::now();
let mut book = IdentifyAddressBook::default();
let foreign = identify_address(PeerId::random(), 1, 10_000);
for _ in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT + 1 {
let peer = PeerId::random();
assert_eq!(
book.update(peer, now, &[]),
IdentifyAddressChanges::default()
);
assert_eq!(
book.update(peer, now, std::slice::from_ref(&foreign)),
IdentifyAddressChanges::default()
);
}
assert!(book.peers.is_empty());
assert_eq!(book.address_count, 0);
}
#[test]
fn identify_fully_rejected_peer_leaves_no_row_at_full_budget() {
let now = Instant::now();
let mut book = IdentifyAddressBook::default();
// Fill the global budget with one-address peers.
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT {
let peer = PeerId::random();
let address = identify_address(peer, 1, 10_000 + index as u16);
book.update(peer, now, std::slice::from_ref(&address));
}
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
// A fresh peer reporting at saturation has every candidate rejected
// by the global-budget check. The row created by or_insert_with must
// be dropped: a zero-address row is invisible to address_count and
// would otherwise linger until the expiry tick.
let fresh = PeerId::random();
let fresh_address = identify_address(fresh, 2, 30_000);
let changes = book.update(fresh, now, std::slice::from_ref(&fresh_address));
assert!(changes.added.is_empty());
assert!(!book.peers.contains_key(&fresh));
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
}
#[test]
fn identify_update_reports_expired_addresses_as_removed() {
let peer = PeerId::random();
let now = Instant::now();
let address = identify_address(peer, 1, 10_000);
let mut book = IdentifyAddressBook::default();
book.update(peer, now, std::slice::from_ref(&address));
// An update past the TTL expires the stale address through
// expire_peer and reports it in changes.removed so the Identify
// handler can drop it from Kademlia.
let changes = book.update(
peer,
now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1),
std::slice::from_ref(&address),
);
assert_eq!(changes.removed, vec![(peer, address.clone())]);
assert_eq!(changes.added, vec![address]);
}
#[test]
fn identify_expire_drops_rows_emptied_by_expiry() {
let peer = PeerId::random();
let now = Instant::now();
let address = identify_address(peer, 1, 10_000);
let mut book = IdentifyAddressBook::default();
book.update(peer, now, std::slice::from_ref(&address));
assert!(book.peers.contains_key(&peer));
// Once every address in the row has expired, expire() must drop the
// row itself, matching the empty-row drop in update().
let removed = book.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1));
assert_eq!(removed, vec![(peer, address)]);
assert!(!book.peers.contains_key(&peer));
assert_eq!(book.address_count, 0);
}
#[test]
fn identify_report_processing_stops_at_the_input_limit() {
let peer = PeerId::random();
let now = Instant::now();
let foreign = identify_address(PeerId::random(), 1, 10_000);
let accepted = identify_address(peer, 2, 10_001);
let ignored = identify_address(peer, 3, 10_002);
let mut report = vec![foreign; IDENTIFY_REPORT_ADDRESS_LIMIT - 1];
report.push(accepted.clone());
report.push(ignored);
let mut book = IdentifyAddressBook::default();
let changes = book.update(peer, now, &report);
assert_eq!(changes.added, vec![accepted]);
assert!(changes.removed.is_empty());
assert_eq!(book.address_count, 1);
}
#[test]
fn identify_addresses_grow_without_eviction_below_the_peer_cap() {
let peer = PeerId::random();
let now = Instant::now();
let first = identify_address(peer, 1, 10_000);
let second = identify_address(peer, 2, 10_001);
let mut book = IdentifyAddressBook::default();
book.update(peer, now, std::slice::from_ref(&first));
let changes = book.update(
peer,
now + IDENTIFY_ADDRESS_WINDOW,
std::slice::from_ref(&second),
);
assert_eq!(changes.added, vec![second]);
assert!(changes.removed.is_empty());
assert_eq!(book.peers[&peer].addresses.len(), 2);
assert_eq!(book.address_count, 2);
}
#[test]
fn identify_global_budget_rejects_new_admissions() {
let now = Instant::now();
let mut book = IdentifyAddressBook::default();
let peers = (0..IDENTIFY_GLOBAL_ADDRESS_LIMIT)
.map(|index| {
let peer = PeerId::random();
let address = identify_address(peer, 1, 10_000 + index as u16);
book.update(peer, now, std::slice::from_ref(&address));
(peer, address)
})
.collect::<Vec<_>>();
let (peer, refreshed) = &peers[0];
let new_address = identify_address(*peer, 2, 30_000);
let changes = book.update(
*peer,
now + IDENTIFY_ADDRESS_WINDOW,
&[new_address.clone(), refreshed.clone()],
);
// The budget is full: the new address is rejected rather than
// evicting another peer's entry, and the refreshed address keeps its
// slot and is reported for re-admission to Kademlia.
assert!(changes.added.is_empty());
assert!(changes.removed.is_empty());
assert_eq!(changes.refreshed, vec![(*refreshed).clone()]);
assert_eq!(book.peers.len(), IDENTIFY_GLOBAL_ADDRESS_LIMIT);
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
assert_eq!(
book.peers[peer]
.addresses
.iter()
.map(|a| &a.address)
.collect::<Vec<_>>(),
vec![refreshed]
);
}
#[test]
fn identify_per_peer_eviction_applies_at_full_global_budget() {
let now = Instant::now();
let mut book = IdentifyAddressBook::default();
// One filler peer goes in before the victim so the evicted address
// is not also the globally-oldest insertion. If saturation evicted
// the globally-oldest entry instead of the victim's own oldest,
// the sentinel's address would be the one removed.
let sentinel = PeerId::random();
let sentinel_address = identify_address(sentinel, 200, 19_999);
book.update(sentinel, now, std::slice::from_ref(&sentinel_address));
// The victim peer holds a full per-peer row while the rest of the
// global budget is filled by one-address peers.
let victim = PeerId::random();
let victim_addresses: Vec<Multiaddr> = (0..IDENTIFY_ADDRESS_LIMIT)
.map(|index| identify_address(victim, index as u8 + 1, 10_000 + index as u16))
.collect();
book.update(victim, now, &victim_addresses);
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT - IDENTIFY_ADDRESS_LIMIT - 1 {
let peer = PeerId::random();
let address = identify_address(peer, 200, 20_000 + index as u16);
book.update(peer, now, &[address]);
}
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
// The victim reports a ninth address after the rate window rolls over.
// Per-peer eviction must still apply at global saturation: the oldest
// non-reported address is evicted to make room, and the global count
// is unchanged. This ordering only holds because eviction runs before
// the global-budget check; hoisting that check above the eviction
// block makes this test fail.
let ninth = identify_address(victim, 100, 30_000);
let changes = book.update(
victim,
now + IDENTIFY_ADDRESS_WINDOW,
std::slice::from_ref(&ninth),
);
assert_eq!(changes.added, vec![ninth]);
assert_eq!(changes.removed, vec![(victim, victim_addresses[0].clone())]);
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
assert_eq!(book.peers[&victim].addresses.len(), IDENTIFY_ADDRESS_LIMIT);
// The sentinel was inserted first; per-peer eviction must leave it
// alone. A global-oldest eviction policy would have removed it.
assert_eq!(
book.peers[&sentinel]
.addresses
.iter()
.map(|a| &a.address)
.collect::<Vec<_>>(),
vec![&sentinel_address]
);
}
#[test]
fn explicit_address_helper_detects_retained_addresses() {
let peer = PeerId::random();
let other = PeerId::random();
let address = identify_address(peer, 1, 10_000);
let mut explicit: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
// Unknown peer: nothing is explicit.
assert!(!is_explicit_address(&explicit, &peer, &address));
explicit.entry(peer).or_default().insert(address.clone());
assert!(is_explicit_address(&explicit, &peer, &address));
// The same address under a different peer is not retained.
assert!(!is_explicit_address(&explicit, &other, &address));
// A different address for the same peer is not retained.
assert!(!is_explicit_address(
&explicit,
&peer,
&identify_address(peer, 2, 10_001)
));
}
#[test]
fn identify_addresses_are_capped_with_fifo_eviction() {
let peer_id = PeerId::random();
let now = Instant::now();
let initial = (0..IDENTIFY_ADDRESS_LIMIT)
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
.collect::<Vec<_>>();
let replacement = identify_address(peer_id, 200, 20_000);
let mut book = IdentifyAddressBook::default();
let first = book.update(peer_id, now, &initial);
assert_eq!(first.added, initial);
assert!(first.removed.is_empty());
let second = book.update(
peer_id,
now + IDENTIFY_ADDRESS_WINDOW,
std::slice::from_ref(&replacement),
);
assert_eq!(second.added, vec![replacement]);
assert_eq!(second.removed, vec![(peer_id, initial[0].clone())]);
}
#[test]
fn identify_oversized_reports_keep_the_same_refreshed_subset() {
let peer_id = PeerId::random();
let now = Instant::now();
let mut report = (0..IDENTIFY_ADDRESS_LIMIT + 2)
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
.collect::<Vec<_>>();
let mut book = IdentifyAddressBook::default();
let first = book.update(peer_id, now, &report);
assert_eq!(first.added.len(), IDENTIFY_ADDRESS_LIMIT);
assert_eq!(first.removed.len(), 0);
let retained = first.added.clone();
report.reverse();
let second = book.update(peer_id, now + IDENTIFY_ADDRESS_WINDOW, &report);
assert!(second.added.is_empty());
assert!(second.removed.is_empty());
let current = book
.peers
.get(&peer_id)
.unwrap()
.addresses
.iter()
.map(|entry| entry.address.clone())
.collect::<Vec<_>>();
assert_eq!(current, retained);
}
#[test]
fn identify_addresses_rate_limit_new_entries_across_updates() {
let peer_id = PeerId::random();
let now = Instant::now();
let initial = (0..IDENTIFY_NEW_ADDRESS_LIMIT)
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
.collect::<Vec<_>>();
let replacement = identify_address(peer_id, 200, 20_000);
let mut book = IdentifyAddressBook::default();
assert_eq!(book.update(peer_id, now, &initial).added, initial);
assert!(book
.update(peer_id, now, std::slice::from_ref(&replacement))
.added
.is_empty());
assert_eq!(
book.update(
peer_id,
now + IDENTIFY_ADDRESS_WINDOW,
std::slice::from_ref(&replacement),
)
.added,
vec![replacement]
);
}
#[test]
fn identify_addresses_have_a_global_budget_across_peers() {
let now = Instant::now();
let mut book = IdentifyAddressBook::default();
let mut first = None;
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT {
let peer_id = PeerId::random();
let address = identify_address(peer_id, (index % 254) as u8 + 1, 10_000 + index as u16);
if first.is_none() {
first = Some((peer_id, address.clone()));
}
assert!(book.update(peer_id, now, &[address]).removed.is_empty());
}
let (peer_id, first_address) = first.unwrap();
let address = identify_address(peer_id, 250, 30_000);
let changes = book.update(
peer_id,
now + IDENTIFY_ADDRESS_WINDOW,
std::slice::from_ref(&address),
);
// The global budget is full, so the admission is rejected and the
// peer's existing entry is left alone.
assert!(changes.added.is_empty());
assert!(changes.removed.is_empty());
assert_eq!(
book.peers[&peer_id]
.addresses
.iter()
.map(|a| &a.address)
.collect::<Vec<_>>(),
vec![&first_address]
);
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
}
#[test]
fn identify_addresses_refresh_and_expire() {
let peer_id = PeerId::random();
let now = Instant::now();
let address = identify_address(peer_id, 1, 10_000);
let mut book = IdentifyAddressBook::default();
assert_eq!(
book.update(peer_id, now, std::slice::from_ref(&address))
.added,
vec![address.clone()]
);
let refreshed = now + IDENTIFY_ADDRESS_TTL - Duration::from_secs(1);
let refresh = book.update(peer_id, refreshed, std::slice::from_ref(&address));
assert!(refresh.added.is_empty());
assert!(refresh.removed.is_empty());
// Refreshed addresses are reported so the Identify handler re-adds
// them to Kademlia even though they were not newly admitted.
assert_eq!(refresh.refreshed, vec![address.clone()]);
assert!(book
.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1))
.is_empty());
assert_eq!(
book.expire(refreshed + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
vec![(peer_id, address)]
);
}
#[test]
fn identify_refresh_reports_only_addresses_in_the_report() {
let peer_id = PeerId::random();
let now = Instant::now();
let reported = identify_address(peer_id, 1, 10_000);
let unreported = identify_address(peer_id, 2, 20_000);
let mut book = IdentifyAddressBook::default();
// Admit both addresses up front so the peer holds two.
assert_eq!(
book.update(peer_id, now, &[reported.clone(), unreported.clone()])
.added
.len(),
2
);
// A later report carrying only one of the two addresses must refresh
// just that one: pins the negative direction of the refresh
// predicate, so an address the peer stopped advertising keeps its
// original TTL instead of being refreshed back onto the Kademlia
// re-admit path on every report.
let report_at = now + IDENTIFY_ADDRESS_TTL - Duration::from_secs(1);
let changes = book.update(peer_id, report_at, std::slice::from_ref(&reported));
assert!(changes.added.is_empty());
assert!(changes.removed.is_empty());
assert_eq!(changes.refreshed, vec![reported.clone()]);
// The unreported address still expires at its original TTL, while
// the refreshed address survives until its refreshed TTL.
assert_eq!(
book.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
vec![(peer_id, unreported)]
);
assert!(book.expire(report_at + Duration::from_secs(1)).is_empty());
assert_eq!(
book.expire(report_at + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
vec![(peer_id, reported)]
);
}
#[test]
fn identify_addresses_reject_foreign_peer_suffixes() {
let peer_id = PeerId::random();
let other_peer_id = PeerId::random();
let address = identify_address(other_peer_id, 1, 10_000);
let mut book = IdentifyAddressBook::default();
assert!(book
.update(peer_id, Instant::now(), &[address])
.added
.is_empty());
}
#[test]
fn identify_addresses_canonicalize_the_peer_suffix_once() {
let peer_id = PeerId::random();
let raw = identify_address_without_peer(1, 10_000);
let canonical = identify_address(peer_id, 1, 10_000);
let mut book = IdentifyAddressBook::default();
assert_eq!(
book.update(peer_id, Instant::now(), &[raw]).added,
vec![canonical]
);
}
#[test]
fn dir_mode_replace_authority_is_the_write_bits() {
assert!(!dir_mode_allows_untrusted_replace(0o755));
+2 -56
View File
@@ -296,7 +296,7 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
RATE_LIMIT_MESSAGE,
"rate limit exceeded — try again later",
)
.into_response();
}
@@ -401,11 +401,6 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
}
}
/// The message every per-IP brake answers with, shared so the GraphQL surface
/// (which cannot return a 429 status inside a 200 GraphQL envelope) says the
/// same thing as the REST routes.
pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later";
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
/// middleware now serves the push path AND the peer-sync routes, so the message
/// stays generic (the offending path is recorded in the warn log below).
@@ -413,60 +408,11 @@ pub fn too_many_requests() -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
RATE_LIMIT_MESSAGE,
"rate limit exceeded — try again later",
)
.into_response()
}
/// The per-IP task-read brake carried as GraphQL request data: the limiter plus
/// the client key the transport already resolved.
///
/// `/graphql` is a single POST endpoint, so this brake cannot sit in middleware
/// the way `task_read_routes` mounts `rate_limit_by_ip`: that would charge every
/// unrelated query and every mutation against the task-read bucket. The two task
/// resolvers debit it instead, which also prices an aliased query honestly — ten
/// aliased `tasks` fields run the visibility gate ten times and pay ten slots,
/// where one request-scoped debit would pay one (#327 review).
/// Default cap on the number of task read fields (across all aliases) permitted
/// in a single GraphQL request before rejecting further field executions (#327 review).
pub const MAX_GRAPHQL_TASK_READS_PER_REQUEST: usize = 5;
#[derive(Clone)]
pub struct TaskReadBrake {
pub limiter: RateLimiter,
/// `None` when no key could be resolved at all (no trusted header and no
/// `ConnectInfo`, e.g. a synthetic test request). Never braked, matching
/// [`rate_limit_by_ip`].
pub key: Option<String>,
pub request_count: Arc<std::sync::atomic::AtomicUsize>,
}
impl TaskReadBrake {
/// Debit one slot for one task-read field. `true` = proceed.
pub async fn check(&self, field: &str) -> bool {
let Some(key) = self.key.as_deref() else {
return true;
};
let count = self
.request_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count >= MAX_GRAPHQL_TASK_READS_PER_REQUEST {
tracing::warn!(
key = %key,
field = %field,
count = count + 1,
"per-request GraphQL task field limit exceeded"
);
return false;
}
if self.limiter.check(key).await {
return true;
}
tracing::warn!(key = %key, field = %field, "per-IP rate limit exceeded");
false
}
}
/// Throttle the git push path by resolved client IP. The socket peer address is
/// read from `ConnectInfo` (see `into_make_service_with_connect_info` in
/// `main`). Only skips the limiter when no key can be resolved at all.
File diff suppressed because it is too large. Load diff
-19
View File
@@ -59,10 +59,6 @@ pub struct AppState {
pub task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
/// GraphQL schema (queries + mutations + subscriptions)
pub graphql_schema: Arc<crate::graphql::GitlawbSchema>,
/// MAC key for task-list continuation tokens, derived from the node
/// keypair seed. Held here (and handed to the GraphQL schema) so REST and
/// GraphQL mint and accept the same tokens.
pub task_cursor_key: crate::api::task_cursor::TaskCursorKey,
/// Fly.io machine ID — used for fly-replay routing in multi-machine deployments
pub machine_id: Option<String>,
/// Centralized repo storage: local disk cache + optional Tigris backend
@@ -194,9 +190,6 @@ pub struct AppState {
/// sink as trigger and accepts unsigned requests from known peers, so it is
/// braked too; each peer's distinct IP gets its own bucket.
pub peer_write_rate_limiter: RateLimiter,
/// Per-client-IP federation budget: 12 requests per minute, with at most
/// 10,000 tracked source keys. Shared by router instances and swept below.
pub federated_rate_limiter: RateLimiter,
/// Process-wide graceful-shutdown signal. Sending `true` causes every
/// task that holds a `watch::Receiver` to exit at its next await point.
/// Used by:
@@ -330,16 +323,6 @@ pub struct AppState {
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
/// it (INV-15).
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
/// Per-client-IP rate limiter for the task read routes. `GET /api/v1/tasks`
/// and `GET /api/v1/tasks/{id}` are anonymously reachable and run the #268
/// visibility gate (a task lookup plus deduped-repo and visibility-rule
/// queries) before returning the opaque 404, so an unauthenticated prober
/// costs the node real work per request whether or not anything is visible.
/// Keyed on the resolved client IP via `push_limiter_trust`. Layered on
/// `task_read_routes` via `rate_limit_by_ip`.
/// The same per-IP hourly budget applies to GraphQL `tasks` / `task`
/// queries and WebSocket task queries via `TaskReadBrake`.
pub task_read_rate_limiter: RateLimiter,
/// The `git` executable the served-git withheld-blob walk spawns. Production is
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
/// process-group teardown in handler tests without mutating the process-global
@@ -368,8 +351,6 @@ impl AppState {
self.ipfs_work_rate_limiter.cleanup().await;
self.sync_trigger_rate_limiter.cleanup().await;
self.peer_write_rate_limiter.cleanup().await;
self.task_read_rate_limiter.cleanup().await;
self.federated_rate_limiter.cleanup().await;
}
/// Trigger graceful shutdown. Idempotent — calling more than once
+1 -81
View File
@@ -701,7 +701,6 @@ 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);
@@ -730,10 +729,7 @@ 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 => {
@@ -1010,82 +1006,6 @@ 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
File diff suppressed because it is too large. Load diff
-13
View File
@@ -79,19 +79,6 @@ async fn fire_event_async(
let delivery_id = uuid::Uuid::new_v4().to_string();
tokio::spawn(async move {
// Policy skip, not a delivery failure: the stored URL predates the
// create-time gate. The "invalid_url" label is documented on
// record_webhook_delivery in metrics.rs.
if !crate::api::peers::is_public_http_url(&hook.url) {
crate::metrics::record_webhook_delivery("invalid_url");
tracing::warn!(
url = %hook.url,
event = %event_name,
"webhook delivery skipped: stored URL is not a public http(s) target"
);
return;
}
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
let mut req = client
@@ -1,177 +0,0 @@
//! #26 Split PR 4 — `GITLAWB_ENFORCE_OWNER_PUSH` default is consistent across surfaces.
//!
//! Reviewer 2 (2026-08-28) closed PR #224 with a P2 finding: SECURITY.md and
//! README.md both stated the default was `false`, while `Config::enforce_owner_push`
//! declared `default_value_t = true` and the runtime, .env.example, and the env
//! var table on the README all agreed with the code. Two operator-facing
//! surfaces lied about the authorization policy. PR #330 flipped the code
//! default and updated .env.example / README env table / docs/RUN-A-NODE.md,
//! but missed these two prose mentions.
//!
//! This guard pins the documentation and example surfaces together. Each one
//! is read off the working tree at test time, not a copy in the binary, so a
//! revert of the doc edit turns it red:
//!
//! 1. `SECURITY.md` says the default is `true`.
//! 2. `README.md` says the default is `true` in the known-limitations prose.
//! 3. `README.md` says the default is `true` in the env-var table row.
//! 4. `.env.example` sets `GITLAWB_ENFORCE_OWNER_PUSH=true`.
//!
//! The runtime declared default (`Config::enforce_owner_push`'s
//! `default_value_t = true`) is protected by the
//! `enforce_owner_push_is_declared_true_independent_of_the_environment`
//! unit test in `config.rs`, which reads the parser directly and is
//! env-independent. The two checks together cover the surfaces that
//! produced the original doc-vs-code drift; this guard cannot reach into
//! the binary crate's `Config` type (it is private to the bin target,
//! which has no `lib.rs`) without a larger refactor, so the contract is
//! stated as the four documentation/example surfaces above.
//!
//! The four are checked independently. A check that just verified "at least
//! one surface says true" would pass while a stale SECURITY.md lied; a check
//! that only verified the binary would not catch the doc drift that
//! produced this finding in the first place.
use std::path::Path;
fn read(rel: &str) -> String {
let p = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join(rel);
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
}
/// Pin the prose mentions of `GITLAWB_ENFORCE_OWNER_PUSH` so a future doc
/// edit cannot reintroduce the "defaults to false" wording the reviewer
/// flagged. The wording in the test is what the docs MUST say about the
/// default; absence of any prose mention is also a fail because operators
/// rely on the docs to learn the default.
#[test]
fn security_md_states_owner_push_default_is_true() {
let security = read("SECURITY.md");
let section = section_after(&security, "### Repository write authorization defaults")
.unwrap_or_else(|| panic!("SECURITY.md is missing the owner-push section"));
assert!(
section.contains("defaults to `true`"),
"SECURITY.md owner-push section must state the default is true. \
Found:\n{section}"
);
assert!(
!section_claims_default_is_false(&section),
"SECURITY.md owner-push section must not claim the default is false. \
Found:\n{section}"
);
}
#[test]
fn readme_known_limitations_states_owner_push_default_is_true() {
let readme = read("README.md");
let limitations = section_after(&readme, "Known limitations:")
.unwrap_or_else(|| panic!("README.md is missing the Known limitations: section"));
// The owner-push line is one bullet in the limitations list. Pull the
// single line that mentions the env var so the assertion is local to
// the claim, not a substring of unrelated text.
let line = limitations
.lines()
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH"))
.unwrap_or_else(|| {
panic!(
"README.md Known limitations must mention GITLAWB_ENFORCE_OWNER_PUSH.\n\
Got:\n{limitations}"
)
});
assert!(
line.contains("defaults to `true`") || line.contains("defaults to true"),
"README Known-limitations owner-push bullet must state the default is true. \
Found:\n{line}"
);
assert!(
!section_claims_default_is_false(line),
"README Known-limitations owner-push bullet must not claim the default is false. \
Found:\n{line}"
);
}
#[test]
fn readme_env_var_table_states_owner_push_default_is_true() {
let readme = read("README.md");
let line = readme
.lines()
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH") && l.contains("|"))
.unwrap_or_else(|| {
panic!("README.md is missing the env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH")
});
assert!(
line.contains("Defaults to `true`") || line.contains("defaults to `true`"),
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must state the default is true. \
Found:\n{line}"
);
assert!(
!section_claims_default_is_false(line),
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must not claim the default is false. \
Found:\n{line}"
);
}
#[test]
fn env_example_sets_owner_push_true() {
let env = read(".env.example");
let line = env
.lines()
.find(|l| l.trim_start().starts_with("GITLAWB_ENFORCE_OWNER_PUSH"))
.unwrap_or_else(|| panic!(".env.example is missing GITLAWB_ENFORCE_OWNER_PUSH"));
assert!(
line.contains("=true"),
".env.example GITLAWB_ENFORCE_OWNER_PUSH must be set to true. Found:\n{line}"
);
}
/// A claim that the default is false can hide in many phrasings. Match the
/// forms that have appeared in the wild on this branch: "defaults to `false`",
/// "defaults to false", "default is false", "is `false` for compatibility",
/// "is false for compatibility". The match is intentionally narrow — false
/// positives would suppress a real doc edit.
fn section_claims_default_is_false(section: &str) -> bool {
let lower = section.to_ascii_lowercase();
lower.contains("defaults to `false`")
|| lower.contains("defaults to false")
|| lower.contains("default is `false`")
|| lower.contains("default is false")
|| (lower.contains("`false`") && lower.contains("for compatibility"))
}
/// Return the prose after a marker up to the next heading boundary. The
/// marker is normally a markdown heading (e.g. `### Section`); if it has
/// no `#` prefix it is treated as a plain-text marker and the slice is
/// bounded by the next heading of any level. This matters because a
/// plain-text marker would otherwise produce `header_level = 0`, making
/// the same-or-higher-level stop condition unsatisfiable and letting the
/// slice run to EOF.
fn section_after(doc: &str, heading: &str) -> Option<String> {
let start = doc.find(heading)?;
let after = &doc[start..];
let header_level = heading.chars().take_while(|c| *c == '#').count();
// Skip past the heading line itself.
let body_start = after.find('\n')? + 1;
let body = &after[body_start..];
// Stop at the next heading of the same or higher level, or at the next
// heading of any level when the marker had no `#` prefix.
let mut end = body.len();
for line in body.lines() {
let level = line.chars().take_while(|c| *c == '#').count();
if level > 0 && (header_level == 0 || level <= header_level) {
end = line.as_ptr() as usize - body.as_ptr() as usize;
break;
}
}
Some(body[..end].to_string())
}
-2
View File
@@ -26,8 +26,6 @@ clap = { version = "4", features = ["derive", "env"] }
dirs = "5"
reqwest = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
percent-encoding = { workspace = true }
urlencoding = "2"
alloy = { version = "1", default-features = false, features = [
"contract",
+26 -17
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -64,16 +65,24 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
.get(&path)
.await
.context("failed to connect to node")?;
// Keep the bespoke 404 hint (older node without the agents API) ahead of
// read_json; every other status routes through it (capped + sanitized error).
if resp.status() == reqwest::StatusCode::NOT_FOUND {
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"this node does not yet support the agents API (v0.3+)\n\
upgrade the node or check GITLAWB_NODE is pointing to the right server"
);
}
let body = crate::http::read_json(resp, "list agents").await?;
let body: Value = resp
.json()
.await
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("list agents failed ({status}): {msg}");
}
let agents = body["agents"].as_array().cloned().unwrap_or_default();
@@ -88,7 +97,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
let short = did
.split(':')
.next_back()
.map(|s| crate::text::truncate(s, 16))
.map(|s| &s[..s.len().min(16)])
.unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
let caps = agent["capabilities"]
@@ -116,15 +125,24 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
.get(&format!("/api/v1/agents/{did}"))
.await
.context("failed to connect to node")?;
// Keep the bespoke 404 hint ahead of read_json; other statuses route through it.
if resp.status() == reqwest::StatusCode::NOT_FOUND {
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"agent not found, or this node does not yet support the agents API (v0.3+)\n\
upgrade the node or check GITLAWB_NODE is pointing to the right server"
);
}
let agent = crate::http::read_json(resp, "agent").await?;
let agent: Value = resp
.json()
.await
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
if !status.is_success() {
let msg = agent["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("agent not found ({status}): {msg}");
}
let full_did = agent["did"].as_str().unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
@@ -205,14 +223,11 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_list(server.url(), None).await.unwrap_err();
assert!(err.to_string().contains("agents API"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -223,14 +238,11 @@ mod tests {
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"internal error"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_list(server.url(), None).await.unwrap_err();
assert!(err.to_string().contains("list agents failed"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -257,7 +269,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"agent not found"}"#)
.expect(1)
.create_async()
.await;
@@ -265,8 +276,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("agents API") || err.to_string().contains("not found"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
+52 -106
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -220,7 +221,13 @@ async fn cmd_create(
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "create bounty").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("create bounty failed ({status}): {msg}");
}
let id = body["id"].as_str().unwrap_or("?");
println!("Bounty created: {id}");
@@ -256,14 +263,11 @@ async fn cmd_list(
u
};
let body = crate::http::read_json(
client
.get_authed(&url)
.await
.context("failed to connect to node")?,
"bounties",
)
.await?;
let resp = client
.get_authed(&url)
.await
.context("failed to connect to node")?;
let body: Value = resp.json().await.unwrap_or_default();
let bounties = body["bounties"].as_array();
if let Some(arr) = bounties {
@@ -275,7 +279,7 @@ async fn cmd_list(
let title = b["title"].as_str().unwrap_or("?");
let amount = b["amount"].as_i64().unwrap_or(0);
let st = b["status"].as_str().unwrap_or("?");
let short_id = crate::text::truncate(id, 8);
let short_id = &id[..8.min(id.len())];
println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}");
}
}
@@ -287,7 +291,13 @@ async fn cmd_show(id: String, node: String, dir: Option<PathBuf>) -> Result<()>
let client = signed_client(&node, dir.as_deref());
let resp = client.get_authed(&format!("/api/v1/bounties/{id}")).await?;
let body = crate::http::read_json(resp, "show bounty").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("not found");
anyhow::bail!("bounty not found: {msg}");
}
println!("{}", serde_json::to_string_pretty(&body)?);
Ok(())
@@ -311,7 +321,13 @@ async fn cmd_claim(
)
.await?;
let _ = crate::http::read_json(resp, "claim").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("claim failed ({status}): {msg}");
}
println!("Bounty {id} claimed. Deadline starts now.");
Ok(())
@@ -330,7 +346,13 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
)
.await?;
let _ = crate::http::read_json(resp, "submit").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("submit failed ({status}): {msg}");
}
println!("Bounty {id} submitted with PR {pr}. Awaiting creator approval.");
Ok(())
@@ -354,7 +376,13 @@ async fn cmd_approve(
)
.await?;
let _ = crate::http::read_json(resp, "approve").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("approve failed ({status}): {msg}");
}
println!("Bounty {id} approved! Payout released to agent.");
Ok(())
@@ -373,7 +401,13 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
)
.await?;
let _ = crate::http::read_json(resp, "cancel").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("cancel failed ({status}): {msg}");
}
println!("Bounty {id} cancelled. Tokens refunded.");
Ok(())
@@ -381,11 +415,8 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
async fn cmd_stats(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
// Route through read_json so a node denial (403/404/5xx, incl. a non-JSON
// body) surfaces as an error instead of a fake `Bounty Stats` with every
// counter zeroed (#186, the denial-as-success class #123 fixes elsewhere).
let body =
crate::http::read_json(client.get("/api/v1/bounties/stats").await?, "bounty stats").await?;
let resp = client.get("/api/v1/bounties/stats").await?;
let body: Value = resp.json().await.unwrap_or_default();
let open = body["open"].as_i64().unwrap_or(0);
let claimed = body["claimed"].as_i64().unwrap_or(0);
@@ -403,7 +434,7 @@ async fn cmd_stats(node: String) -> Result<()> {
let did = entry["did"].as_str().unwrap_or("?");
let count = entry["completed"].as_i64().unwrap_or(0);
let earned = entry["total_earned"].as_i64().unwrap_or(0);
let short = crate::text::truncate_suffix(did, 8);
let short = &did[did.len().saturating_sub(8)..];
println!(
" {}. ...{short} {count} bounties {earned} $GITLAWB",
i + 1
@@ -522,7 +553,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
.expect(1)
.create_async()
.await;
@@ -530,8 +560,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -548,86 +576,4 @@ mod tests {
cmd_stats(server.url()).await.unwrap();
}
/// #186 (F1): a node denial on the stats endpoint must surface as an error,
/// not print a fake `Bounty Stats` with every counter zeroed. RED before the
/// fix (unwrap_or_default + no status check -> Ok with zeros), GREEN after
/// (read_json bails on the non-2xx).
#[tokio::test]
async fn cmd_stats_surfaces_denial_not_fake_result() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("GET", mockito::Matcher::Regex(r"/stats$".to_string()))
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"forbidden"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_stats(server.url())
.await
.expect_err("a 403 must be an error, not a zeroed fake result");
assert!(err.to_string().contains("403"), "err={err}");
// Pin the mock hit explicitly. The status assertion above already fails
// when the route does not match (mockito answers an unmatched route with
// 501, so "403" is absent), but that proof is incidental to mockito's
// fallback status; assert the hit directly so it survives a reworded
// assertion.
m.assert_async().await;
}
/// #186 (F1): the same denial-as-error behavior across the non-2xx response
/// classes, not just 403 — a 404 and a 500 (incl. a non-JSON body) must each
/// surface as an error with the status, never a zeroed fake result.
#[tokio::test]
async fn cmd_stats_surfaces_denial_across_status_classes() {
for (status, body, json) in [
(404, r#"{"message":"not found"}"#, true),
(500, "internal boom", false),
] {
let mut server = mockito::Server::new_async().await;
let mut m = server
.mock("GET", mockito::Matcher::Regex(r"/stats$".to_string()))
.with_status(status)
.with_body(body);
if json {
m = m.with_header("content-type", "application/json");
}
let m = m.expect(1).create_async().await;
let err = cmd_stats(server.url()).await.unwrap_err().to_string();
assert!(
err.contains(&status.to_string()),
"status {status} must surface as an error, got: {err}"
);
m.assert_async().await;
}
}
#[tokio::test]
async fn cmd_list_repo_scoped_surfaces_denial_not_empty() {
// `gl bounty list --repo owner/name` hits the gated /repos/{o}/{n}/bounties;
// a 404 must Err, not silently print nothing.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/repos/alice/secret/bounties".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_list(Some("alice/secret".to_string()), None, server.url(), None).await;
assert!(
result.is_err(),
"bounty list --repo must Err on a gated 404"
);
// Prove the gated repo-scoped path was actually requested: without this,
// an unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
_m.assert_async().await;
}
}
+61 -1149
View File
File diff suppressed because it is too large. Load diff
+16 -34
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::Args;
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -45,7 +46,12 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(&args.node, None);
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
};
@@ -62,7 +68,13 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "changelog").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("changelog failed ({status}): {msg}");
}
let events = body["events"].as_array().cloned().unwrap_or_default();
@@ -75,14 +87,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
for event in &events {
let ts = event["timestamp"].as_str().unwrap_or("?");
let date = crate::text::truncate(ts, 10);
let date = &ts[..ts.len().min(10)];
match event["type"].as_str().unwrap_or("") {
"commit" => {
let sha = event["sha"].as_str().unwrap_or("?");
let msg = event["message"].as_str().unwrap_or("?");
let first_line = msg.lines().next().unwrap_or(msg);
let short_sha = crate::text::truncate(sha, 8);
let short_sha = &sha[..sha.len().min(8)];
println!(" {date} commit {short_sha} {first_line}");
}
"pr_merged" => {
@@ -204,7 +216,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.expect(1)
.create_async()
.await;
@@ -216,8 +227,6 @@ mod tests {
};
let err = run(args).await.unwrap_err();
assert!(err.to_string().contains("changelog failed"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -265,31 +274,4 @@ mod tests {
let err = rt.block_on(run(args)).unwrap_err();
assert!(err.to_string().contains("no repo specified"));
}
#[tokio::test]
async fn resolve_via_run_surfaces_denial() {
// A slash-free repo with an empty identity dir forces the inline GET /
// node-info fetch during resolution. A gated 404 there must Err (surfacing
// the status), proving the read_json conversion is load-bearing.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
let _m = server
.mock("GET", "/")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"denied"}"#)
.expect(1)
.create_async()
.await;
let err = run(ChangelogArgs {
repo: Some("noslash".to_string()),
limit: 20,
node: server.url(),
dir: Some(dir.path().to_path_buf()),
})
.await
.unwrap_err();
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
}
+5 -89
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,7 +174,6 @@ pub fn setup_partial_clone(
"-q",
"--filter=blob:none",
"--no-checkout",
"--",
remote_url,
dest_str,
])?;
@@ -389,7 +388,7 @@ async fn recover_encrypted_blobs(
let plaintext = match open_blob(&envelope, keypair) {
Ok(p) => p,
Err(e) => {
emit_warning(&format!("warning: could not decrypt {oid}: {e}"));
eprintln!("warning: could not decrypt {oid}: {e}");
continue;
}
};
@@ -407,9 +406,7 @@ async fn recover_encrypted_blobs(
recovered.push(p.clone());
}
} else {
emit_warning(&format!(
"warning: recovered blob {oid} hashed to {written}; discarding"
));
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
}
}
Ok(recovered)
@@ -807,9 +804,7 @@ async fn recover_from_arweave(
recovered.push(p.clone());
}
} else {
emit_warning(&format!(
"warning: recovered blob {oid} hashed to {written}; discarding"
));
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
}
}
Ok(recovered)
@@ -1095,85 +1090,6 @@ 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()]);
+36 -177
View File
@@ -3,7 +3,7 @@
//! Checks:
//! 1. Identity — ~/.gitlawb/identity.pem exists, DID parseable
//! 2. Registration — ~/.gitlawb/ucan.json exists (registered with a node)
//! 3. GITLAWB_NODE — env var points at a public node (unset is advisory)
//! 3. GITLAWB_NODE — env var is set to a non-localhost URL
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.twigpine.com)
//! 5. git helper — git-remote-gitlawb is in PATH
//! 6. git — git is installed
@@ -80,6 +80,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
});
let mut checks = Vec::new();
let mut all_ok = true;
// ── 1. Identity ───────────────────────────────────────────────────────
let pem_path = dir.join("identity.pem");
@@ -140,16 +141,32 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
checks.push(gitlawb_node_env_check(
std::env::var("GITLAWB_NODE").ok().as_deref(),
));
match std::env::var("GITLAWB_NODE") {
// A loopback host is a legitimate setup (self-hosted node, dev
// harness) — the connectivity check below fails loudly if it is not
// actually reachable, so don't red-flag the configuration itself.
Ok(v) if is_loopback_url(&v) => {
checks.push(Check::pass(
"GITLAWB_NODE",
format!(
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
),
));
}
Ok(v) if !v.is_empty() => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
_ => {
checks.push(Check::fail(
"GITLAWB_NODE",
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
"export GITLAWB_NODE=https://node.twigpine.com",
));
}
}
// ── 4. Node connectivity ──────────────────────────────────────────────
// GITLAWB_NODE="" binds args.node to "" through clap, and check 3 groups
// empty with unset. The probe must apply the same normalization or it
// would dial "" and Fail while the env row only Warns.
let node_url = effective_node_url(&args.node);
let client = NodeClient::new(node_url, None);
let client = NodeClient::new(&args.node, None);
match client.get("/").await {
Ok(resp) if resp.status().is_success() => {
let info = resp.json::<serde_json::Value>().await.unwrap_or_default();
@@ -158,7 +175,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
let short_did = did.chars().take(30).collect::<String>();
checks.push(Check::pass(
"node",
format!("{} — v{version} ({short_did}…)", node_url),
format!("{} — v{version} ({short_did}…)", args.node),
));
// Capability drift: a node newer than this CLI may require features
// (RFC 9421 signing, iCaptcha) the CLI doesn't speak.
@@ -177,14 +194,14 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
Ok(resp) => {
checks.push(Check::fail(
"node",
format!("{} returned HTTP {}", node_url, resp.status()),
format!("{} returned HTTP {}", args.node, resp.status()),
"check GITLAWB_NODE env var or try gl register --node <url>",
));
}
Err(e) => {
checks.push(Check::fail(
"node",
format!("{} unreachable: {e}", node_url),
format!("{} unreachable: {e}", args.node),
"check your internet connection or set GITLAWB_NODE",
));
}
@@ -268,21 +285,23 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
CheckState::Fail => "✗",
};
println!(" {icon} {:<24} {}", check.label, check.detail);
if matches!(check.state, CheckState::Fail) {
all_ok = false;
}
}
println!();
let has_failures_now = has_failures(&checks);
let has_issues = checks
.iter()
.any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
if !has_issues {
println!("Everything looks good. Run `gl quickstart` to create your first repo.");
} else {
if has_failures_now {
println!("Some checks failed. Suggested fixes:");
} else {
if all_ok {
println!("Setup looks good with some warnings:");
} else {
println!("Some checks failed. Suggested fixes:");
}
for check in &checks {
if matches!(check.state, CheckState::Fail | CheckState::Warn) {
@@ -292,75 +311,14 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
}
println!();
if has_failures_now {
if !all_ok {
println!("Run `gl quickstart` for a guided setup.");
}
}
// #357: a non-zero exit is the only signal scripting and CI have. Warn-only
// rows stay exit 0 (iCaptcha offline, version drift, shell-alias shadowing
// are all non-fatal); Fail-class rows (identity, registration, node
// reachable, git-remote-gitlawb present, git present) flip to exit 1.
// Direct `process::exit` rather than returning Err: the prose above is the
// user-facing summary, and an anyhow Error frame would just duplicate it.
if has_failures(&checks) {
std::process::exit(1);
}
Ok(())
}
/// Classify the `GITLAWB_NODE` env var for `gl doctor`. Extracted so unit
/// tests can pin the Warn tier directly (#357): an unset env is advisory, not
/// a failure, because `--node` defaults to `https://node.twigpine.com` and the
/// CLI works without it. Flipping this back to `Check::fail` must break a
/// test, not just change prose.
fn gitlawb_node_env_check(env_val: Option<&str>) -> Check {
match env_val {
// A loopback host is a legitimate setup (self-hosted node, dev
// harness) — the connectivity check below fails loudly if it is not
// actually reachable, so don't red-flag the configuration itself.
Some(v) if is_loopback_url(v) => Check::pass(
"GITLAWB_NODE",
format!(
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
),
),
Some(v) if !v.is_empty() => Check::pass("GITLAWB_NODE", v.to_string()),
// `--node` defaults to `https://node.twigpine.com` and the CLI works
// fine without the variable, so an unset env is advisory, not a
// failure. Warn-class keeps the exit code 0 for a stock working
// install; this used to be Fail, which the obvious "return non-zero
// on any failure" change would have flipped to exit 1 on every
// default-config install (#357).
_ => Check::warn(
"GITLAWB_NODE",
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
format!("export GITLAWB_NODE={PUBLIC_NODE}"),
),
}
}
/// The URL the connectivity probe should dial. `GITLAWB_NODE` set but empty
/// (or whitespace-only) binds `args.node` to `""` through clap rather than
/// to the `default_value`, and check 3 already groups empty with unset, so
/// the probe applies the same normalization: an empty effective URL means
/// the public node, same as unset.
fn effective_node_url(node: &str) -> &str {
if node.trim().is_empty() {
PUBLIC_NODE
} else {
node
}
}
/// True when at least one check is Fail-class. Warn-only runs (iCaptcha offline,
/// version drift, shell-alias shadowing, GITLAWB_NODE unset) keep exit 0;
/// this is the gating predicate for #357's exit-status fix.
fn has_failures(checks: &[Check]) -> bool {
checks.iter().any(|c| matches!(c.state, CheckState::Fail))
}
/// Check if a binary name exists anywhere on PATH.
/// True when the rc file contains a real `unalias` command naming `gl` —
/// not a comment, and not a longer word like `unalias global`. Ordering
@@ -686,103 +644,4 @@ mod tests {
assert!(matches!(check.state, CheckState::Ok));
assert!(check.detail.contains("GitHub API returned HTTP 403"));
}
/// #357: an all-Ok run does not flip the exit code to non-zero — a healthy
/// install must keep returning 0 so scripts like `gl doctor && gl push`
/// keep working.
#[test]
fn exit_predicate_is_false_for_all_ok() {
let checks = vec![
Check::pass("identity", "ok"),
Check::pass("registration", "ok"),
Check::pass("git", "ok"),
];
assert!(!has_failures(&checks));
}
/// #357: Warn-class rows (iCaptcha offline, version drift, shell-alias
/// shadowing, GITLAWB_NODE unset on a default-config install) are NOT
/// exit-status failures — they are still printed to the user but the
/// process exits 0. This is the rule that the previous "any
/// Fail-or-Warn row → exit 1" change would have broken for stock
/// installs that simply have not set the env var.
#[test]
fn exit_predicate_is_false_for_warn_only() {
let checks = vec![
Check::pass("identity", "ok"),
Check::warn("GITLAWB_NODE", "unset", "export GITLAWB_NODE=..."),
Check::warn("iCaptcha", "offline", "check connectivity"),
Check::warn("version", "drift", "upgrade"),
Check::warn("shell alias", "omz git plugin", "unalias gl"),
];
assert!(!has_failures(&checks));
}
/// #357: a single Fail-class row trips the exit code. The set of Fail
/// rows is whatever `run` writes through `Check::fail`: identity missing
/// or unparseable, registration missing/malformed, node unreachable,
/// git-remote-gitlawb absent, git absent.
#[test]
fn exit_predicate_is_true_when_a_fail_row_is_present() {
let checks = vec![
Check::pass("registration", "ok"),
Check::fail("identity", "missing", "gl identity new"),
Check::pass("git", "ok"),
];
assert!(has_failures(&checks));
let mixed_warns = vec![
Check::pass("identity", "ok"),
Check::warn("GITLAWB_NODE", "unset", "export"),
Check::fail("node", "unreachable", "check network"),
Check::warn("iCaptcha", "offline", "check"),
];
assert!(has_failures(&mixed_warns));
}
/// #357: `GITLAWB_NODE` unset must be Warn, not Fail. This pins the
/// re-tier so flipping it back to `Check::fail` breaks the suite — the
/// previous `has_failures`-only tests built their own `Check::warn` rows
/// and could not see the re-tier at all.
#[test]
fn gitlawb_node_env_unset_is_warn() {
let check = gitlawb_node_env_check(None);
assert!(matches!(check.state, CheckState::Warn));
assert!(check.detail.contains("not set"));
let empty = gitlawb_node_env_check(Some(""));
assert!(matches!(empty.state, CheckState::Warn));
}
/// `GITLAWB_NODE=""` binds `args.node` to `""` through clap, and check 3
/// groups empty with unset. The probe must dial the same URL unset
/// would — removing the normalization makes check 4 probe `""` and Fail
/// while the env row only Warns, so an empty env install exits 1.
#[test]
fn effective_node_url_treats_empty_and_blank_as_unset() {
assert_eq!(effective_node_url(""), PUBLIC_NODE);
assert_eq!(effective_node_url(" "), PUBLIC_NODE);
assert_eq!(effective_node_url("\t\n"), PUBLIC_NODE);
assert_eq!(
effective_node_url("http://127.0.0.1:7545"),
"http://127.0.0.1:7545"
);
}
#[test]
fn gitlawb_node_env_set_is_pass() {
let check = gitlawb_node_env_check(Some("https://node.twigpine.com"));
assert!(matches!(check.state, CheckState::Ok));
assert!(check.detail.contains("https://node.twigpine.com"));
}
#[test]
fn gitlawb_node_env_loopback_is_pass() {
for url in ["http://127.0.0.1:7545", "http://localhost:7545"] {
let check = gitlawb_node_env_check(Some(url));
assert!(
matches!(check.state, CheckState::Ok),
"loopback {url} must be Ok, not Warn/Fail"
);
}
}
}
+1 -269
View File
@@ -10,7 +10,6 @@ use anyhow::{Context, Result};
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use icaptcha_client::IcaptchaCfg;
use serde_json::Value;
/// Max times we'll fetch a fresh proof and retry a 403-iCaptcha response
/// (absorbs proof expiry / first-seen replay).
@@ -320,7 +319,7 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
/// Strip terminal-dangerous characters from (and cap the length of) a
/// node-supplied error string before surfacing it. The node a caller talks to
/// could be hostile and embed escape sequences in its error body; those must not
/// reach the terminal verbatim. We drop the C0/C1 control bytes (which
/// reach the terminal verbatim (INV-6). We drop the C0/C1 control bytes (which
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
/// `char::is_control` does not cover — they can reorder the displayed line).
pub(crate) fn sanitize_node_msg(s: &str) -> String {
@@ -330,60 +329,6 @@ pub(crate) fn sanitize_node_msg(s: &str) -> String {
.collect()
}
/// Read a JSON response, surfacing a node denial/error instead of parsing it as
/// the requested resource. On a non-2xx status it returns an `Err` carrying the
/// node's sanitized `message` plus the status; on success it parses the
/// body and propagates a parse error, so a truncated/garbage 2xx body is an error
/// rather than a silently-empty success (the denial-as-success bug #123 fixes).
/// `what` names the resource for the error text (e.g. "repo", "commits").
///
/// Callers must route gated reads through this rather than `resp.json().await?`:
/// the bare parse renders a gated 404/5xx body back as the resource.
/// Cap on the error body `read_json` reads before parsing a node's `message`.
/// Matches the capped-read bound used on the sync error path; large enough for any
/// legitimate error, small enough that a hostile node cannot exhaust memory.
const ERROR_BODY_CAP: usize = 8 * 1024;
pub(crate) async fn read_json(resp: reqwest::Response, what: &str) -> Result<Value> {
let status = resp.status();
if !status.is_success() {
// Bound the error read: `resp.json()` would buffer and parse the WHOLE
// body, so a hostile node could stream an arbitrarily large valid JSON
// error and exhaust this process's memory (#186). Read a small capped body
// first, then best-effort extract `message`. The error body may also be
// non-JSON (503 degraded, 413 body-limit from middleware), so a parse miss
// falls back to the capped raw text; an empty body to the status alone.
let raw = read_body_capped(resp, ERROR_BODY_CAP).await.text;
// Preserve the prior contract: surface the JSON `message` when present in
// the capped body, else the status alone ("request failed") — a non-JSON
// or message-less body (503 degraded, 413 body-limit) never surfaces raw
// node bytes. A body whose `message` sits past the cap parses as truncated
// garbage here and correctly falls back rather than being buffered whole.
//
// Fall back to `error` before giving up: the node's shared AppError
// envelope carries both keys, but the task API answers with `error` alone
// (`{"error":"task not found"}` in gitlawb-node/src/api/tasks.rs), so
// reading only `message` blanked every `gl task` denial to the generic
// "request failed". `sync.rs`'s hand-rolled reader already reads both; this
// matches it.
let msg = serde_json::from_str::<Value>(&raw)
.ok()
.and_then(|b| {
b.get("message")
.or_else(|| b.get("error"))
.and_then(|m| m.as_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "request failed".to_owned());
anyhow::bail!("{what} failed ({status}): {}", sanitize_node_msg(&msg));
}
// Success: a truncated/garbage 2xx body must be an `Err`, not `Ok(Null)` that
// a caller then renders as an empty success.
resp.json()
.await
.with_context(|| format!("invalid JSON in {what} response"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1323,217 +1268,4 @@ mod tests {
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
assert_eq!(out, "ok \u{0627}\u{200D}b");
}
// ── read_json (status-checked, bounded, sanitized read; #123) ────────
/// Drive a real `reqwest::Response` off a mockito mock so `read_json` sees an
/// actual HTTP status + body, the same shape the gated read arms produce.
async fn response_for(
server: &mut Server,
status: usize,
body: &str,
json: bool,
) -> reqwest::Response {
let mut m = server.mock("GET", "/x").with_status(status).with_body(body);
if json {
m = m.with_header("content-type", "application/json");
}
let _m = m.create_async().await;
NodeClient::new(server.url(), None).get("/x").await.unwrap()
}
#[tokio::test]
async fn read_json_returns_body_on_2xx() {
let mut server = Server::new_async().await;
let resp = response_for(
&mut server,
200,
r#"{"name":"r","owner_did":"did:gitlawb:z"}"#,
true,
)
.await;
let v = read_json(resp, "repo").await.unwrap();
assert_eq!(v["name"], "r");
}
#[tokio::test]
async fn read_json_errs_on_404_surfacing_message_and_status() {
let mut server = Server::new_async().await;
let resp = response_for(
&mut server,
404,
r#"{"message":"repository 'o/r' not found"}"#,
true,
)
.await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(err.contains("404"), "err={err}");
assert!(err.contains("not found"), "err={err}");
}
#[tokio::test]
async fn read_json_errs_on_500_surfacing_message() {
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 500, r#"{"message":"internal boom"}"#, true).await;
let err = read_json(resp, "commits").await.unwrap_err().to_string();
assert!(err.contains("500"), "err={err}");
assert!(err.contains("internal boom"), "err={err}");
}
#[tokio::test]
async fn read_json_errs_on_non_json_error_body_with_fallback() {
// 503 with a plain-text (middleware) body: no `message` field to surface.
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 503, "service unavailable", false).await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(err.contains("503"), "err={err}");
assert!(err.contains("request failed"), "err={err}");
}
#[tokio::test]
async fn read_json_sanitizes_control_and_bidi_in_message() {
// A hostile node embeds ESC, BEL, and a right-to-left override in
// the error message; none may reach the terminal verbatim.
let mut server = Server::new_async().await;
let resp = response_for(
&mut server,
404,
r#"{"message":"a\u001b[31mb\u0007c\u202ed"}"#,
true,
)
.await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(!err.contains('\u{1b}'), "ESC leaked: {err:?}");
assert!(!err.contains('\u{7}'), "BEL leaked: {err:?}");
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
}
#[tokio::test]
async fn read_json_errs_on_garbage_2xx_body() {
// The #123 correctness point: a 200 with a non-JSON/truncated body must be
// an `Err`, NOT `Ok(Null)` that a caller renders as an empty success.
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 200, "this is not json", false).await;
assert!(read_json(resp, "repo").await.is_err());
}
#[tokio::test]
async fn read_json_errs_on_empty_2xx_body() {
// A zero-byte 200 body must be an `Err`, not a silent `Ok(Null)`.
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 200, "", false).await;
assert!(read_json(resp, "repo").await.is_err());
}
#[tokio::test]
async fn read_json_errs_on_non_2xx_json_without_message_or_error() {
// A non-2xx JSON body carrying NEITHER key is the real fallback case.
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 403, r#"{"detail":"forbidden"}"#, true).await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(err.contains("403"), "err={err}");
assert!(err.contains("request failed"), "err={err}");
// The body's own text must not leak when no recognised key carries it.
assert!(!err.contains("forbidden"), "raw body leaked: {err}");
}
#[tokio::test]
async fn read_json_surfaces_error_key_when_message_absent() {
// The task API answers with `error` alone — see
// gitlawb-node/src/api/tasks.rs `{"error":"task not found"}`. Reading only
// `message` blanked those denials to the generic "request failed", so the
// user was told nothing about why the call failed.
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 404, r#"{"error":"task not found"}"#, true).await;
let err = read_json(resp, "task show").await.unwrap_err().to_string();
assert!(err.contains("404"), "err={err}");
assert!(err.contains("task not found"), "reason not surfaced: {err}");
}
#[tokio::test]
async fn read_json_prefers_message_over_error_key() {
// The shared AppError envelope carries both; `message` is the human text
// and must win over the machine code in `error`.
let mut server = Server::new_async().await;
let resp = response_for(
&mut server,
500,
r#"{"error":"db_error","message":"database unavailable"}"#,
true,
)
.await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(err.contains("database unavailable"), "err={err}");
assert!(
!err.contains("db_error"),
"machine code won over message: {err}"
);
}
#[tokio::test]
async fn read_json_sanitizes_error_key_text() {
// The `error` fallback goes through the same sanitizer as `message`; a
// hostile node must not reach the terminal through the new path. The
// controls are JSON \u escapes so the body stays VALID JSON — embedding
// them raw would fail the parse and pass this test vacuously.
let mut server = Server::new_async().await;
let resp = response_for(
&mut server,
404,
r#"{"error":"a\u001b[31mb\u0007c\u202ed"}"#,
true,
)
.await;
let err = read_json(resp, "task show").await.unwrap_err().to_string();
// Proves the parse succeeded and the `error` key was read, not the
// "request failed" fallback (which would make the asserts below vacuous).
assert!(err.contains("task show failed (404"), "err={err}");
assert!(
!err.contains("request failed"),
"fell back, sanitizer untested: {err}"
);
assert!(
err.contains('a') && err.contains('d'),
"text dropped: {err}"
);
assert!(!err.contains('\u{1b}'), "ESC leaked: {err:?}");
assert!(!err.contains('\u{7}'), "BEL leaked: {err:?}");
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
}
/// #186 (F2): the non-2xx error path must read a CAPPED body, not buffer and
/// parse the whole thing — a hostile node can stream an arbitrarily large valid
/// JSON error. Place the `message` field AFTER more than the cap of padding: the
/// bounded read stops before it, so it must be ABSENT from the surfaced error.
/// RED before the fix (`resp.json()` parses the full body → the far message
/// appears), GREEN after (capped read truncates → fallback → far message gone).
/// The status is still surfaced, so a legitimate small error is unaffected.
#[tokio::test]
async fn read_json_bounds_the_error_body_read() {
let mut server = Server::new_async().await;
// >8 KiB of padding, then the marker message at the very end of the body.
let big = format!(
r#"{{"padding":"{}","message":"FARMARKER_BEYOND_CAP"}}"#,
"A".repeat(16 * 1024)
);
let resp = response_for(&mut server, 500, &big, true).await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(err.contains("500"), "bounded read still errors: {err}");
assert!(
!err.contains("FARMARKER_BEYOND_CAP"),
"the error body read must be capped: a message beyond the cap must not be surfaced, err={err}"
);
}
/// #186 (F2): an EMPTY non-2xx body is its own input class — the capped read
/// yields "", the JSON parse fails, and it falls back to the status alone
/// ("request failed"), never a panic or a spurious success.
#[tokio::test]
async fn read_json_errs_on_empty_non_2xx_body() {
let mut server = Server::new_async().await;
let resp = response_for(&mut server, 502, "", false).await;
let err = read_json(resp, "repo").await.unwrap_err().to_string();
assert!(err.contains("502"), "err={err}");
assert!(err.contains("request failed"), "err={err}");
}
}
-21
View File
@@ -99,27 +99,6 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
}
/// Load keypair from an optional directory override, distinguishing missing default
/// identity (clean anonymous) from an explicit directory error or corrupt PEM.
pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Keypair>> {
if let Some(d) = dir {
return load_keypair_from_dir(Some(d)).map(Some);
}
let base = match dirs::home_dir() {
Some(h) => h.join(".gitlawb"),
None => return Ok(None),
};
let path = key_path(&base);
match fs::read_to_string(&path) {
Ok(pem) => Keypair::from_pem(&pem)
.context("failed to load keypair from PEM")
.map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("failed to read identity from {}", path.display()))),
}
}
async fn cmd_new(dir: Option<PathBuf>, force: bool) -> Result<()> {
cmd_new_with_reader(dir, force, &mut std::io::stdin().lock()).await
}
+5 -1
View File
@@ -131,7 +131,11 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
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);
let ts = if pinned_at.len() >= 19 {
&pinned_at[..19]
} else {
pinned_at
};
println!(" {cid}");
println!(" sha256: {sha}");
println!(" pinned: {ts}");
+52 -106
View File
@@ -3,7 +3,7 @@
use anyhow::{Context, Result};
use chrono::Utc;
use clap::{Args, Subcommand};
use serde_json::json;
use serde_json::{json, Value};
use std::path::PathBuf;
use uuid::Uuid;
@@ -166,7 +166,12 @@ async fn resolve_repo(
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
did.split(':').next_back().unwrap_or(did).to_string()
};
@@ -217,7 +222,13 @@ async fn cmd_create(
.post(&path, &request_body)
.await
.context("failed to connect to node")?;
let result = crate::http::read_json(resp, "create issue").await?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("create issue failed ({status}): {msg}");
}
let id = result["id"].as_str().unwrap_or("?");
println!("✓ Created issue #{id}");
@@ -234,7 +245,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let client = signed_client(&node, dir.as_deref());
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let resp = crate::http::read_json(client.get_authed(&path).await?, "issues").await?;
let resp: Value = client
.get_authed(&path)
.await?
.json()
.await
.context("failed to list issues")?;
let issues = resp["issues"].as_array().cloned().unwrap_or_default();
@@ -251,7 +267,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let status = issue["status"].as_str().unwrap_or("?");
let created = issue["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.map(|s| &s[..10])
.unwrap_or("?");
let icon = match status {
"open" => "○",
@@ -272,7 +288,13 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
.get_authed(&path)
.await
.context("failed to connect to node")?;
let issue = crate::http::read_json(resp, "show").await?;
let status = resp.status();
let issue: Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = issue["message"].as_str().unwrap_or("issue not found");
anyhow::bail!("show failed ({status}): {msg}");
}
let title = issue["title"].as_str().unwrap_or("(no title)");
let status = issue["status"].as_str().unwrap_or("?");
@@ -303,7 +325,13 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
.post(&format!("{path}/close"), &body)
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "close issue").await?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("close issue failed ({status}): {msg}");
}
println!("✗ Closed issue {id}");
Ok(())
@@ -328,7 +356,13 @@ async fn cmd_issue_comment(
)
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "comment").await?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !code.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("comment failed ({code}): {msg}");
}
println!("· Comment posted on issue {id}");
Ok(())
@@ -343,15 +377,14 @@ async fn cmd_issue_comments(
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = signed_client(&node, dir.as_deref());
let resp = crate::http::read_json(
client
.get_authed(&format!(
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
))
.await?,
"issue comments",
)
.await?;
let resp: Value = client
.get_authed(&format!(
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
if comments.is_empty() {
@@ -365,13 +398,10 @@ async fn cmd_issue_comments(
let author_short = author
.split(':')
.next_back()
.map(|s| crate::text::truncate(s, 8))
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
println!(" {cbody}");
println!();
@@ -442,34 +472,6 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn test_cmd_list_survives_malformed_timestamps() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// A short timestamp used to panic on `&s[..10]`; a timestamp whose
// byte 10 is mid-char panics even with a len() guard.
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"issues":[{"id":"a","title":"short ts","status":"open","created_at":"2026"},{"id":"b","title":"mb ts","status":"open","created_at":"2026-08-1é5T00:00:00Z"},{"id":"c","title":"empty ts","status":"open","created_at":""}]}"#)
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_create_success() {
let dir = TempDir::new().unwrap();
@@ -512,7 +514,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.expect(1)
.create_async()
.await;
@@ -527,8 +528,6 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("repo not found"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -576,7 +575,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"issue not found"}"#)
.expect(1)
.create_async()
.await;
@@ -590,8 +588,6 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("show failed"), "got: {err}");
assert!(err.to_string().contains("issue not found"), "got: {err}");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -640,7 +636,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"issue not found"}"#)
.expect(1)
.create_async()
.await;
@@ -653,8 +648,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("close issue failed"), "got: {err}");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -701,7 +694,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"issue not found"}"#)
.expect(1)
.create_async()
.await;
@@ -715,8 +707,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("issue not found"), "got: {err}");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -774,48 +764,4 @@ mod tests {
.await
.unwrap();
}
#[tokio::test]
async fn cmd_list_surfaces_denial_not_empty() {
// A gated 404 on the issue-list read must Err, not print "No issues".
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/issues$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
assert!(result.is_err(), "issue list must Err on a gated 404");
// Prove the gated issues path was actually requested: without this, an
// unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
_m.assert_async().await;
}
#[tokio::test]
async fn resolve_repo_surfaces_denial() {
// A slash-free repo with an empty identity dir forces the GET / node-info
// fetch. A gated 404 there must Err (surfacing the status), proving the
// read_json conversion is load-bearing rather than silently ignored.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
let _m = server
.mock("GET", "/")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"denied"}"#)
.expect(1)
.create_async()
.await;
let err = resolve_repo("noslash", &server.url(), Some(dir.path()))
.await
.unwrap_err();
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
}
-1
View File
@@ -30,7 +30,6 @@ mod star;
mod status;
mod sync;
mod task;
mod text;
mod ucan_cmd;
mod visibility;
mod webhook;
+166 -1219
View File
File diff suppressed because it is too large. Load diff
+10 -149
View File
@@ -41,17 +41,6 @@ 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();
@@ -95,7 +84,14 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
let _guard = TmpGuard(tmp_root);
println!("Cloning source (this may take a while for large repos)...");
clone_mirror(&source, &mirror_path)?;
let clone_status = Command::new("git")
.args(["clone", "--mirror", &source, mirror_path.to_str().unwrap()])
.status()
.context("failed to run git clone — is git installed?")?;
if !clone_status.success() {
bail!("git clone --mirror failed\nCheck that the source URL is accessible: {source}");
}
// ── 4. Create the repo on Twigpine ─────────────────────────────────────
println!("Creating repo on Twigpine node...");
@@ -142,34 +138,11 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
println!();
println!("✓ Mirror complete: {name}");
println!(" Clone: git clone {gitlawb_url}");
// Only print View: when the node advertises a web_url — self-hosted nodes
// without a web front-end would otherwise produce a 404 link (#370).
if let Some(web_url) = crate::repo::fetch_node_web_url(&args.node).await {
println!(" View: {web_url}/{owner_short}/{name}");
}
println!(" View: https://twigpine.com/{owner_short}/{name}");
Ok(())
}
/// `git clone --mirror <source> <dest>`. The destination is passed as an
/// OS-native path so a non-UTF-8 temp dir (e.g. a TMPDIR with non-UTF-8 bytes)
/// does not panic on a `to_str()` conversion.
fn clone_mirror(source: &str, dest: &std::path::Path) -> Result<()> {
// allow-unbounded-git: gl is the client CLI; this clone runs in the user's
// terminal with no server-side permit or disconnect to survive. The
// bounded-runner rule governs node request handlers. Same spawn as before,
// only relocated into this helper.
let status = Command::new("git")
.args(mirror_clone_args(source))
.arg(dest)
.status()
.context("failed to run git clone — is git installed?")?;
if !status.success() {
bail!("git clone --mirror failed\nCheck that the source URL is accessible: {source}");
}
Ok(())
}
/// Extract the repo name from a git URL.
/// - `https://github.com/owner/repo` → `"repo"`
/// - `https://github.com/owner/repo.git` → `"repo"`
@@ -201,43 +174,6 @@ 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!(
@@ -289,80 +225,6 @@ mod tests {
assert_eq!(extract_repo_name("https://example.com/.git"), None);
}
// #417: a non-UTF-8 destination path must reach git as an OS-native arg, not
// panic on a `to_str()` conversion.
#[cfg(unix)]
#[test]
fn test_clone_mirror_non_utf8_dest() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("src.git");
let st = Command::new("git")
.args(["init", "--bare"])
.arg(&src)
.status()
.unwrap();
assert!(st.success());
let dest = tmp.path().join(OsStr::from_bytes(b"mirror-\xffdest"));
clone_mirror(src.to_str().unwrap(), &dest).unwrap();
assert!(dest.join("HEAD").exists());
}
// #417 end-to-end: the real trigger is `std::env::temp_dir()` itself
// returning a non-UTF-8 path (a TMPDIR with non-UTF-8 bytes), because the
// mirror dest is built from it. Env is process-global, so this test
// re-execs the test binary in a child with a non-UTF-8 TMPDIR rather than
// mutating this process's env.
#[cfg(unix)]
#[test]
fn test_clone_mirror_under_non_utf8_tmpdir() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
if std::env::var_os("GL_MIRROR_NONUTF8_TMPDIR").is_none() {
// Parent: a source repo under a normal path, a TMPDIR whose name
// is not UTF-8, and a child run of this same test under that env.
let parent = tempfile::TempDir::new().unwrap();
let src = parent.path().join("src.git");
let st = Command::new("git")
.args(["init", "--bare"])
.arg(&src)
.status()
.unwrap();
assert!(st.success());
let bad_tmp = parent.path().join(OsStr::from_bytes(b"tmp-\xff"));
std::fs::create_dir_all(&bad_tmp).unwrap();
let status = Command::new(std::env::current_exe().unwrap())
.args([
"mirror::tests::test_clone_mirror_under_non_utf8_tmpdir",
"--exact",
])
.env("GL_MIRROR_NONUTF8_TMPDIR", "1")
.env("GL_MIRROR_SRC", &src)
.env("TMPDIR", &bad_tmp)
.status()
.unwrap();
assert!(status.success(), "child failed under non-UTF-8 TMPDIR");
return;
}
// Child: the same path shape run() computes — temp_dir() joined with
// the mirror dest. It must be non-UTF-8 or the test proves nothing.
let src = std::env::var("GL_MIRROR_SRC").unwrap();
let tmp_root = std::env::temp_dir().join("gl-mirror-child");
let dest = tmp_root.join("repo");
assert!(
dest.to_str().is_none(),
"fixture broken: dest under a non-UTF-8 TMPDIR must be non-UTF-8"
);
clone_mirror(&src, &dest).unwrap();
assert!(dest.join("HEAD").exists());
}
#[tokio::test]
async fn test_create_repo_conflict_error() {
let mut server = mockito::Server::new_async().await;
@@ -382,8 +244,7 @@ mod tests {
.create_async()
.await;
// The git subprocess argv is pinned separately by
// `mirror_clone_never_parses_the_source_as_a_git_option`; this covers the
// We can't easily test the git subprocess calls, but we can test 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));
+1 -1
View File
@@ -413,7 +413,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
let ref_name = ev["ref"].as_str().unwrap_or("?");
let ts = ev["timestamp"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {ts} {repo} {ref_name}");
}
+32 -297
View File
@@ -79,7 +79,12 @@ pub async fn run(args: PeerArgs) -> Result<()> {
async fn cmd_list(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let resp = crate::http::read_json(client.get("/api/v1/peers").await?, "peers").await?;
let resp: Value = client
.get("/api/v1/peers")
.await?
.json()
.await
.context("failed to list peers")?;
let peers = resp["peers"].as_array().cloned().unwrap_or_default();
let count = resp["count"].as_u64().unwrap_or(peers.len() as u64);
@@ -97,7 +102,7 @@ async fn cmd_list(node: String) -> Result<()> {
let reachable = peer["reachable"].as_bool().unwrap_or(false);
let last_seen = peer["last_seen"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.map(|s| &s[..10])
.unwrap_or("never");
let status = if reachable { "✓" } else { "✗" };
println!(" {status} {url}");
@@ -180,20 +185,18 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
let keypair = load_keypair_from_dir(dir.as_deref())?;
let my_did = keypair.did().to_string();
// Fetch our node's public URL so we can announce it to the peer. This lookup
// is a deliberate fail-soft diagnostic: it only improves the URL we advertise.
// A response-level failure (denial, error, garbage body) falls back to the
// --node value with a visible note rather than aborting; only a transport
// failure on the GET itself is fatal. The announce below keeps the
// fail-closed read_json check.
// Fetch our node's public URL so we can announce it to the peer
let local_client = NodeClient::new(&node, None);
let my_url = match crate::http::read_json(local_client.get("/").await?, "node info").await {
Ok(info) => info["public_url"].as_str().unwrap_or(&node).to_string(),
Err(e) => {
eprintln!("note: local node info unavailable ({e}); announcing {node}");
node.clone()
}
};
let node_info: Value = local_client
.get("/")
.await?
.json()
.await
.context("failed to fetch local node info")?;
let my_url = node_info["public_url"]
.as_str()
.unwrap_or(&node)
.to_string();
// Announce our local node to the remote peer
let body = serde_json::to_vec(&serde_json::json!({
@@ -269,7 +272,12 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
async fn cmd_ping(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/peers/{did}/ping");
let resp = crate::http::read_json(client.get(&path).await?, "ping peer").await?;
let resp: Value = client
.get(&path)
.await?
.json()
.await
.context("failed to ping peer")?;
let url = resp["http_url"].as_str().unwrap_or("?");
let reachable = resp["reachable"].as_bool().unwrap_or(false);
@@ -289,7 +297,12 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let encoded = urlencoding::encode(&did);
let path = format!("/api/v1/resolve/{encoded}");
let resp = crate::http::read_json(client.get(&path).await?, "resolve DID").await?;
let resp: Value = client
.get(&path)
.await?
.json()
.await
.context("failed to resolve DID")?;
let source = resp["source"].as_str().unwrap_or("not found");
let http_url = resp["http_url"].as_str().unwrap_or("(none)");
@@ -312,8 +325,8 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
#[cfg(test)]
mod tests {
use super::{
announced_peer_summary, cmd_add, cmd_list, cmd_ping, cmd_resolve, local_add_refusal,
local_add_report, remote_announce_failure,
announced_peer_summary, cmd_add, local_add_refusal, local_add_report,
remote_announce_failure,
};
use reqwest::StatusCode;
use serde_json::json;
@@ -606,282 +619,4 @@ mod tests {
"legitimate text mangled: {warning:?}"
);
}
#[tokio::test]
async fn cmd_list_surfaces_denial_not_empty() {
// A node error must Err, not print "No known peers" as if the list were empty.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/peers")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_list(server.url()).await.unwrap_err();
assert!(err.to_string().contains("500"), "got: {err}");
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_ping_surfaces_denial_not_unreachable() {
// A node error must Err, not print a fabricated "unreachable" peer.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/peers/peer1/ping")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_ping("peer1".to_string(), server.url())
.await
.unwrap_err();
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_resolve_surfaces_denial_not_notfound() {
// A node error must Err, not print "Source: not found" as if resolved-absent.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/resolve/".to_string()),
)
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_resolve("peer1".to_string(), server.url())
.await
.unwrap_err();
assert!(err.to_string().contains("500"), "got: {err}");
_m.assert_async().await;
}
// cmd_add needs a local identity to build my_did before the GET /.
fn identity_dir() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
dir
}
/// Mock a successful announce that requires `http_url` in the POSTed body.
/// The `{}` response body matters: it leaves `node_url` absent, so cmd_add
/// skips the follow-up local peer-list POST.
async fn announce_ok_mock(server: &mut mockito::ServerGuard, http_url: &str) -> mockito::Mock {
announce_mock_with_body(server, http_url, "{}").await
}
/// Like [`announce_ok_mock`] but with a custom response body, for tests
/// that need `node_url` populated so the follow-up local add runs.
async fn announce_mock_with_body(
server: &mut mockito::ServerGuard,
http_url: &str,
body: &str,
) -> mockito::Mock {
server
.mock("POST", "/api/v1/peers/announce")
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"http_url": http_url,
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await
}
// The local `GET /` lookup in cmd_add is a fail-soft diagnostic: a denied
// or error node-info response must fall back to announcing the --node URL
// (with a stderr note), not abort the command before the announce POST.
#[tokio::test]
async fn cmd_add_announces_fallback_url_when_node_info_denied() {
let mut local = mockito::Server::new_async().await;
let _info = local
.mock("GET", "/")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"internal"}"#)
.expect(1)
.create_async()
.await;
let mut remote = mockito::Server::new_async().await;
let _announce = announce_ok_mock(&mut remote, &local.url()).await;
let dir = identity_dir();
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
_info.assert_async().await;
_announce.assert_async().await;
}
// Same fallback for a 200 node-info response whose body is not JSON.
#[tokio::test]
async fn cmd_add_falls_back_on_malformed_node_info() {
let mut local = mockito::Server::new_async().await;
let _info = local
.mock("GET", "/")
.with_status(200)
.with_body("not json")
.expect(1)
.create_async()
.await;
let mut remote = mockito::Server::new_async().await;
let _announce = announce_ok_mock(&mut remote, &local.url()).await;
let dir = identity_dir();
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
_info.assert_async().await;
_announce.assert_async().await;
}
// Must-not case: the announce POST itself stays fail-closed. A denied
// announce surfaces as an Err naming the status, never a printed success.
#[tokio::test]
async fn cmd_add_surfaces_denied_announce() {
let mut local = mockito::Server::new_async().await;
let _info = local
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"public_url":"https://pub.example"}"#)
.expect(1)
.create_async()
.await;
let mut remote = mockito::Server::new_async().await;
let _announce = remote
.mock("POST", "/api/v1/peers/announce")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"denied"}"#)
.expect(1)
.create_async()
.await;
let dir = identity_dir();
let err = cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
.await
.unwrap_err();
assert!(err.to_string().contains("403"), "got: {err}");
_info.assert_async().await;
_announce.assert_async().await;
}
// Success path unchanged: a usable node-info response announces its
// public_url; the --node fallback must not leak in.
#[tokio::test]
async fn cmd_add_uses_public_url_when_lookup_succeeds() {
let mut local = mockito::Server::new_async().await;
let _info = local
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"public_url":"https://pub.example"}"#)
.expect(1)
.create_async()
.await;
let mut remote = mockito::Server::new_async().await;
let _announce = announce_ok_mock(&mut remote, "https://pub.example").await;
let dir = identity_dir();
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
_info.assert_async().await;
_announce.assert_async().await;
}
// When the peer's announce response carries a node_url, cmd_add posts the
// peer back to the local node's peer list; a successful local POST is the
// one case that prints the added line.
#[tokio::test]
async fn cmd_add_adds_peer_to_local_list_on_success() {
let mut local = mockito::Server::new_async().await;
let _info = local
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"public_url":"https://pub.example"}"#)
.expect(1)
.create_async()
.await;
let _local_add = local
.mock("POST", "/api/v1/peers/announce")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"added"}"#)
.expect(1)
.create_async()
.await;
let mut remote = mockito::Server::new_async().await;
let _announce = announce_mock_with_body(
&mut remote,
"https://pub.example",
r#"{"node_did":"their-did","node_url":"http://their.example"}"#,
)
.await;
let dir = identity_dir();
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
_info.assert_async().await;
_announce.assert_async().await;
_local_add.assert_async().await;
}
// The local add stays best-effort: a failing local POST must not fail the
// command (the announce to the remote peer already succeeded), it only
// changes the printed outcome.
#[tokio::test]
async fn cmd_add_stays_ok_when_local_add_fails() {
let mut local = mockito::Server::new_async().await;
let _info = local
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"public_url":"https://pub.example"}"#)
.expect(1)
.create_async()
.await;
let _local_add = local
.mock("POST", "/api/v1/peers/announce")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
let mut remote = mockito::Server::new_async().await;
let _announce = announce_mock_with_body(
&mut remote,
"https://pub.example",
r#"{"node_did":"their-did","node_url":"http://their.example"}"#,
)
.await;
let dir = identity_dir();
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
_info.assert_async().await;
_announce.assert_async().await;
_local_add.assert_async().await;
}
}
+80 -334
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -264,7 +265,13 @@ async fn cmd_create(
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload)
.await
.context("failed to connect to node")?;
let pr = crate::http::read_json(resp, "create PR").await?;
let status = resp.status();
let pr: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = pr["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("create PR failed ({status}): {msg}");
}
let number = pr["number"].as_i64().unwrap_or(0);
println!("✓ Opened PR #{number}: {title}");
@@ -279,13 +286,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?,
"pull requests",
)
.await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?
.json()
.await
.context("invalid JSON")?;
let prs = resp["pulls"].as_array().cloned().unwrap_or_default();
if prs.is_empty() {
@@ -304,7 +310,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let author_short = author
.split(':')
.next_back()
.map(|s| crate::text::truncate(s, 8))
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let status_icon = match status {
"open" => "○",
@@ -324,13 +330,12 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let pr = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?,
"pull request",
)
.await?;
let pr: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?
.json()
.await
.context("invalid JSON")?;
let title = pr["title"].as_str().unwrap_or("?");
let status = pr["status"].as_str().unwrap_or("?");
@@ -348,25 +353,15 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
}
// Show reviews
// A denied sub-fetch degrades this section rather than aborting: the header
// above has already printed, so a bare `?` here leaves a half-rendered PR and
// skips the comments below it. Mirrors `gl status`'s unavailable-section line.
let reviews = match crate::http::read_json(
client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?,
"reviews",
)
.await
{
Ok(v) => v["reviews"].as_array().cloned().unwrap_or_default(),
Err(e) => {
println!("\nReviews unavailable ({e})");
Vec::new()
}
};
let reviews: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?
.json()
.await
.context("invalid JSON")?;
let reviews = reviews["reviews"].as_array().cloned().unwrap_or_default();
if !reviews.is_empty() {
println!("\nReviews ({}):", reviews.len());
for r in &reviews {
@@ -374,7 +369,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let reviewer_short = reviewer
.split(':')
.next_back()
.map(|s| crate::text::truncate(s, 8))
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let rstatus = r["status"].as_str().unwrap_or("?");
let rbody = r["body"].as_str().unwrap_or("");
@@ -391,22 +386,15 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
}
// Show comments
let comments = match crate::http::read_json(
client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?,
"comments",
)
.await
{
Ok(v) => v["comments"].as_array().cloned().unwrap_or_default(),
Err(e) => {
println!("\nComments unavailable ({e})");
Vec::new()
}
};
let comments: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments = comments["comments"].as_array().cloned().unwrap_or_default();
if !comments.is_empty() {
println!("\nComments ({}):", comments.len());
for c in &comments {
@@ -414,13 +402,10 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let author_short = author
.split(':')
.next_back()
.map(|s| crate::text::truncate(s, 8))
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
println!(" {cbody}");
}
@@ -433,13 +418,12 @@ async fn cmd_diff(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp = crate::http::read_json(
client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?,
"diff",
)
.await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?
.json()
.await
.context("invalid JSON")?;
let diff = resp["diff"].as_str().unwrap_or("");
if diff.is_empty() {
@@ -463,11 +447,17 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
)
.await
.context("failed to connect to node")?;
let result = crate::http::read_json(resp, "merge").await?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("merge failed ({status}): {msg}");
}
let sha = result["merge_sha"].as_str().unwrap_or("?");
println!("✓ Merged PR #{number}");
println!(" Merge commit: {}", crate::text::truncate(sha, 12));
println!(" Merge commit: {}", &sha[..sha.len().min(12)]);
Ok(())
}
@@ -495,7 +485,13 @@ async fn cmd_review(
)
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "review").await?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !code.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("review failed ({code}): {msg}");
}
let icon = match status.as_str() {
"approved" => "✓",
@@ -525,7 +521,13 @@ async fn cmd_comment(
)
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "comment").await?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !code.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("comment failed ({code}): {msg}");
}
println!("· Comment posted on PR #{number}");
Ok(())
@@ -536,15 +538,14 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp = crate::http::read_json(
client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?,
"comments",
)
.await?;
let resp: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
if comments.is_empty() {
@@ -558,13 +559,10 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
let author_short = author
.split(':')
.next_back()
.map(|s| crate::text::truncate(s, 8))
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
println!(" {cbody}");
println!();
@@ -688,7 +686,6 @@ mod tests {
.with_status(422)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"branch not found"}"#)
.expect(1)
.create_async()
.await;
@@ -706,8 +703,6 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("branch not found"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -805,7 +800,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"PR not found"}"#)
.expect(1)
.create_async()
.await;
@@ -819,8 +813,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("PR not found"), "got: {err}");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -905,250 +897,4 @@ mod tests {
.await
.unwrap();
}
// ── Gated PR reads surface node denials, not empty/stub renders (#123) ──
#[tokio::test]
async fn cmd_list_surfaces_denial_not_empty() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/pulls$".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
result.is_err(),
"cmd_list must Err on 404, not print 'No pull requests'"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_view_surfaces_denial_not_stub() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// The PR fetch is first; a 404 there errors before the reviews/comments reads.
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_view(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
result.is_err(),
"cmd_view must Err on 404, not print a stub PR"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_diff_surfaces_denial_not_empty() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/diff$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_diff(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
result.is_err(),
"cmd_diff must Err on 404, not print 'No diff'"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_comments_surfaces_denial_not_empty() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_comments(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(result.is_err(), "cmd_comments must Err on a gated 404");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
/// A denied sub-fetch must not abort `gl pr view` after the header has
/// already printed. The header is fatal; reviews and comments degrade.
#[tokio::test]
async fn cmd_view_continues_when_reviews_are_denied() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _pr = server
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"number":1,"title":"T","status":"open","source_branch":"a","target_branch":"main","author_did":"did:key:z6MkTest"}"#)
.expect(1)
.create_async()
.await;
let _reviews = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/reviews$".to_string()),
)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"forbidden"}"#)
.expect(1)
.create_async()
.await;
// The load-bearing assertion: this mock is only hit if rendering
// continued past the denied reviews fetch.
let _comments = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"comments":[]}"#)
.expect(1)
.create_async()
.await;
let r = cmd_view(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
r.is_ok(),
"cmd_view must finish rendering when reviews are denied"
);
_comments.assert_async().await;
}
#[tokio::test]
async fn cmd_view_continues_when_comments_are_denied() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _pr = server
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"number":1,"title":"T","status":"open","source_branch":"a","target_branch":"main","author_did":"did:key:z6MkTest"}"#)
.expect(1)
.create_async()
.await;
let _reviews = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/reviews$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"reviews":[]}"#)
.expect(1)
.create_async()
.await;
let _comments = server
.mock(
"GET",
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"forbidden"}"#)
.expect(1)
.create_async()
.await;
let r = cmd_view(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(
r.is_ok(),
"cmd_view must finish rendering when comments are denied"
);
_comments.assert_async().await;
}
/// The must-not case: with no header there is nothing to render partially,
/// so a denied header stays fatal. Softening it would resurrect
/// denial-as-success for the whole command.
#[tokio::test]
async fn cmd_view_header_denial_stays_fatal() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _pr = server
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
.expect(1)
.create_async()
.await;
let r = cmd_view(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await;
assert!(r.is_err(), "a denied PR header must stay fatal");
_pr.assert_async().await;
}
}
+28 -29
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -93,7 +94,12 @@ async fn resolve_owner_repo(
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
};
@@ -114,7 +120,13 @@ async fn cmd_set(branch: String, repo: String, node: String, dir: Option<PathBuf
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "protect").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("protect failed ({status}): {msg}");
}
println!("✓ Branch '{branch}' is now protected in {owner}/{name}");
println!(" Only the repo owner can push to this branch.");
@@ -140,7 +152,13 @@ async fn cmd_remove(
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "unprotect").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("unprotect failed ({status}): {msg}");
}
println!("✓ Branch '{branch}' is no longer protected in {owner}/{name}");
Ok(())
@@ -157,7 +175,13 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "list protected branches").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("list protected branches failed ({status}): {msg}");
}
let branches = body["protected_branches"]
.as_array()
@@ -235,7 +259,6 @@ mod tests {
.with_status(400)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"only the repo owner can protect branches"}"#)
.expect(1)
.create_async()
.await;
@@ -248,8 +271,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("protect failed"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -361,26 +382,4 @@ mod tests {
assert_eq!(owner, "alice");
assert_eq!(name, "myrepo");
}
#[tokio::test]
async fn resolve_owner_repo_surfaces_denial() {
// A slash-free repo with an empty identity dir forces the GET / node-info
// fetch. A gated 404 there must Err (surfacing the status), proving the
// read_json conversion is load-bearing rather than silently ignored.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
let _m = server
.mock("GET", "/")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"denied"}"#)
.expect(1)
.create_async()
.await;
let err = resolve_owner_repo("noslash", &server.url(), Some(dir.path()))
.await
.unwrap_err();
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
}
+11 -5
View File
@@ -5,7 +5,7 @@
use anyhow::{Context, Result};
use clap::Args;
use serde_json::json;
use serde_json::{json, Value};
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -59,7 +59,16 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
.await
.context("failed to connect to node")?;
let payload = crate::http::read_json(resp, "registration").await?;
let status = resp.status();
let payload: Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = payload
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
anyhow::bail!("registration failed ({status}): {msg}");
}
// Save bootstrap UCAN
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
@@ -166,7 +175,6 @@ mod tests {
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"invalid signature"}"#)
.expect(1)
.create_async()
.await;
@@ -183,8 +191,6 @@ mod tests {
.unwrap_err()
.to_string()
.contains("invalid signature"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
+89 -576
View File
@@ -5,7 +5,7 @@ use clap::{Args, Subcommand};
use serde_json::{json, Value};
use std::path::PathBuf;
use crate::http::{sanitize_node_msg, NodeClient};
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
@@ -274,126 +274,6 @@ async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
}
/// Fetch `GET /` from the node and return the `web_url` if the node advertises one.
///
/// The View: link is a nice-to-have, so every failure mode degrades to `None`
/// rather than failing the enclosing command — but failures are surfaced as
/// stderr warnings (request error, non-success HTTP status with the status,
/// malformed advertised value), since they all mean the node itself is
/// misbehaving or misconfigured, which the user would otherwise never learn.
/// A successful response that lacks a usable `web_url` (field missing or
/// blank) omits the link silently: self-hosted nodes without a web front-end
/// are expected to not advertise one (#370).
///
/// The body is treated like every other caller-chosen node reply (INV-6): the
/// read is capped and an accepted `web_url` must be free of control/bidi bytes
/// — it reaches the terminal verbatim through the View: line.
pub(crate) async fn fetch_node_web_url(node: &str) -> Option<String> {
let info_client = NodeClient::new(node, None);
let info_resp = match info_client.get("/").await {
Ok(resp) => resp,
Err(err) => {
eprintln!("warning: node info request failed ({err}); skipping View link");
return None;
}
};
let status = info_resp.status();
if !status.is_success() {
eprintln!("warning: node info request returned {status}; skipping View link");
return None;
}
// An info reply is a DID, a few URLs and counts — 8 KiB is well past what
// the shape needs; anything longer is hostile or broken (peer.rs precedent).
let raw_body = crate::http::read_body_capped(info_resp, 8 * 1024).await;
match web_url_from_info_body(&raw_body) {
Ok(url) => url,
Err(reason) => {
eprintln!("warning: {reason}; skipping View link");
None
}
}
}
/// Classify a capped `GET /` body for the `View:` advertisement. Split out of
/// `fetch_node_web_url` so tests can pin the classification contract directly:
/// a truncated or half-read body is indistinguishable from a node that
/// advertised nothing, so it must warn rather than silently omit the link.
fn web_url_from_info_body(
raw_body: &crate::http::CappedBody,
) -> std::result::Result<Option<String>, String> {
if raw_body.truncated || raw_body.read_failed {
return Err("node info body was oversized or could not be read in full".to_string());
}
let info: Value = match serde_json::from_str(&raw_body.text) {
Ok(json) => json,
Err(_) => return Err("node info body was not valid JSON".to_string()),
};
// Missing field / non-string degrade to None without warning — same contract
// as before; only transport- and advertisement-level failures warn there.
let raw = match info["web_url"].as_str() {
Some(v) => v,
None => return Ok(None),
};
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Ok(None);
}
// A present-but-malformed value means the node is misconfigured; say so
// instead of quietly dropping the link. Mirrors the node-side boot
// validation: must be an absolute http(s) URL with no query or fragment —
// the link is built by string-appending `/{owner}/{repo}` to it. Control
// and bidi-format bytes are rejected outright (not stripped): they have no
// legitimate place in a base URL and would reach the terminal through the
// View: line — including when they arrive percent-encoded (INV-6).
let malformed_reason: Option<String> = if trimmed
.chars()
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
{
Some("contains control or bidi characters".to_string())
} else {
match trimmed.parse::<url::Url>() {
Err(_) => Some("not an absolute URL".to_string()),
Ok(parsed) if !matches!(parsed.scheme(), "http" | "https") => {
Some("not http(s)".to_string())
}
Ok(parsed) if parsed.query().is_some() || parsed.fragment().is_some() => {
Some("contains a query or fragment".to_string())
}
// userinfo is rejected because the `View:` line prints the
// value verbatim; a remote advertisement carrying
// `user:pass@host` would land that credential in terminal
// scrollback and CI logs.
Ok(parsed) if !parsed.username().is_empty() || parsed.password().is_some() => {
Some("contains a username or password".to_string())
}
Ok(parsed)
if parsed
.host_str()
.unwrap_or("")
.chars()
.chain(
percent_encoding::percent_decode_str(parsed.path())
.decode_utf8_lossy()
.chars(),
)
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c)) =>
{
Some("contains control or bidi characters, even percent-encoded".to_string())
}
Ok(_) => None,
}
};
match malformed_reason {
// The reason string is ours; the advertised value is not — defang it
// exactly as it would have been defanged had it been accepted.
Some(reason) => Err(format!(
"node advertised a malformed web_url ({:?}, {reason})",
sanitize_node_msg(trimmed)
)),
None => Ok(Some(trimmed.to_string())),
}
}
async fn cmd_create(
name: String,
description: Option<String>,
@@ -423,7 +303,13 @@ async fn cmd_create(
.post("/api/v1/repos", &body)
.await
.context("failed to connect to node")?;
let payload = crate::http::read_json(resp, "create").await?;
let status = resp.status();
let payload: Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = payload["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("create failed ({status}): {msg}");
}
let clone_url = payload["clone_url"].as_str().unwrap_or("");
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
@@ -431,11 +317,7 @@ async fn cmd_create(
println!("✓ Created repository: {name}");
println!(" Clone: git clone {gitlawb_url}");
println!(" HTTP: {clone_url}");
// Only print View: when the node advertises a web_url — self-hosted nodes
// without a web front-end would otherwise produce a 404 link (#370).
if let Some(web_url) = fetch_node_web_url(&node).await {
println!(" View: {web_url}/{owner_short}/{name}");
}
println!(" View: https://twigpine.com/{owner_short}/{name}");
if let Some(desc) = payload["description"].as_str().filter(|s| !s.is_empty()) {
println!(" Desc: {desc}");
}
@@ -447,7 +329,12 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
let client = NodeClient::new(&node, None);
let url = format!("/api/v1/repos?owner={owner}");
let repos = crate::http::read_json(client.get(&url).await?, "list repos").await?;
let repos: Value = client
.get(&url)
.await?
.json()
.await
.context("failed to list repos")?;
let repos = repos.as_array().context("expected array")?;
if repos.is_empty() {
@@ -461,10 +348,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
let name = r["name"].as_str().unwrap_or("?");
let desc = r["description"].as_str().unwrap_or("");
let public = r["is_public"].as_bool().unwrap_or(true);
let updated = r["updated_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.unwrap_or("?");
let updated = r["updated_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let vis = if public { "public" } else { "private" };
println!(" {name:<24} {vis:<8} {updated} {desc}");
}
@@ -519,13 +403,20 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
// A non-existent (or unreadable/quarantined) repo is a real 404 from the
// node — surface it plainly instead of printing a stub card with `?` fields
// and a placeholder owner DID.
if resp.status().as_u16() == 404 {
anyhow::bail!("repository '{owner}/{name}' not found");
if !resp.status().is_success() {
if resp.status().as_u16() == 404 {
anyhow::bail!("repository '{owner}/{name}' not found");
}
let status = resp.status();
let msg = resp
.json::<Value>()
.await
.ok()
.and_then(|v| v["message"].as_str().map(String::from))
.unwrap_or_else(|| "request failed".to_string());
anyhow::bail!("repo info failed ({status}): {msg}");
}
// Every other status routes through read_json: a 2xx yields the parsed body,
// any other non-2xx yields the node's capped + sanitized message, and
// the error read is bounded rather than buffering the whole hostile body.
let r = crate::http::read_json(resp, "repo info").await?;
let r: Value = resp.json().await.context("parse repo info")?;
let owner_did = r["owner_did"].as_str().unwrap_or(&owner);
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
@@ -584,7 +475,12 @@ async fn cmd_replica_register(
.await
.context("failed to connect to origin node")?;
let body = crate::http::read_json(resp, "replica register").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replica register failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("Registered as replica of {owner}/{name}");
@@ -611,7 +507,12 @@ async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>
.await
.context("failed to connect to origin node")?;
let body = crate::http::read_json(resp, "replica unregister").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replica unregister failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("Unregistered as replica of {owner}/{name} ({count} replicas remaining)");
@@ -631,7 +532,12 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Resul
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "replicas list").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replicas list failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("{owner}/{name}: {count} replicas");
@@ -641,7 +547,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Resul
let url = r["replica_url"].as_str().unwrap_or("?");
let registered = r["registered_at"]
.as_str()
.map(|s| crate::text::truncate(s, 10))
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {registered} {did} → {url}");
}
@@ -667,7 +573,12 @@ pub(crate) async fn cmd_commits(
};
let url = format!("/api/v1/repos/{owner}/{name}/commits?branch={branch}&limit={limit}");
let resp = crate::http::read_json(client.get(&url).await?, "commits").await?;
let resp: Value = client
.get(&url)
.await?
.json()
.await
.context("failed to fetch commits")?;
let commits = resp["commits"].as_array().cloned().unwrap_or_default();
if commits.is_empty() {
@@ -683,7 +594,7 @@ pub(crate) async fn cmd_commits(
.or_else(|| c["sha"].as_str())
.or_else(|| c["oid"].as_str())
.unwrap_or("?");
let short_sha = crate::text::truncate(sha, 10);
let short_sha = &sha[..sha.len().min(10)];
let msg = c["message"].as_str().unwrap_or("(no message)");
let first_line = msg.lines().next().unwrap_or(msg);
let author = c["author_name"]
@@ -693,7 +604,7 @@ pub(crate) async fn cmd_commits(
let date = c["date"]
.as_str()
.or_else(|| c["committer_date"].as_str())
.map(|s| crate::text::truncate(s, 10))
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {short_sha} {date} {first_line} ({author})");
}
@@ -720,7 +631,13 @@ async fn cmd_fork(
.post(&format!("/api/v1/repos/{owner}/{repo_name}/fork"), &body)
.await
.context("failed to connect to node")?;
let result = crate::http::read_json(resp, "fork").await?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("fork failed ({status}): {msg}");
}
let fork_name = result["name"].as_str().unwrap_or(&repo_name);
let owner_did = result["owner_did"].as_str().unwrap_or("?");
@@ -758,7 +675,13 @@ async fn cmd_label_add(
.post(&format!("/api/v1/repos/{owner}/{name}/labels"), &body)
.await
.context("failed to connect to node")?;
let result = crate::http::read_json(resp, "add label").await?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("add label failed ({status}): {msg}");
}
let added = result["added"].as_bool().unwrap_or(true);
if added {
@@ -783,7 +706,13 @@ async fn cmd_label_remove(
.delete(&format!("/api/v1/repos/{owner}/{name}/labels/{label}"), &[])
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "remove label").await?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("remove label failed ({status}): {msg}");
}
println!("- Label removed: {label} from {owner}/{name}");
Ok(())
@@ -796,21 +725,18 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
// read their own labels, while public repos stay anonymously listable.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
// Marry both refactors: main's get_maybe_signed (read-visibility gating —
// public repos stay anon-listable, private-repo owner signs) carrying the
// PR's read_json status-check-before-parse (#186: bounded error read, no
// parse-before-status). get_authed from the PR is dropped in favor of
// get_maybe_signed so the visibility gate is preserved.
let resp = crate::http::read_json(
client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await
.context("failed to connect to node")?,
"list labels",
)
.await?;
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await
.context("failed to connect to node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("list labels failed ({status}): {msg}");
}
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
let labels = body["labels"].as_array().cloned().unwrap_or_default();
if labels.is_empty() {
println!("No labels on {owner}/{name}");
} else {
@@ -972,285 +898,6 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn test_fetch_node_web_url_with_web_url() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"web_url":"https://example.com","did":"did:key:z6Mk"}"#)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url.as_deref(), Some("https://example.com"));
}
#[tokio::test]
async fn test_fetch_node_web_url_trims_trailing_slash() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"web_url":"https://example.com/"}"#)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url.as_deref(), Some("https://example.com"));
}
#[tokio::test]
async fn test_fetch_node_web_url_without_web_url() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"did":"did:key:z6Mk"}"#)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url, None);
}
#[tokio::test]
async fn test_fetch_node_web_url_empty_string() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"web_url":""}"#)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url, None);
}
#[tokio::test]
async fn test_fetch_node_web_url_whitespace_only() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"web_url":" "}"#)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url, None);
}
#[tokio::test]
async fn test_fetch_node_web_url_server_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(500)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url, None);
}
/// A node advertising a present-but-malformed web_url (not an absolute
/// http(s) URL) must not yield a View link — and must warn, since a
/// malformed advertisement means the node is misconfigured (#370).
#[tokio::test]
async fn test_fetch_node_web_url_malformed_value_is_rejected() {
for bad in [
"gitlawb.com",
"not a url",
"ftp://files.example.com",
// Query/fragment corrupt the appended /{owner}/{repo} path.
"https://example.com?a=1",
"https://example.com/#faq",
] {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(r#"{{"web_url":"{bad}"}}"#))
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(
web_url, None,
"malformed web_url {bad:?} must not render a View link"
);
}
}
/// A transport-level failure (connection refused) degrades to None but is
/// no longer silent: the request error is surfaced on stderr so an
/// unreachable node isn't indistinguishable from one without a web_url.
#[tokio::test]
async fn test_fetch_node_web_url_connection_refused_warns() {
// Port 1 on localhost is reserved (tcpmux) and refuses connections.
let web_url = fetch_node_web_url("http://127.0.0.1:1").await;
assert_eq!(web_url, None);
}
/// A hostile node can smuggle ANSI/bell/bidi controls inside a web_url that
/// still passes http(s) URL parsing — those bytes would reach the terminal
/// verbatim through the View: line. The advertised value must be rejected
/// outright: no control byte may survive into the returned string.
#[tokio::test]
async fn test_fetch_node_web_url_rejects_control_bytes() {
for bad in [
"https://example.com/\x1b[31mred", // ANSI CSI escape in path
"https://example.com\x07", // bell
"https://ex\u{202e}ample.com", // bidi override (RLO)
"https://example.com/\u{200f}", // RLM format char
"\x1b]0;title\x07https://evil.example", // OSC title-set prefix
] {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
// serde_json escapes the control bytes so the body is VALID
// JSON — a raw literal would make parse fail and the test
// would pass without reaching the control check at all.
.with_body(serde_json::json!({"web_url": bad}).to_string())
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(
web_url, None,
"web_url with control bytes {bad:?} must be rejected"
);
}
}
/// An oversized or half-read `GET /` body must warn, not silently
/// classify as "no web front-end": a cut body fails JSON parse, which is
/// indistinguishable from a node that advertised nothing without the
/// truncated flag. Removing the flag check makes this test pass
/// vacuously through the parse-fail arm.
#[test]
fn web_url_from_info_body_warns_on_truncated_reads() {
for (body, label) in [
(
crate::http::CappedBody {
text: r#"{"web_url":"https://x"#.repeat(2000),
truncated: true,
read_failed: false,
},
"truncated",
),
(
crate::http::CappedBody {
text: r#"{"web_url":"https://x"#.to_string(),
truncated: false,
read_failed: true,
},
"read_failed",
),
] {
let err = web_url_from_info_body(&body)
.expect_err(&format!("{label} body must warn, not silently classify"));
assert!(
err.contains("oversized or could not be read"),
"{label}: got {err}"
);
}
// A clean body missing web_url still classifies as a silent None.
let clean = crate::http::CappedBody {
text: r#"{"did":"did:key:x"}"#.to_string(),
truncated: false,
read_failed: false,
};
assert_eq!(web_url_from_info_body(&clean).unwrap(), None);
}
/// Percent-encoded bidi must not slip past the literal scan: a node
/// advertising `https://example.com/%E2%80%AE` serves wire bytes with no
/// Cf code point, but a browser decodes it into a reordering character
/// on the rendered View: link (INV-6).
#[test]
fn web_url_from_info_body_rejects_percent_encoded_bidi() {
let body = crate::http::CappedBody {
text: r#"{"web_url":"https://example.com/%E2%80%AE"}"#.to_string(),
truncated: false,
read_failed: false,
};
let err = web_url_from_info_body(&body).expect_err("percent-encoded bidi must be rejected");
assert!(err.contains("bidi"), "got {err}");
}
#[tokio::test]
async fn test_fetch_node_web_url_validates_after_trimming() {
// Trailing slash is trimmed before validation; result stays usable.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"web_url":"http://localhost:8080/ui/"}"#)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url.as_deref(), Some("http://localhost:8080/ui"));
}
/// A remote node that advertises `web_url` with userinfo (`user@`,
/// `user:pass@`) must be dropped: the value is reprinted on the `View:`
/// line, and a credential embedded in the URL would land in terminal
/// scrollback and CI logs. Mirrors the node-side boot validation.
#[tokio::test]
async fn test_fetch_node_web_url_rejects_userinfo() {
for bad in [
"https://[email protected]",
"https://user:[email protected]",
] {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(r#"{{"web_url":"{}"}}"#, bad))
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(
web_url, None,
"web_url with userinfo {bad:?} must be rejected"
);
}
}
/// A non-success status must still return None (View link is cosmetic) but
/// the status surfaces as a user-visible stderr warning instead of being
/// swallowed silently (#370 review).
#[tokio::test]
async fn test_fetch_node_web_url_non_success_warns_with_status() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.with_status(503)
.create_async()
.await;
let web_url = fetch_node_web_url(&server.url()).await;
assert_eq!(web_url, None);
// The warning itself goes to stderr; mockito can't capture it here, so
// this asserts only the None contract. Manual check: run against a
// 503-ing node and observe "node info request returned 503" on stderr.
}
#[tokio::test]
async fn test_cmd_create_server_error() {
let dir = TempDir::new().unwrap();
@@ -1262,7 +909,6 @@ mod tests {
.with_status(409)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository already exists"}"#)
.expect(1)
.create_async()
.await;
@@ -1278,8 +924,6 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -1348,33 +992,6 @@ mod tests {
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_list_repos_surfaces_denial() {
// A node error must Err with the status, not trip the vague "expected
// array" fallback that hides the node's actual response.
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos\?owner=".to_string()),
)
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap_err();
assert!(err.to_string().contains("500"), "got: {err}");
_m.assert_async().await;
}
#[tokio::test]
async fn test_cmd_commits_empty() {
let dir = TempDir::new().unwrap();
@@ -1474,7 +1091,6 @@ mod tests {
.with_status(400)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"you already have a repo named myrepo"}"#)
.expect(1)
.create_async()
.await;
@@ -1490,8 +1106,6 @@ mod tests {
err.to_string().contains("already have a repo"),
"got: {err}"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -1864,7 +1478,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
.expect(1)
.create_async()
.await;
@@ -1877,106 +1490,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_repo.assert_async().await;
}
// ── Gated CLI reads surface node denials, not empty renders (#123) ──
#[tokio::test]
async fn cmd_commits_surfaces_denial_not_empty() {
// A gated 404 must Err, not print "No commits" as if the repo were empty.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/commits".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_commits(
"alice/secret".to_string(),
"main".to_string(),
20,
server.url(),
None,
)
.await;
assert!(result.is_err(), "cmd_commits must Err on 404");
assert!(result.unwrap_err().to_string().contains("not found"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
#[tokio::test]
async fn cmd_label_list_surfaces_denial_not_empty() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/labels".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
.expect(1)
.create_async()
.await;
let result = cmd_label_list("alice/secret".to_string(), server.url(), None).await;
assert!(result.is_err(), "cmd_label_list must Err on 404");
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
_m.assert_async().await;
}
// cmd_info keeps its bespoke 404 message ahead of read_json...
#[tokio::test]
async fn cmd_info_404_keeps_bespoke_message() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/repos/alice/secret")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"gone"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_info("alice/secret".to_string(), server.url(), None)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("repository 'alice/secret' not found"),
"bespoke 404 text lost: {err}"
);
_m.assert_async().await;
}
// ...and every other non-2xx routes through read_json (capped + sanitized).
#[tokio::test]
async fn cmd_info_500_routes_through_read_json() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/repos/alice/secret")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
let err = cmd_info("alice/secret".to_string(), server.url(), None)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("repo info failed"),
"read_json label lost: {err}"
);
assert!(err.contains("500"), "status not surfaced: {err}");
_m.assert_async().await;
}
#[tokio::test]
@@ -2175,7 +1688,7 @@ mod vet_merge_deny_arms {
}
// An authenticated caller DENIED with a 403 must surface an error, never
// render a repo card (deny-as-success is the client-contract break).
// render a repo card (deny-as-success is the INV-8 client-contract break).
#[tokio::test]
async fn cmd_info_403_denied_surfaces_error_not_card() {
let dir = TempDir::new().unwrap();
+22 -56
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
@@ -86,7 +87,13 @@ async fn cmd_add(repo: String, node: String, dir: Option<PathBuf>) -> Result<()>
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "star").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("star failed ({status}): {msg}");
}
let count = body["star_count"].as_i64().unwrap_or(0);
println!("Starred {owner}/{name} ({count} stars total)");
@@ -104,7 +111,13 @@ async fn cmd_remove(repo: String, node: String, dir: Option<PathBuf>) -> Result<
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "unstar").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("unstar failed ({status}): {msg}");
}
let count = body["star_count"].as_i64().unwrap_or(0);
println!("Unstarred {owner}/{name} ({count} stars remaining)");
@@ -123,7 +136,13 @@ async fn cmd_count(repo: String, node: String, dir: Option<PathBuf>) -> Result<(
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "star count").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("star count failed ({status}): {msg}");
}
let count = body["star_count"].as_i64().unwrap_or(0);
println!("{owner}/{name}: {count} stars");
@@ -223,7 +242,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.expect(1)
.create_async()
.await;
@@ -235,8 +253,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("star failed"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -283,7 +299,6 @@ mod tests {
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.expect(1)
.create_async()
.await;
@@ -295,8 +310,6 @@ mod tests {
.await
.unwrap_err();
assert!(err.to_string().contains("unstar failed"));
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -334,51 +347,4 @@ mod tests {
assert_eq!(owner, "alice");
assert_eq!(name, "myrepo");
}
// U15 (#186): a converted handler must inherit read_json's capped + sanitized
// error path end-to-end. A hostile 500 whose `message` carries terminal-control
// + bidi bytes and is long must reach the terminal neither verbatim nor unbounded.
#[tokio::test]
async fn cmd_add_hostile_error_is_sanitized_and_bounded() {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
// ESC + a long run + a right-to-left override, all inside the node message
// (JSON \u escapes so the source has no raw control bytes).
let hostile = format!(r#"{{"message":"a\u001b[31m{}b\u202ec"}}"#, "Z".repeat(500));
let _m = server
.mock("PUT", mockito::Matcher::Regex(r"/star$".to_string()))
.with_status(500)
.with_header("content-type", "application/json")
.with_body(hostile)
.expect(1)
.create_async()
.await;
let err = cmd_add(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err()
.to_string();
assert!(
!err.contains('\u{1b}'),
"ESC leaked to the terminal: {err:?}"
);
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
// Bounded: sanitize_node_msg caps the surfaced message, so the 500-char run
// cannot flood the error string.
assert!(err.len() < 300, "error not bounded ({} bytes)", err.len());
assert!(err.contains("star failed"), "handler label lost: {err}");
_m.assert_async().await;
}
}
+15 -166
View File
@@ -41,9 +41,17 @@ pub async fn run(args: StatusArgs) -> Result<()> {
let client = NodeClient::new(&args.node, None);
if let Some(ref did) = maybe_did {
let short_key = did.split(':').next_back().unwrap_or(did);
let resp = client.get(&format!("/api/v1/agents/{short_key}")).await;
if let Some(line) = trust_line(resp).await {
println!("{line}");
match client.get(&format!("/api/v1/agents/{short_key}")).await {
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.json::<Value>().await {
let score = body["trust_score"].as_f64().unwrap_or(0.0);
let bar = trust_bar(score);
println!(" trust {score:.2} {bar}");
}
}
_ => {
println!(" trust — not registered (run `gl register`)");
}
}
}
@@ -77,10 +85,8 @@ pub async fn run(args: StatusArgs) -> Result<()> {
let pr_resp = client
.get(&format!("/api/v1/repos/{short_owner}/{repo_name}/pulls"))
.await;
if let Some(line) = section_unavailable_line("PRs", &pr_resp) {
println!("{line}");
} else if let Ok(r) = pr_resp {
if let Ok(body) = crate::http::read_json(r, "pull requests").await {
if let Ok(r) = pr_resp {
if let Ok(body) = r.json::<Value>().await {
let prs = body["pulls"].as_array().cloned().unwrap_or_default();
let open: Vec<_> = prs
.iter()
@@ -106,10 +112,8 @@ pub async fn run(args: StatusArgs) -> Result<()> {
let issue_resp = client
.get(&format!("/api/v1/repos/{short_owner}/{repo_name}/issues"))
.await;
if let Some(line) = section_unavailable_line("issues", &issue_resp) {
println!("{line}");
} else if let Ok(r) = issue_resp {
if let Ok(body) = crate::http::read_json(r, "issues").await {
if let Ok(r) = issue_resp {
if let Ok(body) = r.json::<Value>().await {
let issues = body["issues"].as_array().cloned().unwrap_or_default();
let open: Vec<_> = issues
.iter()
@@ -136,40 +140,6 @@ pub async fn run(args: StatusArgs) -> Result<()> {
Ok(())
}
/// The status line for a repo section (PRs / issues) whose gated read returned a
/// non-2xx: the denial must surface, never render as "no open ...".
/// Returns `None` for a success (the caller renders the body) or a transport error
/// (the section degrades silently — R5 — so the multi-section `gl status` never
/// hard-fails on one denied read).
fn section_unavailable_line(label: &str, resp: &Result<reqwest::Response>) -> Option<String> {
match resp {
Ok(r) if !r.status().is_success() => {
Some(format!(" {label:<10}unavailable ({})", r.status()))
}
_ => None,
}
}
/// The `trust` status line for the caller's own identity. A genuine 404 means the
/// identity is not registered; any OTHER non-2xx (403/429/5xx) is a failed lookup
/// and must surface as unavailable rather than fabricating an unregistered state
/// A transport error degrades silently — the node-unreachable line below
/// already surfaces it — matching `section_unavailable_line`.
async fn trust_line(resp: Result<reqwest::Response>) -> Option<String> {
match resp {
Ok(r) if r.status().is_success() => {
let body = r.json::<Value>().await.ok()?;
let score = body["trust_score"].as_f64().unwrap_or(0.0);
Some(format!(" trust {score:.2} {}", trust_bar(score)))
}
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => {
Some(" trust — not registered (run `gl register`)".to_string())
}
Ok(r) => Some(format!(" trust unavailable ({})", r.status())),
Err(_) => None,
}
}
/// Render a simple ASCII trust bar: 0.75 → "███░"
fn trust_bar(score: f64) -> String {
let filled = (score * 4.0).round() as usize;
@@ -410,125 +380,4 @@ mod tests {
fn trust_bar_quarter() {
assert_eq!(trust_bar(0.25), "█░░░");
}
// ── gl status section denial surfacing (#123, R5) ────────────────────
async fn get_response(
server: &mut mockito::Server,
status: usize,
) -> Result<reqwest::Response> {
let _m = server
.mock("GET", "/x")
.with_status(status)
.with_header("content-type", "application/json")
.with_body(r#"{"pulls":[]}"#)
.create_async()
.await;
NodeClient::new(server.url(), None).get("/x").await
}
#[tokio::test]
async fn gated_section_surfaces_unavailable_not_empty() {
// A gated 404 on a status section must surface "unavailable", never be
// treated as "no open PRs".
let mut server = mockito::Server::new_async().await;
let resp = get_response(&mut server, 404).await;
assert_eq!(
section_unavailable_line("PRs", &resp),
Some(format!(" {:<10}unavailable (404 Not Found)", "PRs"))
);
}
#[tokio::test]
async fn success_section_returns_none_for_body_render() {
// A 2xx returns None so the caller renders the body as before.
let mut server = mockito::Server::new_async().await;
let resp = get_response(&mut server, 200).await;
assert!(section_unavailable_line("issues", &resp).is_none());
}
#[test]
fn transport_error_degrades_silently() {
// A transport error (not a status) must NOT surface — the section
// degrades silently so gl status never hard-fails on one bad read (R5).
let err: Result<reqwest::Response> = Err(anyhow::anyhow!("connection refused"));
assert!(section_unavailable_line("PRs", &err).is_none());
}
// ── trust_line: a 404 means unregistered; any OTHER failure must surface as
// unavailable, never fabricate an unregistered verdict. ───────────────
async fn agents_resp(
server: &mut mockito::Server,
status: usize,
body: &str,
) -> Result<reqwest::Response> {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/agents/".to_string()),
)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
NodeClient::new(server.url(), None)
.get("/api/v1/agents/zTest")
.await
}
#[tokio::test]
async fn trust_line_registered_shows_score_only() {
let mut s = mockito::Server::new_async().await;
let line = trust_line(agents_resp(&mut s, 200, r#"{"trust_score":0.5}"#).await)
.await
.unwrap();
assert!(line.contains("0.50"), "line={line}");
assert!(!line.contains("not registered"), "line={line}");
assert!(!line.contains("unavailable"), "line={line}");
}
#[tokio::test]
async fn trust_line_404_is_not_registered() {
let mut s = mockito::Server::new_async().await;
let line = trust_line(agents_resp(&mut s, 404, r#"{"message":"agent not found"}"#).await)
.await
.unwrap();
assert!(line.contains("not registered"), "line={line}");
assert!(!line.contains("unavailable"), "line={line}");
}
// Load-bearing: a 403 for an existing identity is a denied lookup, not proof it is unregistered.
#[tokio::test]
async fn trust_line_403_is_unavailable_not_registered() {
let mut s = mockito::Server::new_async().await;
let line = trust_line(agents_resp(&mut s, 403, r#"{"message":"forbidden"}"#).await)
.await
.unwrap();
assert!(
line.contains("unavailable") && line.contains("403"),
"line={line}"
);
assert!(!line.contains("not registered"), "line={line}");
}
// Load-bearing: a 5xx is a failed lookup, not an unregistered verdict.
#[tokio::test]
async fn trust_line_500_is_unavailable_not_registered() {
let mut s = mockito::Server::new_async().await;
let line = trust_line(agents_resp(&mut s, 500, r#"{"message":"boom"}"#).await)
.await
.unwrap();
assert!(line.contains("unavailable"), "line={line}");
assert!(!line.contains("not registered"), "line={line}");
}
// Load-bearing: a transport error degrades silently, never fabricates unregistered.
#[tokio::test]
async fn trust_line_transport_error_is_silent() {
let resp = NodeClient::new("http://127.0.0.1:1", None)
.get("/api/v1/agents/zTest")
.await;
assert!(trust_line(resp).await.is_none());
}
}
+2 -82
View File
@@ -75,9 +75,8 @@ pub async fn run(args: SyncArgs) -> Result<()> {
SyncCmd::Status => {
let client = NodeClient::new(&args.node, None);
// Just show peer list and node stats for now
let stats =
crate::http::read_json(client.get("/api/v1/stats").await?, "node stats").await?;
let peers = crate::http::read_json(client.get("/api/v1/peers").await?, "peers").await?;
let stats: serde_json::Value = client.get("/api/v1/stats").await?.json().await?;
let peers: serde_json::Value = client.get("/api/v1/peers").await?.json().await?;
println!("Node stats:");
println!(" repos: {}", stats["repos"].as_i64().unwrap_or(0));
println!(" agents: {}", stats["agents"].as_i64().unwrap_or(0));
@@ -163,7 +162,6 @@ mod tests {
// Valid JSON: the parse-without-status-check bug deserializes this
// into a zero-count success struct and prints "✓ sync triggered".
.with_body(r#"{"message":"unauthorized"}"#)
.expect(1)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
@@ -172,8 +170,6 @@ mod tests {
err.to_string().contains("401"),
"expected 401 surfaced, got: {err}"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -184,7 +180,6 @@ mod tests {
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"slow down"}"#)
.expect(1)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
@@ -193,8 +188,6 @@ mod tests {
err.to_string().contains("429"),
"expected 429 surfaced, got: {err}"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -211,7 +204,6 @@ mod tests {
// BEL (\u0007); serde decodes them to real control bytes a naive
// client would print. (The status-check bug fake-successes here.)
.with_body("{\"message\":\"pwned\\u001b[31m\\u0007bad\"}")
.expect(1)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
@@ -223,8 +215,6 @@ mod tests {
s.contains("pwned") && s.contains("bad"),
"message text dropped: {s:?}"
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -250,7 +240,6 @@ mod tests {
.mock("POST", "/api/v1/sync/trigger")
.with_status(401)
.with_body("A".repeat(2_000_000))
.expect(1)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
@@ -262,8 +251,6 @@ mod tests {
"error message not bounded: {} chars",
s.len()
);
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
_m.assert_async().await;
}
#[tokio::test]
@@ -294,71 +281,4 @@ mod tests {
(0, 0)
);
}
#[tokio::test]
async fn status_surfaces_stats_denial_not_fake_zeros() {
// A node error on /stats must Err, not print "0 repos / 0 agents / 0 pushes".
let mut server = mockito::Server::new_async().await;
let stats = server
.mock("GET", "/api/v1/stats")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
// A peers mock so the pre-fix path reaches a clean success rather than a
// 501 on an unmocked route; after the fix the /stats denial bails first.
let _peers = server
.mock("GET", "/api/v1/peers")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"count":0,"peers":[]}"#)
.create_async()
.await;
let args = SyncArgs {
cmd: SyncCmd::Status,
node: server.url(),
dir: None,
};
let err = run(args).await.unwrap_err();
assert!(
err.to_string().contains("500"),
"expected 500 surfaced, got: {err}"
);
stats.assert_async().await;
}
#[tokio::test]
async fn status_surfaces_peers_denial() {
// /stats succeeds but /peers denies — the peers read must Err too, not
// print "Known peers: 0".
let mut server = mockito::Server::new_async().await;
let _stats = server
.mock("GET", "/api/v1/stats")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repos":1,"agents":2,"pushes":3}"#)
.create_async()
.await;
let peers = server
.mock("GET", "/api/v1/peers")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"boom"}"#)
.expect(1)
.create_async()
.await;
let args = SyncArgs {
cmd: SyncCmd::Status,
node: server.url(),
dir: None,
};
let err = run(args).await.unwrap_err();
assert!(
err.to_string().contains("500"),
"expected 500 surfaced, got: {err}"
);
peers.assert_async().await;
}
}
+65 -1524
View File
File diff suppressed because it is too large. Load diff
-63
View File
@@ -1,63 +0,0 @@
//! Display helpers for strings that come off the wire unchecked.
/// Truncate `s` to at most `max` bytes, cutting at a char boundary.
///
/// `&s[..max]` panics when `s` is shorter than `max` bytes and when `max`
/// lands inside a multi-byte char; `s.len().min(max)` guards only the first
/// case. Node-supplied timestamps and ids hit both.
pub(crate) fn truncate(s: &str, max: usize) -> &str {
let mut end = max.min(s.len());
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
/// Keep at most the last `max` bytes of `s`, cutting at a char boundary.
///
/// `&s[s.len().saturating_sub(max)..]` panics when the start index lands
/// inside a multi-byte char; node-supplied ids hit that case.
pub(crate) fn truncate_suffix(s: &str, max: usize) -> &str {
let mut start = s.len().saturating_sub(max);
while !s.is_char_boundary(start) {
start += 1;
}
&s[start..]
}
#[cfg(test)]
mod tests {
use super::{truncate, truncate_suffix};
#[test]
fn cuts_ascii_at_max() {
assert_eq!(truncate("2026-08-15T12:34:56Z", 10), "2026-08-15");
}
#[test]
fn short_and_empty_inputs_return_whole() {
assert_eq!(truncate("2026", 10), "2026");
assert_eq!(truncate("", 10), "");
}
#[test]
fn multibyte_cut_does_not_panic() {
// Byte index 10 lands inside 'é'; the cut must land on the boundary
// before it (9 bytes) rather than panic or exceed the byte limit.
assert_eq!(truncate("2026-08-1\u{e9}5T12:34:56Z", 10), "2026-08-1");
// A string that is entirely multi-byte and shorter than max.
assert_eq!(truncate("\u{e9}\u{e9}", 10), "\u{e9}\u{e9}");
}
#[test]
fn suffix_cut_does_not_panic() {
// 'é' is two bytes; a start index of len-8 lands inside it.
assert_eq!(truncate_suffix("did:key:\u{e9}1234567", 8), "1234567");
}
#[test]
fn suffix_short_input_returns_whole() {
assert_eq!(truncate_suffix("short", 8), "short");
assert_eq!(truncate_suffix("", 8), "");
}
}
+25 -26
View File
@@ -2,6 +2,7 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::{Path, PathBuf};
use crate::http::NodeClient;
@@ -98,7 +99,12 @@ async fn resolve_owner_repo(
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
};
@@ -129,7 +135,12 @@ async fn cmd_set(
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "visibility set").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("visibility set failed ({status}): {msg}");
}
println!("✓ Visibility rule set on {owner}/{name}: {path_glob} (mode {mode})");
if path_glob != "/" {
@@ -157,7 +168,12 @@ async fn cmd_remove(
.await
.context("failed to connect to node")?;
let _ = crate::http::read_json(resp, "visibility remove").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("visibility remove failed ({status}): {msg}");
}
println!("✓ Visibility rule removed from {owner}/{name}: {path_glob}");
Ok(())
@@ -175,7 +191,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
.await
.context("failed to connect to node")?;
let body = crate::http::read_json(resp, "visibility list").await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("visibility list failed ({status}): {msg}");
}
let rules = body["rules"].as_array().cloned().unwrap_or_default();
if rules.is_empty() {
@@ -298,26 +319,4 @@ mod tests {
.await
.unwrap();
}
#[tokio::test]
async fn resolve_owner_repo_surfaces_denial() {
// A slash-free repo with an empty identity dir forces the GET / node-info
// fetch. A gated 404 there must Err (surfacing the status), proving the
// read_json conversion is load-bearing rather than silently ignored.
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
let _m = server
.mock("GET", "/")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"denied"}"#)
.expect(1)
.create_async()
.await;
let err = resolve_owner_repo("noslash", &server.url(), Some(dir.path()))
.await
.unwrap_err();
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
}
+35 -34
View File
@@ -110,17 +110,17 @@ async fn cmd_create(
"events": event_list,
}))?;
// #186: read_json status-checks before parsing and bounds the error body, so
// a denial (or a hostile oversized error) fails here instead of being parsed
// whole. `what` preserves main's "create webhook failed (...)" message prefix.
let hook = crate::http::read_json(
client
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
.await
.context("failed to connect to node")?,
"create webhook",
)
.await?;
let resp = client
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
.await
.context("failed to connect to node")?;
let status = resp.status();
let hook: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = hook["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("create webhook failed ({status}): {msg}");
}
let id = hook["id"].as_str().unwrap_or("?");
let hook_events = hook["events"]
@@ -157,15 +157,16 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
// get_signed (not get) attaches the RFC 9421 signature — plain get() never
// signs, and the node owner-gates this route, so an unsigned GET 401s.
// #186: read_json status-checks + bounds the error body; `what` preserves
// main's "list webhooks failed (...)" message prefix.
let body = crate::http::read_json(
client
.get_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
.await?,
"list webhooks",
)
.await?;
let resp = client
.get_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
.await?;
let status = resp.status();
let body: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("list webhooks failed ({status}): {msg}");
}
let hooks = body
.get("webhooks")
@@ -206,20 +207,20 @@ async fn cmd_delete(repo: String, id: String, node: String, dir: Option<PathBuf>
let client = NodeClient::new(&node, Some(keypair));
let payload = serde_json::to_vec(&serde_json::json!({}))?;
// #186: read_json status-checks + bounds the error body; `what` preserves
// main's "delete webhook failed (...)" message prefix. The success body is
// unused, so discard it.
let _ = crate::http::read_json(
client
.delete(
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
&payload,
)
.await
.context("failed to connect to node")?,
"delete webhook",
)
.await?;
let resp = client
.delete(
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
&payload,
)
.await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
if !status.is_success() {
let msg = result["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("delete webhook failed ({status}): {msg}");
}
println!("✓ Webhook {id} deleted");
Ok(())
-138
View File
@@ -1,138 +0,0 @@
//! End-to-end test for the `View:` line printed by `gl repo create` (#370).
//!
//! `cmd_create` is the load-bearing #370 path: it is the one place the CLI
//! prints a `View:` line, and the only thing keeping it from being a
//! hardcoded `https://gitlawb.com/...` 404 is `fetch_node_web_url`. The
//! helper-level tests in `repo.rs` cover the helper, not the command path —
//! if `cmd_create` went back to a hardcoded constant, every helper test
//! would still pass.
//!
//! This integration test drives the real `gl` binary against a mockito
//! server that answers both `POST /api/v1/repos` and `GET /`. Asserting on
//! the subprocess's stdout is straightforward: bytes are bytes, with no
//! libtest / gag / in-process capture race.
//!
//! CARGO_BIN_EXE_gl is set by Cargo for integration tests of a `[[bin]]`
//! in the same package, so the test always picks up the in-tree build.
use std::process::Stdio;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
fn gl_bin() -> String {
std::env::var("CARGO_BIN_EXE_gl").expect("CARGO_BIN_EXE_gl is unset for this test")
}
async fn write_identity(dir: &TempDir) {
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
let path = dir.path().join("identity.pem");
let mut f = tokio::fs::File::create(&path).await.unwrap();
f.write_all(pem.as_bytes()).await.unwrap();
}
/// The trailing key segment of the freshly-generated identity. Mirrors the
/// in-source `resolve_owner_did` test helper but lives in this integration
/// file because that helper is `pub(crate)`.
async fn owner_short(dir: &TempDir) -> String {
let pem = tokio::fs::read_to_string(dir.path().join("identity.pem"))
.await
.unwrap();
let kp = gitlawb_core::identity::Keypair::from_pem(&pem).unwrap();
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
}
async fn run_gl_create(node_url: &str, dir: &TempDir) -> String {
let output = Command::new(gl_bin())
.arg("repo")
.arg("create")
.arg("myrepo")
.arg("--private")
.arg("--branch")
.arg("main")
.arg("--node")
.arg(node_url)
.arg("--dir")
.arg(dir.path())
.env("NO_COLOR", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.expect("failed to spawn `gl repo create`");
assert!(
output.status.success(),
"`gl repo create` failed: stderr:\n{}\nstdout:\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
String::from_utf8(output.stdout).expect("`gl` wrote non-UTF-8 to stdout")
}
#[tokio::test]
async fn cmd_create_view_url_tracks_node_advertisement() {
let dir = TempDir::new().unwrap();
write_identity(&dir).await;
let owner = owner_short(&dir).await;
// Case 1: node advertises a web_url. The `View:` line must use the
// advertised origin and the resolved owner/short name.
{
let mut server = mockito::Server::new_async().await;
let _m_create = server
.mock("POST", "/api/v1/repos")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"name":"myrepo","clone_url":"gitlawb://did:key:z6Mk/myrepo"}"#)
.create_async()
.await;
let _m_info = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"web_url":"https://git.example","did":"did:key:z6Mk"}"#)
.create_async()
.await;
let server_url = server.url();
let out = run_gl_create(&server_url, &dir).await;
let view_line = out
.lines()
.find(|l| l.trim_start().starts_with("View:"))
.unwrap_or_else(|| panic!("View: line missing from stdout:\n{out}"));
assert!(
view_line.contains(&format!("https://git.example/{owner}/myrepo")),
"View: line must use the advertised origin, got: {view_line:?}"
);
}
// Case 2: node does NOT advertise a web_url. The `View:` line must be
// absent, so a self-hosted node without a web front-end never produces
// a 404 link (#370).
{
let mut server = mockito::Server::new_async().await;
let _m_create = server
.mock("POST", "/api/v1/repos")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"name":"myrepo","clone_url":"gitlawb://did:key:z6Mk/myrepo"}"#)
.create_async()
.await;
let _m_info = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"did":"did:key:z6Mk"}"#)
.create_async()
.await;
let server_url = server.url();
let out = run_gl_create(&server_url, &dir).await;
assert!(
!out.lines().any(|l| l.trim_start().starts_with("View:")),
"View: line must be absent when node omits web_url; got:\n{out}"
);
}
}
-48
View File
@@ -1,48 +0,0 @@
//! #357: pin the `gl doctor` exit-code wiring, not just the predicate.
//!
//! The unit tests for `has_failures` never touch `std::process::exit(1)` or
//! the `GITLAWB_NODE` Warn tier. Deleting the exit gate outright kept
//! `cargo test -p gl doctor::` green, so this binary probe is the only thing
//! that breaks when the wiring is gutted.
use std::process::Command;
#[test]
fn doctor_exits_nonzero_when_fail_rows_present() {
let dir = tempfile::tempdir().expect("temp dir");
// Use an unroutable node so the "node unreachable" check is Fail-class.
// The temp dir has no identity.pem / ucan.json, so identity + registration
// are also Fail — at least one Fail must flip the process to exit 1.
let bin = env!("CARGO_BIN_EXE_gl");
let output = Command::new(bin)
.args([
"doctor",
"--dir",
dir.path().to_str().unwrap(),
"--node",
"http://127.0.0.1:1",
])
// Inherit PATH but neutralize GITLAWB_NODE so the test is deterministic
// across machines that have it set (otherwise it would be Pass).
.env_remove("GITLAWB_NODE")
// iCaptcha probes a real URL by default; keep it unreachable too so
// it stays Warn and does not affect the Fail gating.
.env("GITLAWB_ICAPTCHA_URL", "http://127.0.0.1:1")
.output()
.expect("run gl doctor");
// The command should have exited 1 because at least one Fail row exists.
// If the `std::process::exit(1)` gate is removed, this becomes 0 and the
// test fails — that is the regression it pins.
assert!(
!output.status.success(),
"gl doctor must exit non-zero when Fail rows are present; stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output.status.code(),
Some(1),
"exit code must be 1, not some other non-zero"
);
}
-356
View File
@@ -1,356 +0,0 @@
//! Completeness gate for #186, itself proven load-bearing by reverting a
//! converted handler and watching this go red.
//!
//! The client handlers converted in #186 must read node responses through
//! `crate::http::read_json` — status-first, capped error read, sanitized message.
//! The bypass this fix removed is *parse-before-status*: a `resp.json().await`
//! whose result is only checked against the status AFTER parsing, which lets a
//! hostile node stream an unbounded JSON error and print its `message` unsanitized.
//!
//! This test scans the converted source files and fails if that idiom returns —
//! a `.json().await` call (not `read_json`) with an `.is_success()` check within a
//! few lines AFTER it. A status check BEFORE the parse (the `KEEP` probes in
//! repo.rs) reads as status-first and is not flagged. If a converted site is
//! reverted to the bypass, this goes RED.
//!
//! The gate does not hand-list the files to scan; it DERIVES them: every
//! `src/*.rs` that references `read_json` (except `http.rs`, where it is defined)
//! is a converted handler and is scanned for the bypass idiom.
//!
//! Derivation alone has a blind spot: it keys on the very `read_json` marker a
//! full revert deletes. A converted handler with a single node call (register.rs)
//! reverted to `resp.json().await` loses `read_json` entirely, drops out of the
//! derived set, and its bypass goes unscanned. So `CONVERTED_IN_186` is the
//! authoritative required set and the gate asserts the derived surface EQUALS it,
//! failing closed in both directions:
//!
//! - a pinned file missing from the derived set was reverted off `read_json`
//! (the blind-spot escape), so the gate goes RED;
//! - a file that uses `read_json` but is not pinned is a conversion that was
//! never enrolled, so its own later revert would escape unseen; RED until it
//! is added to the pin.
//!
//! Equality is what extends the protection to handlers converted after #186, not
//! just the original sixteen: a conversion cannot ship unpinned, and a pinned
//! surface cannot be deconverted silently.
//!
//! Pre-existing bypasses in files this PR did not convert (e.g. init.rs,
//! mirror.rs, profile.rs) are known debt, tracked separately and out of scope.
//! They do not use `read_json`, so the derivation excludes them and they are not
//! pinned.
use std::path::Path;
/// Files that define/host `read_json` rather than consume it as a converted
/// handler. Excluded from the scanned set even though they reference the symbol.
const NOT_A_HANDLER: &[&str] = &["http.rs"];
/// The authoritative converted-handler surface: every non-`http.rs` file whose
/// node-response reads route through `read_json`. The gate asserts the derived
/// `read_json` set EQUALS this exactly, so a new conversion must be added here
/// (the gate is RED until it is) and a pinned surface cannot be reverted off
/// `read_json` without tripping the gate. Both directions fail closed, which is
/// what protects post-#186 conversions, not only the original sixteen.
const CONVERTED_IN_186: &[&str] = &[
"agent.rs",
"bounty.rs",
"cert.rs",
"changelog.rs",
"issue.rs",
"mcp.rs",
"peer.rs",
"pr.rs",
"protect.rs",
"register.rs",
"repo.rs",
"star.rs",
"status.rs",
"sync.rs",
"task.rs",
"visibility.rs",
"webhook.rs",
];
/// Derive the converted-handler surface: every `src/*.rs` that references
/// `read_json`, minus the definition site(s). This is the set the equality check
/// compares against `CONVERTED_IN_186`; a newly-converted handler surfaces here
/// and must then be pinned (the gate is RED until it is).
fn scanned_handlers(src: &Path) -> Vec<(String, String)> {
let mut handlers = Vec::new();
for entry in std::fs::read_dir(src).expect("read src dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.expect("file name")
.to_string();
if NOT_A_HANDLER.contains(&file_name.as_str()) {
continue;
}
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
if has_read_json_call_site(&text) {
handlers.push((file_name, text));
}
}
handlers.sort();
handlers
}
/// Does this file actually CALL `read_json`, as opposed to merely mentioning it?
///
/// Membership in the derived set must key on a call site, never on the text of
/// the file. A textual `contains("read_json")` matched prose too, so a handler
/// fully reverted off `read_json` kept its membership on the strength of a
/// leftover comment ("previously routed through read_json"). Still-derived means
/// the pinned-vs-derived equality below never fired, and the reverted file went
/// on being scanned as though it were converted, so the deconversion — which
/// drops the cap and the sanitizer — shipped green. Requiring the trailing `(`
/// on a non-comment line also drops test names and doc references.
fn has_read_json_call_site(text: &str) -> bool {
text.lines().any(|l| {
let t = l.trim_start();
!t.starts_with("//") && t.contains("read_json(")
})
}
/// How far above a parse site to look for its status check.
///
/// The widest legitimate gap in the tree is `sync.rs`'s hand-rolled status-first
/// `Trigger` arm, whose `.status()` sits 19 lines above its parse; 24 leaves a
/// little headroom. Wider is weaker, so this should shrink if that arm is ever
/// routed through `read_json` like its siblings.
const STATUS_LOOKBACK: usize = 24;
/// Does `line` open a JSON parse — `.json().await`, or a turbofish
/// `.json::<T>().await`, or the head of a split-line chain (`.json(` /
/// `.json::<T>(` whose `()` / `.await` continue on following lines)?
///
/// Lines routed through `read_json` are never parse sites (that IS the fix).
fn opens_json_parse(line: &str) -> bool {
if line.contains("read_json") {
return false;
}
// A `.json(` token starts every reqwest JSON parse, bare or turbofished,
// single- or split-line. We anchor on it and let the window below confirm
// the `.await` / status check; anchoring on `.json(` alone is what catches
// `resp.json::<Value>().await` and `resp\n .json()\n .await` chains
// that the old bare-`.json().await` substring missed.
line.contains(".json()") || line.contains(".json::<")
}
/// Within `window` (the parse-site line joined with the few lines after it), is
/// the parse actually completed with `.await` and then checked against the
/// status? A completed parse whose `.is_success()` lands AFTER it is the
/// parse-before-status bypass. A status check BEFORE the parse (KEEP probes)
/// never appears in this after-the-parse window, so it is not flagged.
fn window_is_bypass(window: &str) -> bool {
// The parse must actually resolve — guards against matching a `.json`-shaped
// token that never awaits. Split-line chains put `.await` a line or two down,
// which the joined window still contains.
let completes = window.contains(".await");
completes && window.contains(".is_success()")
}
/// Is there a FULL non-success guard ABOVE this parse site — one that exits for
/// every non-2xx status, not merely a bespoke branch for one code?
///
/// `window_is_bypass` only recognises a status check that lands AFTER the parse,
/// so it describes one specific bypass and says nothing about a parse with no
/// status check anywhere. That shape is the more dangerous revert (it renders a
/// denial body as a successful result with no check at all). Requiring positive
/// evidence of a guard first turns the rule from "detect one bad ordering" into
/// "require the good ordering".
///
/// The evidence must cover the whole non-success range. "Any `.status()`
/// nearby" is not enough: agent.rs legitimately keeps a 404-only hint
/// (`resp.status() == StatusCode::NOT_FOUND`) ahead of its `read_json` call,
/// and a revert that swaps that call for `resp.json().await?` would still see
/// the 404 check in the lookback — while a 500's JSON denial body renders as an
/// empty agent list again. A single-status equality (or a `matches!(…, 404 |
/// 501)` enumeration) guards its own arm and nothing else, so it does not
/// count. What counts:
///
/// - `.is_success()` — the canonical spelling; sync.rs binds the status to a
/// variable first, so this token is matched on its own rather than as a
/// `.status()` suffix;
/// - a RANGE comparison over `as_u16()` (`>= 400` and friends), which reads the
/// whole class rather than enumerating codes.
///
/// The `special_404_hint_is_not_a_guard` mutation test below keeps this
/// distinction honest against the real agent.rs source.
fn has_full_status_guard_above(lookback: &str) -> bool {
if lookback.contains(".is_success()") {
return true;
}
lookback.contains("as_u16() >=")
|| lookback.contains("as_u16() >")
|| lookback.contains("as_u16() <=")
|| lookback.contains("as_u16() <")
}
/// The scan shared by the gate and the mutation regression: every raw parse
/// site in `text` is classified as an after-the-parse bypass (`offenders`) or a
/// parse with no full status guard above it (`unguarded`). Factored out so the
/// mutation test exercises EXACTLY the logic the gate runs, not a paraphrase of
/// it.
fn scan_handler(name: &str, text: &str) -> (Vec<String>, Vec<String>) {
let mut offenders = Vec::new();
let mut unguarded = Vec::new();
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
if !opens_json_parse(line) {
continue;
}
// Join the parse-site line with the following lines so a split-line
// chain's `.await` and an after-the-parse `.is_success()` are both in
// view. Status-first probes put `.is_success()` on an EARLIER line, so
// it is outside this window and stays green.
let window = lines[i..(i + 6).min(lines.len())].join("\n");
if window_is_bypass(&window) {
offenders.push(format!("{name}:{}", i + 1));
continue;
}
// A parse that never resolves is not a read; only a completed parse
// can render a denial body as a result.
if !window.contains(".await") {
continue;
}
let lookback = lines[i.saturating_sub(STATUS_LOOKBACK)..i].join("\n");
if !has_full_status_guard_above(&lookback) {
unguarded.push(format!("{name}:{}", i + 1));
}
}
(offenders, unguarded)
}
#[test]
fn converted_handlers_never_parse_before_status() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let handlers = scanned_handlers(&src);
// Deriving the set is the whole point of the fix; if the tree ever stops
// using `read_json` the gate would silently pass on an empty set.
assert!(
!handlers.is_empty(),
"derived no converted handlers from src/*.rs read_json usage — the gate \
would vacuously pass; check the derivation"
);
// The pin is the authoritative required set: the derived `read_json` surface
// must EQUAL it, failing closed in both directions. A pinned file that drops
// out was reverted off `read_json` (register.rs, its single call, is the
// motivating case) and its bypass would escape the derived scan. A derived
// file that is not pinned is a conversion nobody enrolled, so ITS later revert
// would escape the same way; force it into the pin now. Equality is what
// extends the protection to handlers converted after #186.
let derived: Vec<&str> = handlers.iter().map(|(n, _)| n.as_str()).collect();
let deconverted: Vec<&str> = CONVERTED_IN_186
.iter()
.copied()
.filter(|f| !derived.contains(f))
.collect();
assert!(
deconverted.is_empty(),
"pinned handler(s) no longer route node responses through read_json: a \
parse-before-status revert would otherwise escape the derived scan. \
Re-route through crate::http::read_json (or drop from CONVERTED_IN_186 if \
the surface was intentionally deconverted): {deconverted:?}"
);
let unpinned: Vec<&str> = derived
.iter()
.copied()
.filter(|f| !CONVERTED_IN_186.contains(f))
.collect();
assert!(
unpinned.is_empty(),
"handler(s) use read_json but are absent from CONVERTED_IN_186: add them so \
the gate protects them against a later parse-before-status revert (an \
unpinned conversion drops out of both the scan and this check when \
reverted): {unpinned:?}"
);
let mut offenders = Vec::new();
let mut unguarded = Vec::new();
for (name, text) in &handlers {
let (o, u) = scan_handler(name, text);
offenders.extend(o);
unguarded.extend(u);
}
assert!(
offenders.is_empty(),
"parse-before-status bypass present in converted handler(s) — route the read \
through crate::http::read_json (status-first, capped, sanitized): {offenders:?}"
);
assert!(
unguarded.is_empty(),
"node response parsed with NO full status guard above it in a converted \
handler — the denial body is being rendered as a result. Route the read \
through crate::http::read_json (status-first, capped, sanitized): {unguarded:?}"
);
}
/// Mutation regression for the special-404 escape: agent.rs (and the agent-show
/// / repo-info paths shaped like it) keeps a legitimate 404-only hint ahead of
/// its `read_json` call. Revert that call to a raw `resp.json().await?` and the
/// 404 equality is the only status evidence in the lookback — under the old
/// "any `.status()` nearby" rule the fence stayed green while a 500's denial
/// body rendered as an empty agent list. This test performs that exact revert
/// on the REAL agent.rs source and requires the scan to flag the mutant, so the
/// full-guard distinction in `has_full_status_guard_above` cannot regress to a
/// mere proximity check without going red here.
#[test]
fn special_404_hint_is_not_a_guard() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let text = std::fs::read_to_string(src.join("agent.rs")).expect("read agent.rs");
// The mutation must stay real: it requires the idiom it reverts. If
// agent.rs drops the 404 hint or the read_json call, this needs re-basing
// on whichever handler carries the special-404 idiom then.
assert!(
text.contains("StatusCode::NOT_FOUND"),
"agent.rs no longer carries the special-404 hint this mutation reverts; \
re-anchor the mutation on the handler that does"
);
let lines: Vec<&str> = text.lines().collect();
let not_found_line = lines
.iter()
.position(|l| l.contains("StatusCode::NOT_FOUND"))
.unwrap();
let read_json_line = lines[not_found_line..]
.iter()
.position(|l| l.contains("read_json"))
.map(|off| not_found_line + off)
.expect(
"agent.rs has no read_json call after its 404 hint; re-anchor the \
mutation on the handler that carries the idiom",
);
// The revert under test: the guarded helper call becomes a raw parse.
let mut mutant: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
mutant[read_json_line] = " let body: serde_json::Value = resp.json().await?;".to_string();
let mutant = mutant.join("\n");
let (_, unguarded) = scan_handler("agent.rs[mutant]", &mutant);
assert!(
unguarded
.iter()
.any(|hit| hit.starts_with("agent.rs[mutant]:")),
"the special-404 revert was not flagged: a 404-only equality upstream is \
being accepted as a full status guard, which reopens the escape this \
mutation pins (expected an unguarded hit, got {unguarded:?})"
);
// Positive control: the unmutated file must stay green, or the fence is
// flagging the legitimate hint rather than the revert.
let (offenders, unguarded) = scan_handler("agent.rs", &text);
assert!(
offenders.is_empty() && unguarded.is_empty(),
"unmutated agent.rs trips the fence; the guard classifier is wrong, not \
the handler: offenders={offenders:?} unguarded={unguarded:?}"
);
}
+25 -284
View File
@@ -88,81 +88,20 @@ fn sanitize_excerpt(s: &str) -> String {
out
}
/// Fixed warning when `GITLAWB_ICAPTCHA_INSECURE` is present but not an
/// explicit truthy value. Does not include the raw env value (arbitrary
/// deployment input — secrets / terminal controls).
fn warn_icaptcha_insecure_non_truthy() {
// One literal line on purpose: a continuation escape here keeps emitting
// correct prose (Rust strips the next line's leading whitespace), but the
// single-line form makes that property visible without knowing the escape
// semantics, which is what the review asked for.
tracing::warn!(
"GITLAWB_ICAPTCHA_INSECURE is set but not truthy (expected 1 or true); loopback HTTP trust relaxation remains disabled"
);
}
/// Parse a possibly-present `GITLAWB_ICAPTCHA_INSECURE` string.
/// Whether `u` parses as a trusted URL.
///
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
/// enables the loopback-HTTP trust relaxation. Presence alone is not enough:
/// `=0`, `=false`, or an empty value leave it off. A non-empty non-truthy
/// value emits [`warn_icaptcha_insecure_non_truthy`].
fn icaptcha_insecure_from_str(raw: Option<&str>) -> bool {
let Some(v) = raw else {
return false;
};
let t = v.trim();
if t.eq_ignore_ascii_case("1") || t.eq_ignore_ascii_case("true") {
true
} else {
if !t.is_empty() {
warn_icaptcha_insecure_non_truthy();
}
false
}
}
/// Interpret `std::env::var("GITLAWB_ICAPTCHA_INSECURE")` without reading the
/// environment again — used by production and by unit tests.
///
/// `NotPresent` → disabled. `NotUnicode` → disabled + the same fixed warning
/// as a present non-truthy value (fail closed; do not treat as unset).
fn icaptcha_insecure_from_var(result: Result<String, std::env::VarError>) -> bool {
match result {
Ok(v) => icaptcha_insecure_from_str(Some(&v)),
Err(std::env::VarError::NotUnicode(_)) => {
warn_icaptcha_insecure_non_truthy();
false
}
Err(std::env::VarError::NotPresent) => false,
}
}
/// Whether `GITLAWB_ICAPTCHA_INSECURE` explicitly enables the loopback-HTTP
/// trust relaxation used by integration tests.
///
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
/// enables it. Presence alone is not enough: `=0`, `=false`, or an empty
/// value leave the relaxation off. A non-empty non-truthy value (including a
/// present non-Unicode value) emits a `tracing::warn!` (visible when the
/// `icaptcha_client` target is at WARN+, e.g. `RUST_LOG=icaptcha_client=warn`).
fn icaptcha_insecure_enabled() -> bool {
icaptcha_insecure_from_var(std::env::var("GITLAWB_ICAPTCHA_INSECURE"))
}
/// Whether `u` parses as a trusted URL, given a pre-parsed insecure flag.
///
/// Production trusts only `https`. When `insecure` is true the function also
/// trusts `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry
/// path can be exercised against a local mockito server.
fn is_https_with(u: &str, insecure: bool) -> bool {
/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a
/// runtime escape hatch for integration tests) the function also trusts
/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path
/// can be exercised against a local mockito server.
fn is_https(u: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(u) else {
return false;
};
if parsed.scheme() == "https" {
return true;
}
if insecure
if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
&& parsed.scheme() == "http"
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
{
@@ -200,15 +139,7 @@ fn origin_key(u: &str) -> Option<String> {
/// explicitly-configured host (never a node-discovered one), so a key is never
/// exfiltrated to an origin the operator did not choose.
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
resolve_solver_url_with(advertised, operator, icaptcha_insecure_enabled())
}
fn resolve_solver_url_with(
advertised: Option<&str>,
operator: Option<&str>,
insecure: bool,
) -> (String, bool) {
let operator = operator.filter(|u| is_https_with(u, insecure));
let operator = operator.filter(|u| is_https(u));
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
let default_origin = origin_key(DEFAULT_URL);
@@ -226,7 +157,7 @@ fn resolve_solver_url_with(
};
let chosen = match advertised {
Some(a) if is_https_with(a, insecure) && allowed(&origin_key(a)) => a.to_string(),
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
Some(a) => {
tracing::warn!(
advertised = %a,
@@ -424,13 +355,7 @@ fn submit_answer(
/// Returns `None` when stdin isn't a usable interactive source (e.g. an agent),
/// so the caller surfaces a clear "couldn't auto-solve" error instead.
fn interactive_prompt(challenge: &Challenge) -> Option<String> {
use std::io::{stderr, stdin, IsTerminal, Write};
// An open but silent stdin (a pipe an orchestrator holds open, an agent
// harness) has no one to answer: `read_line` would block forever. Only a
// terminal can be interactive.
if !stdin().is_terminal() {
return None;
}
use std::io::{stderr, stdin, Write};
let mut err = stderr();
let _ = writeln!(
err,
@@ -458,36 +383,27 @@ mod tests {
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes the real-env reader test so it never races with other
/// process-global env mutations (and recovers from a poisoned lock).
/// Serializes tests that touch the process-global
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
/// prior value on drop.
struct InsecureEnv {
_lock: MutexGuard<'static, ()>,
prev: Option<OsString>,
}
impl EnvGuard {
fn lock() -> MutexGuard<'static, ()> {
ICAPTCHA_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn with_value(val: &str) -> Self {
let lock = Self::lock();
impl InsecureEnv {
fn new() -> Self {
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", val);
EnvGuard { _lock: lock, prev }
}
fn unset() -> Self {
let lock = Self::lock();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE");
EnvGuard { _lock: lock, prev }
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
InsecureEnv { _lock: lock, prev }
}
}
impl Drop for EnvGuard {
impl Drop for InsecureEnv {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
@@ -496,118 +412,6 @@ mod tests {
}
}
/// #227 / #246: cover the production env read in `icaptcha_insecure_enabled`.
/// Pure-helper tests alone leave mutations of this function green.
#[test]
fn icaptcha_insecure_enabled_reads_real_env() {
{
let _g = EnvGuard::unset();
assert!(!icaptcha_insecure_enabled(), "unset must disable");
}
{
let _g = EnvGuard::with_value("0");
assert!(!icaptcha_insecure_enabled(), "=0 must disable");
}
{
let _g = EnvGuard::with_value("false");
assert!(!icaptcha_insecure_enabled(), "=false must disable");
}
{
let _g = EnvGuard::with_value("");
assert!(!icaptcha_insecure_enabled(), "empty must disable");
}
{
let _g = EnvGuard::with_value("1");
assert!(icaptcha_insecure_enabled(), "=1 must enable");
}
{
let _g = EnvGuard::with_value("true");
assert!(icaptcha_insecure_enabled(), "=true must enable");
}
{
let _g = EnvGuard::with_value("TRUE");
assert!(icaptcha_insecure_enabled(), "=TRUE must enable");
}
{
let _g = EnvGuard::with_value(" 1 ");
assert!(icaptcha_insecure_enabled(), "\" 1 \" (trimmed) must enable");
}
}
/// #227: `=0` / `=false` / empty / unset must NOT enable loopback HTTP;
/// only explicit truthy `1` / `true` may. Uses pure helpers so tests do
/// not mutate process-global environment (avoids races with parallel
/// resolver tests that also call `resolve_solver_url`).
#[test]
fn insecure_env_only_truthy_enables_loopback_http() {
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("0"))),
"=0 must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("0")));
assert!(
!is_https_with(
"http://127.0.0.1",
icaptcha_insecure_from_str(Some("false"))
),
"=false must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("false")));
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(""))),
"empty value must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("")));
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(None)),
"unset must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(None));
assert!(!icaptcha_insecure_from_var(Err(
std::env::VarError::NotPresent
)));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("1"))),
"=1 must enable loopback HTTP"
);
assert!(is_https_with(
"http://localhost",
icaptcha_insecure_from_str(Some("1"))
));
assert!(icaptcha_insecure_from_str(Some("1")));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("true"))),
"=true must enable loopback HTTP"
);
assert!(icaptcha_insecure_from_str(Some("true")));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("TRUE"))),
"=TRUE (case-insensitive) must enable loopback HTTP"
);
// Trim is load-bearing: whitespace around a truthy token must still enable.
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(" 1 "))),
"\" 1 \" (trimmed) must enable loopback HTTP"
);
assert!(icaptcha_insecure_from_str(Some(" 1 ")));
// Present non-Unicode: fail closed + same fixed warning path as non-truthy.
assert!(!icaptcha_insecure_from_var(Err(
std::env::VarError::NotUnicode(OsString::from("x"))
)));
// https remains trusted regardless of the insecure flag.
assert!(is_https_with("https://icaptcha.gitlawb.com", false));
assert!(is_https_with("https://icaptcha.gitlawb.com", true));
}
// ── P1: hostile error bodies must not reach the terminal raw ──────────
#[test]
@@ -733,12 +537,13 @@ mod tests {
#[test]
fn rejects_insecure_advertised_url_on_different_port() {
// With insecure mode enabled, two loopback URLs on different
// With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different
// ports must be treated as distinct origins so a hostile node cannot
// redirect the bearer key to a different listener on localhost.
let _env = InsecureEnv::new();
let op = "http://localhost:3000";
let (url, key_trusted) =
resolve_solver_url_with(Some("http://localhost:9000"), Some(op), true);
let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op));
assert_eq!(
url, op,
"must fall back to operator origin, not use different port"
@@ -956,68 +761,4 @@ mod tests {
);
assert_eq!(non_empty.api_key.as_deref(), Some("secret-bearer"));
}
/// #345: an open-but-silent non-TTY stdin must not hang the prompt. The
/// child runs `interactive_prompt` with a held-open, empty pipe as stdin:
/// without the terminal check `read_line` blocks forever; with it the
/// child returns `None` and exits.
#[test]
fn interactive_prompt_skips_open_silent_pipe() {
const CHILD_ENV: &str = "ICAPTCHA_PROMPT_PIPE_CHILD";
if std::env::var_os(CHILD_ENV).is_some() {
let ch = Challenge {
challenge_id: "c".into(),
kind: "arithmetic".into(),
difficulty: 1,
prompt: "What is 1 + 1?".into(),
token: "t".into(),
pow: None,
};
assert!(interactive_prompt(&ch).is_none());
println!("prompt-skipped");
return;
}
let exe = std::env::current_exe().expect("current test binary");
let mut child = std::process::Command::new(exe)
.args([
"--exact",
"tests::interactive_prompt_skips_open_silent_pipe",
"--nocapture",
])
.env(CHILD_ENV, "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn child test");
// The parent keeps the write end open with no data, which is exactly
// the shape that hung: an open, silent, non-terminal stdin.
let _held_stdin = child.stdin.take();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match child.try_wait().expect("poll child") {
Some(status) => {
assert!(status.success(), "child exited with {status}");
let mut out = String::new();
std::io::Read::read_to_string(
&mut child.stdout.take().expect("child stdout"),
&mut out,
)
.expect("read child stdout");
assert!(
out.contains("prompt-skipped"),
"child test body did not run: {out}"
);
return;
}
None if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
None => {
let _ = child.kill();
panic!("interactive_prompt blocked on an open, silent non-TTY stdin");
}
}
}
}
}
+28 -111
View File
@@ -30,8 +30,8 @@ fn solve_arithmetic(prompt: &str) -> Option<i64> {
while let Some(op) = tokens.next() {
let n: i64 = tokens.next()?.parse().ok()?;
match op {
"+" => acc = acc.checked_add(n)?,
"-" => acc = acc.checked_sub(n)?,
"+" => acc += n,
"-" => acc -= n,
_ => return None,
}
}
@@ -45,15 +45,15 @@ fn solve_algebra(prompt: &str) -> Option<i64> {
let (lhs, rhs) = eq.split_once('=')?;
let (al, cl) = parse_linear(lhs.trim())?;
let (ar, cr) = parse_linear(rhs.trim())?;
let denom = al.checked_sub(ar)?;
let denom = al - ar;
if denom == 0 {
return None;
}
let num = cr.checked_sub(cl)?;
if num.checked_rem(denom)? != 0 {
let num = cr - cl;
if num % denom != 0 {
return None;
}
num.checked_div(denom)
Some(num / denom)
}
/// Parse a linear expression in `x` into `(coeff_of_x, constant)`.
@@ -76,12 +76,12 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> {
let m: i64 = it.next()?.parse().ok()?;
match op {
"+" => (1, m),
"-" => (1, m.checked_neg()?),
"-" => (1, -m),
_ => return None,
}
}
};
return Some((a.checked_mul(coeff_inner)?, a.checked_mul(const_inner)?));
return Some((a * coeff_inner, a * const_inner));
}
// Sum of `±`-separated terms.
@@ -99,10 +99,10 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> {
"-" => -1,
_ => cpart.parse().ok()?,
};
coeff = coeff.checked_add(sign.checked_mul(c)?)?;
coeff += sign * c;
} else {
let n: i64 = t.parse().ok()?;
konst = konst.checked_add(sign.checked_mul(n)?)?;
konst += sign * n;
}
sign = 1;
}
@@ -127,26 +127,23 @@ fn solve_sequence(prompt: &str) -> Option<i64> {
fn next_in_sequence(n: &[i64]) -> Option<i64> {
let last = *n.last()?;
// Arithmetic: constant first difference. An overflowing difference means
// the pattern doesn't fit, not that the answer wraps.
if let Some(d) = n[1].checked_sub(n[0]) {
if n.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(d)) {
return last.checked_add(d);
}
// Arithmetic: constant first difference.
let d = n[1] - n[0];
if n.windows(2).all(|w| w[1] - w[0] == d) {
return Some(last + d);
}
// Geometric: constant integer ratio.
if n.iter().all(|&v| v != 0) && n[1].checked_rem(n[0]) == Some(0) {
if let Some(r) = n[1].checked_div(n[0]) {
if r != 0 && n.windows(2).all(|w| w[0].checked_mul(r) == Some(w[1])) {
return last.checked_mul(r);
}
if n.iter().all(|&v| v != 0) && n[0] != 0 && n[1] % n[0] == 0 {
let r = n[1] / n[0];
if r != 0 && n.windows(2).all(|w| w[1] == w[0] * r) {
return Some(last * r);
}
}
// Fibonacci-like: each term is the sum of the two before it.
if n.len() >= 3 && (2..n.len()).all(|i| n[i - 1].checked_add(n[i - 2]) == Some(n[i])) {
return n[n.len() - 1].checked_add(n[n.len() - 2]);
if n.len() >= 3 && (2..n.len()).all(|i| n[i] == n[i - 1] + n[i - 2]) {
return Some(n[n.len() - 1] + n[n.len() - 2]);
}
// Squares: all perfect squares with consecutive roots.
@@ -154,7 +151,7 @@ fn next_in_sequence(n: &[i64]) -> Option<i64> {
if let Some(roots) = roots {
if roots.windows(2).all(|w| w[1] == w[0] + 1) {
let nr = roots[roots.len() - 1] + 1;
return nr.checked_mul(nr);
return Some(nr * nr);
}
}
@@ -163,16 +160,12 @@ fn next_in_sequence(n: &[i64]) -> Option<i64> {
.iter()
.enumerate()
.all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 });
let mags: Option<Vec<i64>> = n.iter().map(|v| v.checked_abs()).collect();
if signs_alternate {
if let Some(mags) = mags {
if let Some(md) = mags[1].checked_sub(mags[0]) {
if mags.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(md)) {
let next_sign: i64 = if last >= 0 { -1 } else { 1 };
return next_sign.checked_mul(mags[mags.len() - 1].checked_add(md)?);
}
}
}
let mags: Vec<i64> = n.iter().map(|v| v.abs()).collect();
let md = mags[1] - mags[0];
if signs_alternate && mags.windows(2).all(|w| w[1] - w[0] == md) {
let next_mag = mags[mags.len() - 1] + md;
let next_sign = if last >= 0 { -1 } else { 1 };
return Some(next_sign * next_mag);
}
None
@@ -186,7 +179,7 @@ fn isqrt_exact(v: i64) -> Option<i64> {
let r = (v as f64).sqrt().round() as i64;
[r - 1, r, r + 1]
.into_iter()
.find(|&cand| cand >= 0 && cand.checked_mul(cand) == Some(v))
.find(|&cand| cand >= 0 && cand * cand == v)
}
#[cfg(test)]
@@ -274,80 +267,4 @@ mod tests {
assert_eq!(solve("anagram", "Unscramble: tca"), None);
assert_eq!(solve("riddle", "What has keys but no locks?"), None);
}
// #345: prompts are attacker/service-controlled and every literal can be a
// valid i64 while the evaluation still overflows. Overflow must return
// None (unsolvable), never a debug panic or a wrapped wrong answer.
#[test]
fn arithmetic_overflow_returns_none() {
assert_eq!(
solve("arithmetic", "What is 9223372036854775807 + 1?"),
None
);
assert_eq!(
solve("arithmetic", "What is -9223372036854775808 - 1?"),
None
);
// Boundary-adjacent values still solve.
assert_eq!(
solve("arithmetic", "What is 9223372036854775806 + 1?").as_deref(),
Some("9223372036854775807")
);
}
#[test]
fn algebra_overflow_returns_none() {
// num = i64::MIN, denom = -1: the quotient overflows.
assert_eq!(
solve("algebra", "Solve for x: x + 1 = 2x + -9223372036854775807"),
None
);
// sign * coefficient overflows at i64::MIN.
assert_eq!(
solve("algebra", "Solve for x: x - -9223372036854775808x = 1"),
None
);
// A large but solvable equation still solves.
assert_eq!(
solve("algebra", "Solve for x: 4611686018427387904x + 0 = 0").as_deref(),
Some("0")
);
}
#[test]
fn sequence_overflow_returns_none() {
// First difference overflows (1 - i64::MIN).
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? -9223372036854775808, 1, 9223372036854775806, ?"
),
None
);
// i64::MIN in an alternating-sign candidate: abs() overflows.
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 1, -9223372036854775808, 3, ?"
),
None
);
// Perfect-square check near i64::MAX: candidate root squared overflows.
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 9223372036854775807, 9223372036854775800, 9223372036854775801, ?"
),
None
);
// Geometric next term overflows (r = 2, last * 2 > i64::MAX).
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 2305843009213693951, 4611686018427387902, 9223372036854775804, ?"
),
None
);
}
}
@@ -1,134 +0,0 @@
//! #345 end-to-end: `obtain_proof` must not block or crash when stdin is an
//! open, silent non-TTY and the challenge can't be solved deterministically.
//!
//! The unit seam can't fake a process stdin, so the parent spawns this test
//! binary with a held-open pipe as stdin and the child runs the real
//! `obtain_proof` against a mocked iCaptcha service over HTTP. Before the fix
//! the child blocked in `read_line` forever (unsolvable type) or panicked /
//! submitted a wrapped answer (overflowing arithmetic). After the fix it
//! exits promptly with the "cannot solve" error.
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use icaptcha_client::{obtain_proof, IcaptchaCfg};
const CHILD_ENV: &str = "ICAPTCHA_STDIN_E2E_CHILD";
const URL_ENV: &str = "ICAPTCHA_STDIN_E2E_URL";
const CHILD_TIMEOUT: Duration = Duration::from_secs(15);
/// Child entry point: fetch the challenge from the mock service and run the
/// full solve loop. Both scenarios must reach the same clean error.
fn run_child() {
let url = std::env::var(URL_ENV).expect("child needs the mock url");
let cfg = IcaptchaCfg {
url,
did: "did:key:zTEST".to_string(),
level: 1,
api_key: None,
};
let err = obtain_proof(&cfg, None).expect_err("the challenge is unsolvable");
assert!(
err.to_string().contains("cannot solve iCaptcha challenge"),
"expected the unsolvable-challenge error, got: {err}"
);
println!("child-finished-cleanly");
}
/// Spawn the child with a held-open, empty pipe as stdin, wait with a
/// deadline, and assert it printed the clean-exit marker.
fn run_scenario(prompt_type: &str, prompt: &str, expect_answer_calls: usize) {
let mut server = mockito::Server::new();
let challenge = server
.mock("POST", "/v1/challenge")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"challengeId":"c1","type":"{prompt_type}","difficulty":1,"prompt":"{prompt}","token":"tok-1"}}"#
))
.expect(1)
.create();
// No answer may be submitted: an unsolvable prompt must produce no
// request to /v1/answer (a wrapped wrong answer would still hit it).
let answer = server
.mock("POST", "/v1/answer")
.expect(expect_answer_calls)
.create();
let exe = std::env::current_exe().expect("current test binary");
let mut cmd = Command::new(exe);
cmd.args([
"--exact",
"obtain_proof_does_not_block_on_silent_non_tty_stdin",
"--nocapture",
])
.env(CHILD_ENV, "1")
.env(URL_ENV, server.url())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// The solver client honors proxy env; force direct connections so the
// loopback mock is always reachable regardless of the runner's env.
for var in [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"REQUEST_METHOD",
] {
cmd.env_remove(var);
}
cmd.env("NO_PROXY", "*");
let mut child = cmd.spawn().expect("spawn child");
// Hold the write end open with no data: exactly the shape that hung.
let _held_stdin = child.stdin.take();
let deadline = Instant::now() + CHILD_TIMEOUT;
loop {
match child.try_wait().expect("poll child") {
Some(_) => break,
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
None => {
let _ = child.kill();
panic!("obtain_proof blocked on an open, silent non-TTY stdin");
}
}
}
let out = child.wait_with_output().expect("collect child output");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"child failed: status {:?}\nstdout: {stdout}\nstderr: {stderr}",
out.status.code()
);
assert!(
stdout.contains("child-finished-cleanly"),
"child body did not run to completion: {stdout}"
);
assert!(
!stderr.contains("panicked"),
"child panicked instead of returning a clean error: {stderr}"
);
challenge.assert();
answer.assert();
}
#[test]
fn obtain_proof_does_not_block_on_silent_non_tty_stdin() {
if std::env::var_os(CHILD_ENV).is_some() {
run_child();
return;
}
// An unsolvable challenge type falls through to the interactive prompt,
// which must decline the non-TTY stdin instead of blocking on read_line.
run_scenario("anagram", "listen", 0);
// An arithmetic prompt whose evaluation overflows i64 must also end at
// the unsolvable error: no panic, and no wrapped answer submitted.
run_scenario("arithmetic", "What is 9223372036854775807 + 1?", 0);
}
+1 -1
View File
@@ -41,7 +41,7 @@ Owner focus: protocol/auth.
- Close default-open repo write authorization and wire trusted UCAN delegation into repository permissions.
- Close the remaining visibility gaps: restrict or document task, pin, and anchor metadata routes, and publish an irreversible-publication policy.
- Add UCAN revocation or blocklisting, with an emergency compromised-key runbook.
- GraphQL mutations require a verified signer (shipped, #87). Keep the surface limited to the agent-task queue; do not expose an owner-gated repo-write mutation over GraphQL without its owner-gate and REST parity. The `every_graphql_mutation_has_its_gate` fence forces any new mutation to be classified in its table (discovered from the schema SDL, with an introspection cross-check for hidden fields, so none can slip in unlisted) and requires `require_signer` plus actor binding in each registered resolver; the owner-gate and REST parity themselves stay the INV-1 review check, not something the fence proves.
- Add mutation-aware GraphQL auth before GraphQL becomes a public write API surface.
- Harden peer registration and outbound peer calls against SSRF and peer-list poisoning.
- After all live peers upgrade, enable signed peer writes by default.
+2 -10
View File
@@ -95,7 +95,7 @@ Live-network blockers to prioritize:
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization. Owner checks are now enforced on every branch, protected or not (`GITLAWB_ENFORCE_OWNER_PUSH`, on by default); what remains is that a UCAN `git/push` capability is not yet honored, so a delegated or CI key cannot push.
- UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature.
- Private repository reads are enforced per repository through `is_public` and path-scoped visibility rules. `GITLAWB_PUBLIC_READ` remains reserved and inert.
- Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired.
- Peer URLs are self-asserted by DIDs. Signatures prove control of the DID key when present, not ownership/safety of the announced URL.
- Outbound peer fetch/ping/sync paths should be reviewed for SSRF protections before accepting arbitrary public peer registrations.
@@ -146,16 +146,8 @@ Risks:
## Obvious live-network priorities
1. Implement repo write authorization: owner checks, UCAN capability checks, and clear delegation semantics for push/PR/issue/bounty operations.
2. Private reads are already enforced per repository (`is_public` plus path-scoped rules); retire the reserved and inert `GITLAWB_PUBLIC_READ` flag or document it as permanently reserved.
2. Implement private-read enforcement or remove private repo affordances until it exists.
3. Add UCAN revocation/blocklisting and operator docs for emergency key compromise.
4. Harden peer registration and outbound fetch behavior against SSRF and peer-list poisoning.
5. Add Docker/installer/release smoke tests to CI.
6. Label PoS/economics docs consistently with the current live contract and rewards status.
## Status updates (post-snapshot)
These entries record security-posture changes after the 2026-05-28 inspection above. They do not rewrite that snapshot's command output or test counts.
### 2026-08-30: GraphQL mutation auth and completeness fence (#87, #219)
At `main` from `bfc44f92` onward, GraphQL mutations require a verified signer bound to the acting DID. The surface stays limited to the agent-task queue; repo writes are not exposed over GraphQL. PR #219 adds `every_graphql_mutation_has_its_gate`, a CI completeness fence that discovers mutations from the built schema SDL (with an introspection cross-check for hidden fields) and requires signer acquisition plus actor binding in each registered resolver.
-33
View File
@@ -1,33 +0,0 @@
import { createData, EthereumSigner } from "arbundles";
import { createHash } from "node:crypto";
import base64url from "base64url";
const data = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{};':\",./<>?`~";
const tags = [
{ name: "tag1", value: "value1" },
{ name: "tag2", value: "value2" },
];
const anchor = "thisSentenceIs32BytesLongTrustMe";
const target = "OXcT1sVRSA5eGwt2k6Yuz8-3e3g9WJi5uSE99CWqsBs";
const signer = new EthereumSigner("8da4ef21b864d2cc526dbdb2a120bd2874c36c9d0a1fb7f8c63d7f7a8b41de8f");
const item = createData(data, signer, { anchor, target, tags });
// Sign so the captured signature is real: `createData` alone leaves a
// zeroed placeholder, whose id is just sha256(zeros).
await item.sign(signer);
const raw = item.getRaw();
const id = item.id;
// `item.signature` is base64url(rawSignature). Decode and sha256.
const sigBytes = base64url.toBuffer(item.signature);
const idCheck = base64url.encode(createHash("sha256").update(sigBytes).digest());
if (idCheck !== id) {
console.error(`MISMATCH: id=${id} sha256(sig)=${idCheck}`);
process.exit(1);
}
console.log("id =", id);
console.log("signature_b64=", item.signature);
console.log("signature_len=", sigBytes.length);
console.log("binary_len =", raw.length);
console.log("binary_hex =", raw.toString("hex"));
-40
View File
@@ -1,40 +0,0 @@
import { createData, SolanaSigner } from "arbundles";
import bs58 from "bs58";
import { getPublicKey } from "@noble/ed25519";
import { createHash } from "node:crypto";
import base64url from "base64url";
// Deterministic Ed25519 keypair from seed 0x01 * 32.
const seed = Buffer.alloc(32, 0x01);
const pub = await getPublicKey(seed);
const secret64 = Buffer.concat([seed, Buffer.from(pub)]);
const signer = new SolanaSigner(bs58.encode(secret64));
const data = "hello gitlawb ed25519 golden";
const tags = [
{ name: "App-Name", value: "gitlawb" },
{ name: "Schema", value: "gitlawb/ref-update/v1" },
];
const item = createData(data, signer, { tags });
await item.sign(signer);
const raw = Buffer.from(item.getRaw());
const id = item.id;
const sigBytes = base64url.toBuffer(item.signature);
const idCheck = base64url.encode(createHash("sha256").update(sigBytes).digest());
if (idCheck !== id) {
console.error(`MISMATCH: id=${id} sha256(sig)=${idCheck}`);
process.exit(1);
}
const sigData = await item.getSignatureData();
console.log("id =", id);
console.log("signature_b64=", item.signature);
console.log("signature_len=", sigBytes.length);
console.log("owner_b64 =", item.owner);
console.log("owner_len =", base64url.toBuffer(item.owner).length);
console.log("sigtype =", item.signatureType);
console.log("binary_len =", raw.length);
console.log("binary_hex =", raw.toString("hex"));
console.log("deephash_hex =", Buffer.from(sigData).toString("hex"));
console.log("data_b64 =", base64url.encode(Buffer.from(data)));
console.log("isValid =", await item.isValid());
-39
View File
@@ -1,39 +0,0 @@
ANS-104 golden vectors, captured from arbundles 0.10.x.
To regenerate: run `npm ci` in scripts/, then `node ans104_golden_ed25519.mjs`.
Ed25519 vector (the interop pin used by
`ans104::tests::dataitem_matches_arbundles_golden_vector`):
captured by = scripts/ans104_golden_ed25519.mjs (deterministic seed
0x01 * 32 via SolanaSigner, sigtype 2, `await item.sign`)
data = "hello gitlawb ed25519 golden"
tags = [{name:"App-Name",value:"gitlawb"},
{name:"Schema",value:"gitlawb/ref-update/v1"}]
target = absent
anchor = absent
Outputs:
signature_type = 2 (Ed25519)
signature_len = 64 bytes
owner_len = 32 bytes
binary_len = 192 bytes
id = SGrcBs-ITTyzvd7eIB5kk2GdBWoxB_iTi9iJ6KOe_RE (base64url of sha256(signature))
deephash_hex = f15c82431767f14ac9e66ab8e995a8cd08e094be3773245163b53c12feb50aefc55d9f8c1098fabcfbf11a462706d347
(arbundles `getSignatureData` 8-element fold)
binary_hex:
0200da41825fd44ca3b2705af18fce86ed6d04d0204331965d9af5d5cb1a740fcc6587ee81501b1d7928c54c0f174fde8893560d785db4d988a3161113b10a2028038a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00000200000000000000300000000000000004104170702d4e616d650e6769746c6177620c536368656d612a6769746c6177622f7265662d7570646174652f76310068656c6c6f206769746c617762206564323535313920676f6c64656e
The Rust side will:
1. Parse binary via DataItem::from_binary (signature_type, signature,
owner, target, anchor, tags, data).
2. Compute deep_hash via DataItem::deep_hash (the 8-element arbundles
fold) and compare to deephash_hex above.
3. Verify the Ed25519 signature via DataItem::verify_data_item WITHOUT
re-signing.
4. Assert sha256(signature_bytes) base64url-encodes to the captured id.
5. Assert to_binary round-trips byte-exact (signature slot preserved).
Legacy Ethereum script (scripts/ans104_golden.mjs) now signs before
capture as well; its output is illustrative only — the node verifies
Ed25519 (sigtype 2) on the verify path.
-1624
View File
File diff suppressed because it is too large. Load diff
-15
View File
@@ -1,15 +0,0 @@
{
"name": "ans104-golden",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@noble/ed25519": "^1.7.5",
"arbundles": "^0.10.0",
"base64url": "^3.0.1",
"bs58": "^4.0.1"
},
"overrides": {
"ws": "^8.21.0"
}
}
+1 -68
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s extglob
tag="${1-}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
@@ -12,79 +11,13 @@ case "$tag" in
;;
esac
# Right-anchored on purpose: v[0-9]* leaves the tail unbounded and accepts
# v1.2.3.4.5, v1_x, v1-any-branch-name, and bare v1. Every existing tag is
# plain vX.Y.Z (release-please, release-type: simple, no prerelease channel).
case "$tag" in
v+([0-9]).+([0-9]).+([0-9])) ;;
v[0-9]*) ;;
*)
printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)"
exit 1
;;
esac
# Provenance, enforced only in CI where a token is present. The environment
# gate restricts which REF a dispatch may run from; this restricts which TAG
# the input may name: it must be a tag the release flow already published,
# pointing at a commit main already contains. Otherwise a write-access user
# could plant a v99.99.99 tag on unreviewed content and backfill-publish it.
if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then
release_meta="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" \
--json author,targetCommitish -q '.author.login + " " + .targetCommitish' \
2>/dev/null)" || {
printf '%s\n' "::error::no GitHub release exists for tag $tag"
exit 1
}
release_author="${release_meta%% *}"
release_target="${release_meta##* }"
# A hand-created release on a valid tag does not qualify. Not a hard bound
# (a workflow run can mint a bot-authored release), but it removes the
# cheapest path to publishing attacker-uploaded release assets.
if [ "$release_author" != "github-actions[bot]" ]; then
printf '%s\n' "::error::release $tag was authored by $release_author, not the release automation"
exit 1
fi
# Same boundary for the assets the npm backfill republishes: a release
# asset replaced by a collaborator login is attacker-mutable content. The
# captured name/id pairs go to GITHUB_OUTPUT so the download step fetches
# by immutable asset id; a delete+reupload between here and the download
# produces a new id and fails closed. Names are emitted base64-encoded:
# an asset name is attacker-controlled text, and whitespace in it would
# otherwise let a crafted name shift the positional fields.
assets="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \
-q '.assets[] | (.name | @base64) + " " + (.id | tostring) + " " + (.uploader.login // "unknown")')"
uploaders="$(printf '%s\n' "$assets" | awk 'NF {print $3}' | sort -u)"
if [ -n "$uploaders" ] && [ "$uploaders" != "github-actions[bot]" ]; then
printf '%s\n' "::error::release $tag has assets not uploaded by the release automation: $uploaders"
exit 1
fi
{
printf '%s\n' 'assets<<GHAE'
printf '%s\n' "$assets" | awk 'NF {print $1, $2}'
printf '%s\n' 'GHAE'
} >> "$GITHUB_OUTPUT"
# Resolve through the fully-qualified tag ref to a commit SHA: an
# unqualified name can resolve to a same-named branch and vouch for the
# wrong commit, the same shadowing the refs/tags/ checkout prefix avoids.
# The SHA is emitted so checkouts pin the verified commit instead of
# re-resolving a tag that could be moved between resolve and checkout.
tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$tag" -q .sha)"
# The tag must still point at the commit the release was created against.
# Reachable-from-main alone accepts a tag moved to any other main commit.
if [ "$tag_commit" != "$release_target" ]; then
printf '%s\n' "::error::tag $tag points at $tag_commit but release $tag was created against $release_target (tag moved)"
exit 1
fi
status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag_commit" -q .status)"
case "$status" in
identical|behind) ;;
*)
printf '%s\n' "::error::tag $tag is not reachable from main (compare: $status)"
exit 1
;;
esac
printf '%s\n' "tag_commit=$tag_commit" >> "$GITHUB_OUTPUT"
fi
printf '%s\n' "tag=$tag" >> "$GITHUB_OUTPUT"
printf '%s\n' "version=${tag#v}" >> "$GITHUB_OUTPUT"
-297
View File
@@ -1,297 +0,0 @@
#!/usr/bin/env node
// Case matrix for the inline-test detector embedded in
// .github/workflows/pr-triage.yml. The workflow runs on pull_request_target
// and deliberately never checks out PR code, so the detector cannot be tested
// where it runs; this script extracts the fenced TRIAGE_DETECTOR block from
// the committed workflow body and exercises it here, where pr-checks.yml DOES
// check out the proposed workflow. If the fence markers move or the block
// stops being self-contained, this script fails loudly rather than testing a
// stale copy.
//
// The matrix encodes the review contract for the detector
// (Gitlawb/node#277): legal Rust separators between the attribute path and
// its ]/( delimiter must be accepted (line comments, nested block comments,
// splits onto immediately following added lines), while unrelated patch
// records - delimiter-looking lines before the path, later in the hunk, or in
// another hunk - must never complete a path they do not adjoin. False
// positives here SUPPRESS the needs-tests label silently (they set
// touchedTests, which clears the label), while false negatives apply it
// (the loud, corrigible direction). So every uncertain path in the detector
// is required to answer "no inline test".
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const workflow = readFileSync(
join(repoRoot, ".github/workflows/pr-triage.yml"),
"utf8"
);
const BEGIN = "// TRIAGE_DETECTOR_BEGIN";
const END = "// TRIAGE_DETECTOR_END";
const begin = workflow.indexOf(BEGIN);
const end = workflow.indexOf(END);
if (begin === -1 || end === -1 || end <= begin) {
console.error("FAIL: TRIAGE_DETECTOR fence not found in pr-triage.yml");
process.exit(1);
}
const block = workflow.slice(begin + BEGIN.length, end);
let patchAddsInlineTest;
try {
const factory = new Function(`${block}\nreturn patchAddsInlineTest;`);
patchAddsInlineTest = factory();
} catch (err) {
console.error(
"FAIL: fenced detector block is not self-contained JavaScript:",
err.message
);
process.exit(1);
}
// Each patch is the `patch` field GitHub's listFiles API returns: hunk
// headers plus +/-/space-prefixed lines, no ---/+++ file headers.
const cases = [
// ── Accepted spellings ────────────────────────────────────────────────
["bare same-line", "@@ -1,0 +1,2 @@\n+#[test]\n+fn a() {}", true],
["cfg(test)", "@@ -1,0 +1,1 @@\n+#[cfg(test)]", true],
["indented with inner space", "@@ -1,0 +1,1 @@\n+ #[ test ]", true],
[
"namespaced with args",
'@@ -1,0 +1,1 @@\n+#[tokio::test(flavor = "multi_thread")]',
true,
],
["test_case harness", "@@ -1,0 +1,1 @@\n+#[test_case(1)]", true],
["wasm_bindgen_test harness", "@@ -1,0 +1,1 @@\n+#[wasm_bindgen_test]", true],
["raw identifier", "@@ -1,0 +1,1 @@\n+#[r#test]", true],
[
"line comment then closer on next added line",
"@@ -1,0 +1,2 @@\n+#[test // rationale\n+]",
true,
],
[
"nested block comment, same line",
"@@ -1,0 +1,1 @@\n+#[test /* outer /* inner */ outer */]",
true,
],
[
"block comment spanning added lines",
"@@ -1,0 +1,3 @@\n+#[test /* why\n+ still why */ ]\n+fn a() {}",
true,
],
[
"path-only line, ( on the immediately following added line",
"@@ -1,0 +1,2 @@\n+#[test_case\n+(1)]",
true,
],
[
"whitespace-only continuation before closer",
"@@ -1,0 +1,3 @@\n+#[test\n+\t\n+]",
true,
],
// ── Rejected spellings and adversarial shapes ─────────────────────────
["rstest stays excluded", "@@ -1,0 +1,1 @@\n+#[rstest]", false],
["substring #[testable]", "@@ -1,0 +1,1 @@\n+#[testable]", false],
["substring #[contest]", "@@ -1,0 +1,1 @@\n+#[contest]", false],
[
"delimiter-looking line BEFORE the path",
"@@ -1,0 +1,2 @@\n+(\n+#[test_case",
false,
],
[
"raw-string fixture path + unrelated ( later in the same hunk",
'@@ -1,0 +1,5 @@\n+let s = r#"\n+#[test_case\n+not a separator token\n+"#;\n+let t = (1);',
false,
],
[
"path at end of one hunk, closer in another hunk",
"@@ -1,0 +1,1 @@\n+#[test_case\n@@ -10,0 +11,1 @@\n+(1)]",
false,
],
[
"closer only on a context line",
"@@ -1,1 +1,1 @@\n+#[test_case\n (1)]",
false,
],
[
"closer only on a removed line",
"@@ -1,1 +1,1 @@\n+#[test_case\n-(1)]",
false,
],
[
"unfinished block comment never closes",
"@@ -1,0 +1,2 @@\n+#[test /*\n+ still open",
false,
],
[
"continuation bound exceeded stays loud",
"@@ -1,0 +1,40 @@\n+#[test /*\n" + "+ filler\n".repeat(30) + "+ */ ]",
false,
],
[
"raw-string fixture with adjoining closer",
'@@ -1,0 +1,4 @@\n+const FIXTURE: &str = r#"\n+#[test_case\n+(1)]\n+"#;',
false,
],
[
"block comment with adjoining closer",
'@@ -1,0 +1,4 @@\n+/* this is a comment\n+#[test_case\n+(1)]\n+end of comment */',
false,
],
[
"raw string with no hash delimiters",
'@@ -1,0 +1,4 @@\n+let s = r"\n+#[test_case\n+(1)]\n+";',
false,
],
[
"raw string closes then real test attribute on next line",
'@@ -1,0 +1,2 @@\n+let s = r#""#;\n+#[test_case(1)]',
true,
],
[
"block comment closes then real test attribute on next line",
'@@ -1,0 +1,2 @@\n+/* c */\n+#[test]',
true,
],
[
"multiline regular string with test attribute inside",
'@@ -1,0 +1,3 @@\n+const S: &str = "\n+#[test]\n+";',
false,
],
[
"regular string closes then real test on next line",
'@@ -1,0 +1,2 @@\n+let s = "text";\n+#[test]',
true,
],
[
"char literal with double quote then real test",
"@@ -1,0 +1,4 @@\n+const QUOTE: char = '\"';\n+#[test]\n+fn real_test() {\n+ assert_eq!(QUOTE as u32, 34);\n+}",
true,
],
[
"byte char literal with double quote then real test",
"@@ -1,0 +1,4 @@\n+const QUOTE: u8 = b'\"';\n+#[test]\n+fn real_test() {\n+ assert_eq!(QUOTE, 34);\n+}",
true,
],
[
"char literal with escaped quote then real test",
"@@ -1,0 +1,4 @@\n+const Q: char = '\\'';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 39);\n+}",
true,
],
[
"char literal with escaped backslash then real test",
"@@ -1,0 +1,4 @@\n+const Q: char = '\\\\';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 92);\n+}",
true,
],
[
"char literal with unicode escape then real test",
"@@ -1,0 +1,4 @@\n+const Q: char = '\\u{2764}';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 10084);\n+}",
true,
],
[
"lifetime in type then real test",
"@@ -1,0 +1,3 @@\n+fn foo<'a>(x: &'a str) {}\n+#[test]\n+fn real_test() {}",
true,
],
[
"label then real test",
"@@ -1,0 +1,3 @@\n+'label: loop {}\n+#[test]\n+fn real_test() {}",
true,
],
[
"static lifetime then real test",
"@@ -1,0 +1,3 @@\n+const S: &'static str = \"hi\";\n+#[test]\n+fn real_test() {}",
true,
],
[
"context line with stray closing quote then real test",
'@@ -1,1 +1,2 @@\n );"\n+#[test]',
true,
],
[
"context line with stray opening quote then real test",
'@@ -1,1 +1,2 @@\n let s = "\n+#[test]',
true,
],
[
"r# inside string on context line then real test",
'@@ -1,1 +1,2 @@\n let s = "r#";\n+#[test]',
true,
],
[
"/* inside string on context line then real test",
'@@ -1,1 +1,2 @@\n let s = "/*";\n+#[test]',
true,
],
// Known limitation: a non-code region opened by a context line is invisible
// to the pre-pass (context lines are not scanned). An added #[test] inside
// an existing raw string or block comment is scanned as code and may match.
// This is a false positive (silent direction), accepted because the old
// detector had the same behavior, the pattern is rare, and needs-tests is
// advisory. Scanning context lines to close this gap would re-introduce a
// false negative on the far more common case of a context line with a stray
// " from a string that opened before the visible window.
[
"known limitation: #[test] inside context-opened raw string (FP)",
'@@ -1,1 +1,2 @@\n const FIX: &str = r#"\n+#[test]\n "#;',
true,
],
[
"known limitation: #[test_case] inside context-opened raw string (FP)",
'@@ -1,1 +1,3 @@\n const FIX: &str = r#"\n+#[test_case\n+(1)]\n "#;',
true,
],
[
"known limitation: #[test] inside context-opened block comment (FP)",
'@@ -1,1 +1,2 @@\n /*\n+#[test]\n */',
true,
],
];
let failures = 0;
for (const [name, patch, expected] of cases) {
const got = patchAddsInlineTest(patch);
if (got !== expected) {
failures += 1;
console.error(`FAIL: ${name}: expected ${expected}, got ${got}`);
}
}
// Runtime probe: the detector walks fork-controlled input on
// pull_request_target, so a pathological head must not stall the job. The
// long `_a` run is the historical exponential-backtracking shape for the
// attribute-path regex; the comment run exercises the scanner loop.
const probes = [
["long _a run", "@@ -1,0 +1,1 @@\n+#[test" + "_a".repeat(30000), false],
[
"long unclosed comment line",
"@@ -1,0 +1,1 @@\n+#[test /*" + " *".repeat(30000),
false,
],
[
"long malformed \\u{ escape run (quadratic guard)",
"@@ -1,0 +1,1 @@\n+" + "'\\u{".repeat(30000),
false,
],
];
for (const [name, patch, expected] of probes) {
const t0 = process.hrtime.bigint();
const got = patchAddsInlineTest(patch);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
if (got !== expected) {
failures += 1;
console.error(`FAIL: probe ${name}: expected ${expected}, got ${got}`);
}
if (ms > 1000) {
failures += 1;
console.error(`FAIL: probe ${name}: took ${ms.toFixed(0)}ms (>1000ms)`);
}
}
if (failures) {
console.error(`${failures} failure(s) across ${cases.length + probes.length} cases`);
process.exit(1);
}
console.log(`ok: ${cases.length + probes.length} detector cases passed`);
File diff suppressed because it is too large. Load diff