Compare commits

..
Author SHA1 Message Date
Kevin Codex 15e0f4f629 feat(node): add --bulk mode to purge-spam for fast set-based cleanup
The per-repo cascade (one transaction per repo) stalls and drops connections
on small DB instances with large match sets (node2/node3, ~70k keep* mirrors).

--bulk deletes all matching repos and their child rows with a handful of
set-based statements in one transaction, then removes the on-disk bare repos
(collected before the delete). Skips Tigris (mirror rows have no archive).
Slug-keyed child tables match both the repo id and the {owner_short}/{name}
slug so canonical and mirror rows are both cleaned.

Tested: bulk_delete_by_name_regex removes both canonical + mirror matches and
their id-/slug-keyed children while leaving non-matching repos intact.
2026-07-01 09:17:41 +08:00
Kevin Codex f83c6689ef feat(node): admin purge-spam subcommand for bulk spam-repo cleanup
Adds `gitlawb-node purge-spam --regex <re> [--execute] [--limit N]
[--skip-tigris]`, a one-off maintenance command to remove repos whose name
matches a Postgres regex (e.g. '^keep[0-9]' for the keepNxNxN spam flood).

- Defaults to a dry run (reports match count + a sample); --execute deletes.
- Per repo: cascade-deletes DB rows (repos + all child tables, matching both
  the repo_id and the {owner_short}/{name} slug used by branch_cids/sync_queue/
  received_ref_updates/arweave_anchors), removes the on-disk bare repo, and
  deletes the Tigris archive (unless --skip-tigris). Batched, re-querying after
  each batch so no offset bookkeeping is needed.
- Runs instead of the daemon and exits (Config gains an optional subcommand).

DB layer: count_repos_by_name_regex, list_repos_by_name_regex,
delete_repo_cascade. Tested: regex precision (^keep[0-9] matches keepNxNxN but
not "keeper-tools") and cascade removal of child rows.

Cargo.lock: reconcile workspace crate versions to 0.4.0.
2026-07-01 09:17:41 +08:00
93 changed files with 1445 additions and 28364 deletions

No files matched your search

-57
View File
@@ -21,23 +21,6 @@ GITLAWB_REPOS_DIR=/data/repos
# When using the bundled docker-compose, this is wired automatically.
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
# ── Database pool & startup resilience ────────────────────────────────────
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
# connections open lazily. Size against the DB server's max_connections,
# remembering admin tooling opens its own pool.
GITLAWB_DB_MAX_CONNECTIONS=20
# Seconds a request waits for a pool connection before failing with 503.
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
# generous enough for a peer instance to finish migrating (the migration
# advisory lock is held across a deploy); on expiry the attempt is retried.
GITLAWB_DB_CONNECT_TIMEOUT_SECS=60
# Startup retry backoff while the database is unavailable: first delay and
# ceiling, in seconds. The node stays up serving 503s (with a truthful
# /ready) instead of crash-looping.
GITLAWB_DB_RETRY_INITIAL_SECS=5
GITLAWB_DB_RETRY_MAX_SECS=60
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
# Get a JWT at https://app.pinata.cloud/developers/api-keys
GITLAWB_PINATA_JWT=
@@ -107,46 +90,6 @@ GITLAWB_PUBLIC_READ=true
# Maximum git smart-HTTP pack request size, in bytes.
GITLAWB_MAX_PACK_BYTES=2147483648
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
# worker and, on push, the repo write lock. Does NOT cover the info/refs
# advertisement or the withheld-blob path, which remain unbounded. Default 600.
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
# Max receive-pack requests (info/refs advertisement + push POST) per client
# IP per hour. 0 disables. Default 600.
GITLAWB_PUSH_RATE_LIMIT=600
# ── 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
# limit alone is bypassed by a did:key farm (one throwaway identity per repo),
# which is how spam-repo floods slip past it and the iCaptcha gate; the per-IP
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
# the client IP. 0 disables. Default 120.
GITLAWB_CREATE_RATE_LIMIT=120
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
# known peers and run at higher frequency, so a generous bucket. Separate from
# the trigger bucket so an unsigned notify flood can't drain trigger's quota.
# 0 disables. Default 600.
GITLAWB_PEER_WRITE_RATE_LIMIT=600
# /api/v1/sync/trigger requires a signature and fans out to every peer per call,
# so it gets a tight bucket. 0 disables. Default 60.
GITLAWB_SYNC_TRIGGER_RATE_LIMIT=60
# Which forwarded header the edge is trusted to set, used to resolve the real
# client IP for the push limiter. One of:
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
# fly — behind Fly's edge (trust Fly-Client-IP).
# x-forwarded-for — behind a single reverse proxy like Caddy/NGINX (trust the
# rightmost X-Forwarded-For hop).
# Only set this when a proxy you control actually fronts the node; trusting a
# forwarded header on a directly-exposed node lets clients spoof the key.
GITLAWB_TRUSTED_PROXY=
# ── Sync ─────────────────────────────────────────────────────────────────
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false
+1 -12
View File
@@ -37,18 +37,7 @@ jobs:
with:
key: audit-schedule
- name: Install cargo-audit (floating; drift canary)
# Deliberately UNPINNED, unlike the blocking gate in pr-checks.yml. This
# job is cron-only (weekly); it is never pull_request- or push-triggered,
# so it NEVER gates a merge no matter what it does. Floating the scanner
# here is the drift canary: if a newer cargo-audit regresses, at worst
# this weekly run reds (the install step below is unguarded) or its
# visibility report changes, and that is exactly the signal that latest
# has moved and the pinned gate should not be bumped yet. The scan result
# itself never fails the run (the "Full audit report" step is `|| true`
# and the only other hard-fail is a Cargo.lock grep). The point is that
# drift surfaces here without ever blocking a PR. Do NOT pin to match
# pr-checks.yml.
- name: Install cargo-audit
run: cargo install --locked cargo-audit
# Full advisory report with NO suppressions. Run from a scratch dir that
-121
View File
@@ -1,121 +0,0 @@
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
# issue when a push-to-main run of PR Checks ends red, and closes it when a
# subsequent run on main's tip goes green.
#
# Why: two individually-green PRs can break main in combination when the
# second merges on checks that ran against a stale base (semantic merge
# race — #113 + #145 broke test compilation, found only via #229). A red main
# is otherwise silent, and release-please PRs inherit the breakage.
#
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
# commit is no longer main's tip are ignored (a re-run of an old red run
# after a fix has landed must not cry wolf), and a green run on the tip
# closes any open alert.
name: Main red alert
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
branches: [main]
permissions:
issues: write
contents: read # getBranch tip check
# Serialize runs: concurrent completions would race the label/issue lookups
# into duplicates (or 422 on createLabel). queue: max keeps every pending
# completion instead of letting a later (possibly green-skipped) arrival
# displace a pending red alert under burst merges; it is only incompatible
# with cancel-in-progress: true, which we don't use.
concurrency:
group: main-red-alert
cancel-in-progress: false
queue: max
jobs:
track:
name: Track main CI state
# timed_out (the test job runs 45 min against Postgres) and
# startup_failure (malformed workflow) are red too — only cancelled is
# excluded as a deliberate human abort. success runs are needed to close
# the alert once main recovers.
if: >-
github.event.workflow_run.event == 'push' &&
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reconcile the broken-main issue with main's current state
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const label = 'ci-main-red';
// Freshness gate: only the run for main's current tip may open or
// close anything. Re-runs of superseded commits are events about
// the past, not statements about main's state.
const { data: branch } = await github.rest.repos.getBranch({
...context.repo, branch: 'main',
});
if (branch.commit.sha !== run.head_sha) {
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
return;
}
// listForRepo returns PRs carrying the label too — those must
// never be mistaken for the tracking issue.
const open = (await github.rest.issues.listForRepo({
...context.repo, state: 'open', labels: label, per_page: 100,
})).data.filter(item => !item.pull_request);
if (run.conclusion === 'success') {
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
for (const issue of open) {
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number, body,
});
await github.rest.issues.update({
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
});
}
return;
}
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
const body = [
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
'',
`> ${headline}`,
'',
'Likely a semantic merge race: each merged PR was green on a stale base.',
'Fix main before merging anything else — release-please PRs inherit this breakage.',
'',
"This issue closes automatically when a run on main's tip goes green.",
].join('\n');
try {
await github.rest.issues.getLabel({ ...context.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
...context.repo,
name: label,
color: 'b60205',
description: 'CI is failing on main',
});
}
if (open.length > 0) {
await github.rest.issues.createComment({
...context.repo, issue_number: open[0].number, body,
});
} else {
await github.rest.issues.create({
...context.repo,
title: 'CI is red on main',
body,
labels: [label],
});
}
+7 -98
View File
@@ -2,17 +2,6 @@ name: PR Checks
on:
pull_request:
# Run in the merge queue so a PR is re-validated against the true merged
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
# and the lock-sync job), and GitHub's recursion guard means those pushes
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
# the release branch to attach check runs to its head. Read-only workflow
# (contents: read), so the dispatch surface adds no privilege.
workflow_dispatch:
push:
branches:
- main
@@ -53,7 +42,7 @@ jobs:
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings
test:
name: test (${{ matrix.toolchain }})
@@ -101,7 +90,7 @@ jobs:
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
run: cargo test --workspace
build-release:
name: build --release
@@ -124,7 +113,7 @@ jobs:
key: release
- name: cargo build --release
run: cargo build --locked --release --workspace
run: cargo build --release --workspace
audit:
name: cargo audit
@@ -144,30 +133,10 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: audit-0.22.2
key: audit
- name: Install cargo-audit (pinned)
# Pin the scanner on this BLOCKING gate: an unpinned `cargo install`
# floats to the latest release, so a regressed cargo-audit could red CI
# repo-wide with no code change. Advisory-DB freshness is fetched at scan
# time, independent of the binary, so this does not weaken what the gate
# catches. The weekly Scheduled Audit (audit-schedule.yml) deliberately
# floats to latest as the drift canary; when it shows a newer cargo-audit
# behaving differently, bump this version (and the cache key above) as a
# deliberate maintainer action. If 0.22.2 is ever yanked from crates.io
# this step fails loud until the pin is bumped.
run: |
set -euo pipefail
cargo install --locked --version 0.22.2 cargo-audit
# Assert the pin took effect so an accidental future unpin (or a drift
# off 0.22.2) fails loudly here instead of silently running a different
# scanner. `cargo audit --version` prints "cargo-audit-audit 0.22.2".
installed="$(cargo audit --version)"
echo "cargo-audit installed: $installed"
echo "$installed" | grep -qE '(^| )0\.22\.2($| )' || {
echo "::error::cargo-audit is not the pinned 0.22.2 (got: $installed)"
exit 1
}
- name: Install cargo-audit
run: cargo install --locked cargo-audit
# Hard-fail gate. Suppressions live in .cargo/audit.toml (read automatically
# from the repo root), each with no available upstream fix. A green check
@@ -206,7 +175,7 @@ jobs:
key: msrv
- name: cargo check on MSRV
run: cargo check --locked --workspace --all-targets
run: cargo check --workspace --all-targets
docker-build:
name: Docker build smoke test
@@ -223,63 +192,3 @@ jobs:
- name: Smoke test --version
run: docker run --rm gitlawb-node:ci-test --version
test-windows:
name: test (windows, non-blocking)
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
# test coverage, so run their suites here to make Windows-only regressions
# VISIBLE on the PR without gating merge (#228). These two are the client
# CLIs and depend on no database, so no Postgres service is needed (service
# containers do not run on windows-latest regardless). `git` is preinstalled
# on windows-latest, which the real_git_fetch harness relies on.
continue-on-error: true
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test --locked -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
# dependency reds CI whether or not anyone thought to ban it, which a denylist
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: core-deps-purity
- name: Check gitlawb-core dependency allowlist
run: bash scripts/check-gitlawb-core-deps.sh
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Label and guide
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const pr = context.payload.pull_request;
+63 -372
View File
@@ -4,20 +4,6 @@ on:
push:
branches:
- main
# Manual backfill: republish an already-cut release to a channel that
# missed it. Fill in only the channel(s) you want — an empty input leaves
# that channel's jobs skipped. npm reuses the existing release's binary
# assets; docker rebuilds the image from the tag and re-tags the manifest.
workflow_dispatch:
inputs:
npm_backfill_tag:
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
required: false
type: string
docker_backfill_tag:
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
required: false
type: string
concurrency:
group: release-${{ github.ref }}
@@ -28,10 +14,7 @@ permissions: {}
jobs:
release-please:
# Skipped on manual dispatch: a backfill must never create or advance a
# release PR as a side effect (npm-publish tolerates the skipped need via
# !cancelled(), and every other job gates on release_created == 'true').
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
if: ${{ github.repository == 'Gitlawb/node' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
@@ -41,7 +24,6 @@ jobs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
pr: ${{ steps.release.outputs.pr }}
steps:
- name: Run release-please
id: release
@@ -49,198 +31,24 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
# The release PR bumps the workspace crate versions in the Cargo.tomls but
# release-please cannot update Cargo.lock, so without this job every release
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
# the release branch so the tagged tree is internally consistent.
#
# Runs on every push-triggered release run, not only when release-please
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
# the release PR, so gating on it would leave an already-open release PR
# (opened before this job existed, or untouched by a non-releasable push)
# permanently without the sync and without check runs. The resolve step
# discovers any open release-please PR itself and no-ops when there is none.
sync-release-lock:
name: Sync Cargo.lock on the release PR
needs: release-please
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
# actions: write lets the final step dispatch pr-checks on the release
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
# scope it to one workflow); acceptable here because reaching this job's
# steps already requires write to main. Needed because every push to the
# release branch (release-please's and the lock sync below) is made with
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
# dispatch the release PR's head carries no check runs and the --locked
# gate never validates it.
actions: write
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
# branch (prefix match: the branch carries `--components--` suffixes).
- name: Resolve the open release PR branch
id: resolve
env:
GH_TOKEN: ${{ github.token }}
OWNER: ${{ github.repository_owner }}
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
run: |
branch="$BRANCH_FROM_OUTPUT"
if [ -z "$branch" ]; then
# The branch name alone is not a trust signal. `gh pr list` includes
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
# prefix match by itself lets any fork nominate the branch this job
# checks out, commits to, and dispatches checks on. Require the head
# repo to be THIS repository and the author to be the release-please
# app, so neither a fork nor a same-repo human branch named
# `release-please--*` can be selected. Then take the highest PR
# number: `[0]` depended on gh's default ordering, which is not a
# documented guarantee, and picking a stable one keeps a second
# (component) release PR from making the choice flap between runs.
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
--json headRefName,headRepositoryOwner,number,author \
--jq '[ .[]
| select(.headRepositoryOwner.login == env.OWNER)
| select(.author.login == "app/github-actions")
| select(.headRefName | startswith("release-please--"))
] | max_by(.number).headRefName // empty')
fi
if [ -z "$branch" ]; then
echo "no open release PR; nothing to sync"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- name: Checkout release branch
if: ${{ steps.resolve.outputs.branch != '' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.resolve.outputs.branch }}
# Resolve on a declared toolchain instead of whatever Rust the runner
# image happens to ship, so the lock this job writes comes off the same
# stable channel the other cargo jobs use. Not a claim of identical
# resolution: `stable` still moves between runs, and the MSRV gate
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
# runner image as an undeclared input.
- name: Install Rust toolchain
if: ${{ steps.resolve.outputs.branch != '' }}
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Sync workspace crate versions into Cargo.lock
if: ${{ steps.resolve.outputs.branch != '' }}
run: cargo update --workspace
- name: Commit and push when the lock changed
if: ${{ steps.resolve.outputs.branch != '' }}
run: |
if git diff --quiet Cargo.lock; then
echo "Cargo.lock already in sync"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.lock
git commit -m "chore: sync Cargo.lock with the release version bump"
git push
# Every push to the release branch is made with GITHUB_TOKEN (release-
# please's own pushes and the lock sync above), and events created by
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
# branch to attach check runs to its current head. Unconditional (also on
# the already-in-sync path) because the branch's ORIGINAL head has the
# same no-runs problem. Loud on failure by design: a silent fallback
# would recreate the checkless-head problem, and a job rerun is safe
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
# is the sync commit. Merge-queue note: this validates the branch tip;
# the enforced merge queue's merge_group run still re-validates the true
# merged result before landing.
- name: Dispatch PR checks on the release branch
if: ${{ steps.resolve.outputs.branch != '' }}
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.resolve.outputs.branch }}
run: |
# Every dispatch failure fails the job, including the 422 a release
# branch cut before pr-checks gained its workflow_dispatch trigger
# returns ("does not have a workflow_dispatch trigger", reported
# against the dispatched ref's copy of the file). An earlier revision
# tolerated exactly that 422 on the theory that release-please would
# rewrite the branch and heal it, but a rewrite only happens when a
# later releasable commit lands: an open release PR nobody touches is
# never rewritten. Tolerating it therefore left the lock-sync commit
# this job had just pushed sitting on the release head with no
# --locked validation, which is the precise state this workflow exists
# to prevent. The tolerance is also no longer needed: once this lands,
# every release branch is cut from a main that already carries the
# trigger, so the only branch that could 422 is one open at merge time
# (there is none), and a job rerun after a manual rebase is safe (the
# sync no-ops, the dispatch retries).
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
-f "ref=$BRANCH"
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
# workflow concurrency group, blocked every queued release run behind it.
docker:
name: Build & Push Docker Image (${{ matrix.arch }})
name: Build & Push Docker Image
needs: release-please
# Runs on a fresh release, or on manual dispatch for an existing tag
# (release-please is skipped on dispatch, hence !cancelled()).
if: >-
${{ !cancelled() && (
needs.release-please.outputs.release_created == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
) }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
os: ubuntu-latest
platform: linux/amd64
- arch: arm64
os: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.os }}
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# ghcr requires a lowercase repository path, and unlike metadata-action,
# buildx's `--output name=` does no lowercasing — a mixed-case owner
# makes the digest push fail with "invalid reference format".
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.rel.outputs.tag }}
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@@ -251,130 +59,45 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally (smoke)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: ${{ matrix.platform }}
load: true
tags: gitlawb-node:smoke
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-from: type=gha
# Native runner on both arches, so the built image can always execute.
# Assert the released version so a stale artifact fails here, not in prod.
- name: Smoke test
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
out="$(docker run --rm gitlawb-node:smoke --version)"
echo "$out"
grep -qF "$VERSION" <<<"$out" || {
echo "::error::image --version did not report expected version $VERSION (got: $out)"
exit 1
}
run: docker run --rm gitlawb-node:smoke --version
# Push by digest only — tags are applied once by docker-manifest so a
# half-finished matrix can never publish a partially-tagged image.
# provenance:false keeps each push a plain single-arch manifest, which is
# what imagetools create expects to merge.
- name: Build and push by digest
id: push
- name: Build and push (multi-arch)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
- name: Export digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: docker-digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-manifest:
name: Publish Docker manifest
needs: [release-please, docker]
if: ${{ !cancelled() && needs.docker.result == 'success' }}
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Resolve release tag
id: rel
env:
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: docker-digest-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
env:
VERSION: ${{ steps.rel.outputs.version }}
run: |
set -euo pipefail
# ghcr requires a lowercase repository path.
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
MAJOR_MINOR="${VERSION%.*}"
digests=""
for f in /tmp/digests/*; do
digests="$digests $IMAGE@sha256:$(basename "$f")"
done
# shellcheck disable=SC2086
docker buildx imagetools create \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:$MAJOR_MINOR" \
-t "$IMAGE:latest" \
$digests
docker buildx imagetools inspect "$IMAGE:$VERSION"
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Release summary
env:
VERSION: ${{ steps.rel.outputs.version }}
TAG: ${{ steps.rel.outputs.tag }}
run: |
{
echo "## Released $TAG"
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY"
release-binaries:
@@ -393,14 +116,10 @@ jobs:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
# Native arm64 runner: cross-compiling this target with the glibc
# toolchain broke aws-lc-sys, and native lets the smoke test run.
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
# macos-13 (the last plain Intel image) is retired and queues forever;
# macos-15-intel is GitHub's supported Intel label.
os: ubuntu-latest
- target: x86_64-apple-darwin
os: macos-15-intel
os: macos-13
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-pc-windows-msvc
@@ -418,13 +137,16 @@ jobs:
toolchain: stable
targets: ${{ matrix.target }}
# Both musl targets build natively on a matching-arch runner, so the stock
# musl-gcc wrapper is all that's needed (no cross toolchain).
- name: Install musl tools (linux)
if: contains(matrix.target, 'linux-musl')
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
echo "CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
fi
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
@@ -446,12 +168,13 @@ jobs:
set -euo pipefail
args=""
for b in $BINS; do args="$args -p $b"; done
cargo build --release --locked --target ${{ matrix.target }} $args
cargo build --release --target ${{ matrix.target }} $args
# Run each packaged binary's --version so a broken release artifact fails the
# build instead of shipping. Every target builds on a matching-arch runner,
# so all of them can execute here.
# build instead of shipping. Skipped for cross-compiled targets that can't run
# on the host runner (aarch64 linux on an x86_64 runner).
- name: Smoke test binaries
if: ${{ matrix.target != 'aarch64-unknown-linux-musl' }}
shell: bash
run: |
set -euo pipefail
@@ -525,62 +248,37 @@ jobs:
npm-publish:
name: Publish to npm
needs: [release-please, release-binaries]
# Runs on a fresh release, or on manual dispatch for an existing tag
# (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, or publish fails loudly here —
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
# never reached npm.
if: >-
${{ !cancelled() && (
(needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '')
) }}
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm trusted publishing (OIDC) + provenance
id-token: write # npm provenance
steps:
- name: Resolve release tag
id: rel
- name: Guard on secret
id: guard
env:
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
case "$TAG" in
v[0-9]*) ;;
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::warning::NPM_TOKEN is not set — skipping npm publish."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout release tag
if: ${{ steps.guard.outputs.enabled == 'true' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.rel.outputs.tag }}
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
# package has a trusted publisher configured; older npm silently falls
# back to (absent) token auth and fails confusingly.
- name: Set up Node 24 + OIDC-capable npm
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "24"
- name: Ensure npm supports trusted publishing
run: |
set -euo pipefail
npm install -g npm@^11.5.1
npm --version
- name: Lay in release binaries
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
# npm platform package -> Rust target triple (unix only; Windows is not
@@ -607,8 +305,9 @@ jobs:
done
- name: Set versions
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
VERSION: ${{ steps.rel.outputs.version }}
VERSION: ${{ needs.release-please.outputs.version }}
run: |
set -euo pipefail
node -e '
@@ -628,12 +327,13 @@ jobs:
'
- name: Publish
if: ${{ steps.guard.outputs.enabled == 'true' }}
env:
VERSION: ${{ steps.rel.outputs.version }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.release-please.outputs.version }}
run: |
set -euo pipefail
# No token: npm exchanges this job's GitHub OIDC identity with the
# registry (trusted publishing); provenance is attested automatically.
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
# 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.
@@ -729,15 +429,6 @@ jobs:
bin.install "git-remote-gitlawb"
end
def caveats
<<~CAVEATS
oh-my-zsh's git plugin aliases gl='git pull', which shadows this
binary in interactive shells. If \`gl\` prints "fatal: not a git
repository", run:
echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc
CAVEATS
end
test do
assert_match version.to_s, shell_output("#{bin}/gl --version")
end
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.7.1"
".": "0.4.0"
}
-94
View File
@@ -1,99 +1,5 @@
# Changelog
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
### Bug Fixes
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
### Features
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
### Bug Fixes
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
### Features
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
### Bug Fixes
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
### Bug Fixes
* **bounties:** add tests for claim_bounty repo-read gate ([#160](https://github.com/Gitlawb/node/issues/160)) ([#169](https://github.com/Gitlawb/node/issues/169)) ([6bafaa6](https://github.com/Gitlawb/node/commit/6bafaa6dc5f05c0dcd61c708397afddbcf8c2e3f))
* **deps:** bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#162](https://github.com/Gitlawb/node/issues/162)) ([#163](https://github.com/Gitlawb/node/issues/163)) ([67ad2b8](https://github.com/Gitlawb/node/commit/67ad2b876c8d9a336219d1016968de7a88fc4e75))
* **node,git:** bound a hung served git with a total-duration timeout ([#62](https://github.com/Gitlawb/node/issues/62)) ([#165](https://github.com/Gitlawb/node/issues/165)) ([cd67718](https://github.com/Gitlawb/node/commit/cd67718f49ec38726a40f6bcf36f539ccdb42969))
* **node:** bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth ([#147](https://github.com/Gitlawb/node/issues/147)) ([#149](https://github.com/Gitlawb/node/issues/149)) ([6b5e5bc](https://github.com/Gitlawb/node/commit/6b5e5bc7aee00a2d03295d3620df1ee4d8c024a2))
* **node:** gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes ([#82](https://github.com/Gitlawb/node/issues/82)) ([#161](https://github.com/Gitlawb/node/issues/161)) ([d00d89a](https://github.com/Gitlawb/node/commit/d00d89ae5be992d1f63e95b714ae1bd3735e8457))
* **node:** rate-limit repo/agent creation per client IP to stop DID-farm spam floods ([#180](https://github.com/Gitlawb/node/issues/180)) ([dfcaa22](https://github.com/Gitlawb/node/commit/dfcaa22b23ec91be4c75926956aa994ca89de8d5))
* **release:** build aarch64-musl natively on arm64 runners, replace retired macos-13 ([#155](https://github.com/Gitlawb/node/issues/155)) ([6cff528](https://github.com/Gitlawb/node/commit/6cff5286b436fdee44ad881999e2a5f4bdba18f9))
* **visibility:** gate repo-scoped read surfaces on visibility ([#120](https://github.com/Gitlawb/node/issues/120)) ([#157](https://github.com/Gitlawb/node/issues/157)) ([26bc3f6](https://github.com/Gitlawb/node/commit/26bc3f69870aa77e43c0d92115a5aa59555b7d88))
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
### Features
* **gl:** sanctioned iCaptcha client flow + secure git lifecycle ([#138](https://github.com/Gitlawb/node/issues/138)) ([06388ec](https://github.com/Gitlawb/node/commit/06388ec26aa29d356ae311276fdb91be054e9ecc))
### Bug Fixes
* **gl:** sign the CLI's /ipfs/pins reads under the [#134](https://github.com/Gitlawb/node/issues/134) auth gate ([#146](https://github.com/Gitlawb/node/issues/146)) ([20d6848](https://github.com/Gitlawb/node/commit/20d6848846b3a988d604208833167a528b7d8820))
* **node,git-remote:** gate receive-pack advertisement, sign client fetch/push ([#119](https://github.com/Gitlawb/node/issues/119)) ([6f36fc0](https://github.com/Gitlawb/node/commit/6f36fc07b8e10a650c5948b269feac1cb25cae2a))
* **node,gossip:** route gossip HTTP through the no-redirect client ([#93](https://github.com/Gitlawb/node/issues/93)) ([#140](https://github.com/Gitlawb/node/issues/140)) ([563c456](https://github.com/Gitlawb/node/commit/563c456803bf3e958d63869db424b3940472bc3d))
* **node:** close two spam-vector root causes (trust upsert + ungated push) ([#152](https://github.com/Gitlawb/node/issues/152)) ([2df6ff9](https://github.com/Gitlawb/node/commit/2df6ff9d30de62f754fa41473e85db316021718e))
* **node:** gate GET /ipfs/{cid} on reachable allowed-set, not deny-set ([#126](https://github.com/Gitlawb/node/issues/126)) ([#133](https://github.com/Gitlawb/node/issues/133)) ([466a550](https://github.com/Gitlawb/node/commit/466a550915edd711856ef32035f9f474e2577c4f))
* **node:** gate the ref-updates feeds on read visibility ([#112](https://github.com/Gitlawb/node/issues/112), [#114](https://github.com/Gitlawb/node/issues/114)) ([#143](https://github.com/Gitlawb/node/issues/143)) ([4891db3](https://github.com/Gitlawb/node/commit/4891db38892663326ee0c1417a2db931988be4b5))
* **node:** prefer canonical repo row over mirror row in get_repo ([#124](https://github.com/Gitlawb/node/issues/124)) ([#141](https://github.com/Gitlawb/node/issues/141)) ([6c95592](https://github.com/Gitlawb/node/commit/6c95592d188222ac3446dc23ef8d9befbf82ad6f))
* **remote:** include HTTP error response body ([#137](https://github.com/Gitlawb/node/issues/137)) ([09a0cb2](https://github.com/Gitlawb/node/commit/09a0cb23b9f284ccbd69aca6958b70671f3bfb46))
* **repos:** log the cause when repo create fails ([#103](https://github.com/Gitlawb/node/issues/103)) ([2620e97](https://github.com/Gitlawb/node/commit/2620e973e3cd4835ed42ddd4adcd8183b5b3080e))
## [0.4.0](https://github.com/Gitlawb/node/compare/v0.3.9...v0.4.0) (2026-06-30)
Generated
+13 -106
View File
@@ -770,23 +770,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ark-ff"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
dependencies = [
"ark-ff-asm 0.6.0",
"ark-ff-macros 0.6.0",
"ark-serialize 0.6.0",
"ark-std 0.6.0",
"digest 0.10.7",
"educe",
"num-bigint",
"num-traits",
"zeroize",
]
[[package]]
name = "ark-ff-asm"
version = "0.3.0"
@@ -817,16 +800,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-asm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.3.0"
@@ -865,19 +838,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
dependencies = [
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-serialize"
version = "0.3.0"
@@ -911,30 +871,6 @@ dependencies = [
"num-bigint",
]
[[package]]
name = "ark-serialize"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
dependencies = [
"ark-serialize-derive",
"ark-std 0.6.0",
"digest 0.10.7",
"num-bigint",
"serde_with",
]
[[package]]
name = "ark-serialize-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-std"
version = "0.3.0"
@@ -965,16 +901,6 @@ dependencies = [
"rand 0.8.6",
]
[[package]]
name = "ark-std"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
dependencies = [
"num-traits",
"rand 0.8.6",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2367,9 +2293,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
@@ -2630,7 +2556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 2.0.117",
"syn 1.0.109",
]
[[package]]
@@ -3002,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3374,11 +3300,10 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.7.1"
version = "0.4.0"
dependencies = [
"anyhow",
"gitlawb-core",
"mockito",
"reqwest",
"tracing",
"tracing-subscriber",
@@ -3386,7 +3311,7 @@ dependencies = [
[[package]]
name = "gitlawb-attest"
version = "0.7.1"
version = "0.4.0"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3403,7 +3328,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.7.1"
version = "0.4.0"
dependencies = [
"anyhow",
"base64",
@@ -3430,7 +3355,7 @@ dependencies = [
[[package]]
name = "gitlawb-node"
version = "0.7.1"
version = "0.4.0"
dependencies = [
"alloy",
"anyhow",
@@ -3486,16 +3411,14 @@ dependencies = [
[[package]]
name = "gl"
version = "0.7.1"
version = "0.4.0"
dependencies = [
"alloy",
"anyhow",
"base64",
"chrono",
"clap",
"dirs",
"gitlawb-core",
"icaptcha-client",
"mockito",
"reqwest",
"serde",
@@ -3890,21 +3813,6 @@ dependencies = [
"cc",
]
[[package]]
name = "icaptcha-client"
version = "0.4.0"
dependencies = [
"anyhow",
"gitlawb-core",
"mockito",
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -5815,15 +5723,14 @@ dependencies = [
[[package]]
name = "ruint"
version = "1.20.0"
version = "1.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
dependencies = [
"alloy-rlp",
"ark-ff 0.3.0",
"ark-ff 0.4.2",
"ark-ff 0.5.0",
"ark-ff 0.6.0",
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
@@ -5897,7 +5804,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6791,7 +6698,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
-1
View File
@@ -6,7 +6,6 @@ members = [
"crates/gl",
"crates/git-remote-gitlawb",
"crates/gitlawb-attest",
"crates/icaptcha-client",
]
[workspace.package]
+5 -18
View File
@@ -8,33 +8,20 @@ FROM rust:1.91-bookworm AS builder
WORKDIR /build
# Cache dependencies first for faster rebuilds.
# EVERY workspace member's manifest must be copied here, not just the ones this
# image ships: cargo loads the whole workspace before it resolves anything, so a
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
# layer before a single crate is fetched.
# Cache dependencies first for faster rebuilds
COPY Cargo.toml Cargo.lock ./
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
COPY crates/gl/Cargo.toml crates/gl/
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
# Now copy real sources and build for real.
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
@@ -47,7 +34,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
target/release/.fingerprint/gl-* \
target/release/.fingerprint/git-remote-gitlawb-* && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
# ── Runtime stage ───────────────────────────────────────────────────────────
+2 -5
View File
@@ -3,10 +3,7 @@
# Used by scripts/build-bins.sh — not used for node deployment.
ARG TARGET=x86_64-unknown-linux-musl
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
# base image here fails only when someone runs scripts/build-bins.sh.
FROM rust:1.91-bookworm AS builder
FROM rust:1.85-bookworm AS builder
ARG TARGET
WORKDIR /build
@@ -26,7 +23,7 @@ COPY crates/ crates/
# Configure cross-linker for aarch64
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
FROM debian:bookworm-slim
ARG TARGET
+3 -44
View File
@@ -185,45 +185,6 @@ git clone gitlawb://did:key:z6Mk.../my-repo
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
### Full lifecycle against an iCaptcha-enforcing node
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
1. **RFC 9421 HTTP Signatures** — every write is signed by your identity key. `gl`
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
locally (arithmetic / algebra / sequence), and **retries the same signed request**
with the `x-icaptcha-proof` header — no manual steps, no env vars.
```bash
gl identity new # create did:key identity
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
gl doctor # preflight: identity, node, version, iCaptcha
```
Notes:
- **`requesterId` is always your DID.** The proof's `sub` claim must equal the
authenticated signer; `gl`/helper set this automatically and the node enforces
`sub == authenticated DID` (so a proof minted for another identity is rejected).
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
solving and use, the client transparently solves a fresh one and retries.
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
`git push` is **signed-only** (owner signature is the gate — no per-push challenge);
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
clear `404`, never a placeholder.
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
`https` origin whose host is allowlisted (that URL or the public default), and
sends the bearer token **only** to your configured origin — never to a URL a
node advertises — so a hostile node can't capture the key or redirect the solve.
---
## Architecture
@@ -302,21 +263,20 @@ POST /api/v1/bounties/{id}/...
POST /{owner}/{repo}/git-receive-pack
```
These peer write routes support staged rollout:
Peer write routes support staged rollout:
```txt
POST /api/v1/peers/announce
POST /api/v1/sync/notify
POST /api/v1/sync/trigger
```
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Once all live peers upgrade, operators can set:
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted, but signed requests are verified when signature headers are present. Once all live peers upgrade, operators can set:
```bash
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
```
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
---
## Configuration
@@ -343,7 +303,6 @@ Important node settings:
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
| `GITLAWB_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. |
-112
View File
@@ -1,112 +0,0 @@
# Dependency allowlist for the `gitlawb-core` crate.
#
# gitlawb-core is the shared-primitives crate every consumer embeds (the `gl`
# CLI, git-remote-gitlawb, the node daemon). It must stay lean so those
# consumers do not inherit daemon-weight dependencies. This file is the source
# of truth for the crates gitlawb-core's NORMAL (non-dev, non-build) transitive
# dependency closure is allowed to contain. CI (`scripts/check-gitlawb-core-deps.sh`,
# wired into the `core-deps-purity` job in .github/workflows/pr-checks.yml)
# fails if gitlawb-core's tree contains any crate not listed here.
#
# The allowlist is exhaustive-by-construction: a NEW dependency reds CI whether
# or not anyone thought to ban it, which a denylist cannot do.
#
# Measured in-workspace, so feature unification across the workspace can, in
# rare cases, surface a crate here that gitlawb-core would not pull standalone.
# An unexpected new entry is a prompt to check what pulled it in, not noise to
# rubber-stamp.
#
# Regenerate (from repo root, after an INTENTIONAL dependency change):
# cargo tree -p gitlawb-core --edges normal --prefix none \
# | sed -E 's/ v[0-9].*$//' \
# | grep -v '^gitlawb-core$' \
# | sort -u
# then paste the result below this header block. (The checker snapshots and
# restores Cargo.lock; a manual regen may refresh it — `git checkout Cargo.lock`
# afterward if you did not intend that.)
#
# Lines starting with `#` and blank lines are ignored by the checker.
aead
anyhow
base-x
base256emoji
base64
base64ct
block-buffer
cfg-if
chacha20
chacha20poly1305
chrono
cid
cipher
const-oid
const-str
core2
cpufeatures
crypto-common
crypto_box
crypto_secretbox
curve25519-dalek
curve25519-dalek-derive
data-encoding
data-encoding-macro
data-encoding-macro-internal
der
digest
ed25519
ed25519-dalek
equivalent
generic-array
getrandom
hashbrown
hex
iana-time-zone
indexmap
inout
itoa
libc
match-lookup
memchr
multibase
multihash
multihash-codetable
multihash-derive
multihash-derive-impl
num-traits
opaque-debug
pem-rfc7468
pkcs8
poly1305
ppv-lite86
proc-macro-crate
proc-macro2
quote
rand
rand_chacha
rand_core
salsa20
serde
serde_core
serde_derive
serde_json
sha2
signature
spki
subtle
syn
synstructure
thiserror
thiserror-impl
toml_datetime
toml_edit
toml_parser
typenum
unicode-ident
universal-hash
unsigned-varint
uuid
winnow
zerocopy
zeroize
zeroize_derive
zmij
+1 -4
View File
@@ -1,7 +1,7 @@
[package]
name = "git-remote-gitlawb"
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
version = "0.7.1" # x-release-please-version
version = "0.4.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -16,6 +16,3 @@ anyhow = { workspace = true }
reqwest = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
mockito = "1"
+35 -830
View File
@@ -14,13 +14,7 @@
//! # Protocol flow (connect capability)
//! capabilities → "connect\n\n"
//! connect git-upload-pack → GET /info/refs | POST /git-upload-pack
//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack
//! Push (git-receive-pack) is RFC-9421 signed from the first request when an
//! identity keypair is present, since a push can never be anonymous. Fetch
//! (git-upload-pack) stays anonymous and is signed only on a single retry, after
//! the node denies the anonymous advertisement with 404 — so a public clone never
//! discloses the caller's DID, while a private repo's owner (or an authorized
//! reader) still authenticates when the node demands it.
//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack (+ auth header)
use anyhow::{bail, Context, Result};
use gitlawb_core::http_sig::sign_request;
@@ -69,10 +63,7 @@ fn main() -> Result<()> {
let repo_base = format!("{}/{}/{}", node_base, short_owner, repo_name);
tracing::debug!("repo_base: {repo_base}");
// Load keypair for signing requests (optional). Push always signs when a key is
// present; fetch signs only if the node 404s the anonymous advertisement, so
// public fetches stay anonymous. Absent a key, public fetch still works and push
// falls back to unsigned (v0.1 local alpha only).
// Load keypair for signing push requests (optional — push still works unsigned in v0.1)
let keypair = load_keypair();
run_helper(&repo_base, keypair.as_ref())
@@ -124,7 +115,7 @@ fn help_text() -> String {
\n\
ENVIRONMENT:\n\
\x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\
\x20 GITLAWB_KEY Identity PEM path for signed pushes (default: ~/.gitlawb/identity.pem)\n\
\x20 GITLAWB_LOG Log filter (default: warn)\n\
\n\
FLAGS:\n\
@@ -188,11 +179,11 @@ fn run_helper(repo_base: &str, keypair: Option<&Keypair>) -> Result<()> {
// ── HTTP proxy for git smart protocol ────────────────────────────────────────
fn handle_connect<R: Read>(
fn handle_connect(
repo_base: &str,
service: &str,
keypair: Option<&Keypair>,
stdin: &mut R,
stdin: &mut io::BufReader<io::Stdin>,
) -> Result<()> {
match service {
"git-upload-pack" | "git-receive-pack" => {}
@@ -207,52 +198,19 @@ fn handle_connect<R: Read>(
//
// The server advertises its refs. git reads this to decide what to fetch/push.
// Signing policy for this exchange, carried through to the Phase-2 POST:
// - git-receive-pack (push): sign from the first request; a push is
// signature-gated and can never be anonymous.
// - git-upload-pack (fetch): start anonymous so a public clone never discloses
// the caller's DID, then escalate to a single signed retry only if the node
// denies the anonymous advertisement with 404 (how it withholds a private
// repo from an unauthenticated reader).
let mut signing_key = if service == "git-receive-pack" {
keypair
} else {
None
};
let refs_url = format!("{}/info/refs?service={}", repo_base, service);
tracing::debug!("GET {refs_url}");
let mut refs_resp = build_advertisement_request(&client, &refs_url, signing_key)
let refs_resp = client
.get(&refs_url)
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
.send()
.with_context(|| format!("GET {refs_url}"))?;
// Fetch escalation: retry the advertisement signed once when the anonymous try
// is denied and we hold an identity. A public repo answers 200 on the first
// (anonymous) request and never reaches here, so it stays DID-private.
if service == "git-upload-pack"
&& refs_resp.status() == reqwest::StatusCode::NOT_FOUND
&& keypair.is_some()
{
tracing::debug!("anonymous info/refs returned 404; retrying signed");
signing_key = keypair;
refs_resp = build_advertisement_request(&client, &refs_url, signing_key)
.send()
.with_context(|| format!("GET {refs_url} (signed retry)"))?;
}
if !refs_resp.status().is_success() {
let status = refs_resp.status();
let body = read_error_body(refs_resp);
bail!(
"{}",
http_error_message(
"GET",
"/info/refs",
status,
&body,
Some("— is the repo registered on this node?")
)
"GET /info/refs returned {} — is the repo registered on this node?",
refs_resp.status()
);
}
@@ -308,7 +266,29 @@ fn handle_connect<R: Read>(
let post_url = format!("{}/{}", repo_base, service);
tracing::debug!("POST {post_url} ({} bytes)", request_body.len());
let req = build_pack_post_request(&client, &post_url, service, &request_body, signing_key);
// Extract the URL path for signing (e.g., "/z6Mk.../my-repo/git-receive-pack")
let path_for_sig = url_path(&post_url);
let mut req = client
.post(&post_url)
.header("Content-Type", format!("application/x-{}-request", service))
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0");
// Add RFC 9421 HTTP Signature auth on push operations
if service == "git-receive-pack" {
if let Some(kp) = keypair {
let signed = sign_request(kp, "POST", &path_for_sig, &request_body);
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
tracing::debug!("attached RFC 9421 HTTP Signature (DID: {})", kp.did());
} else {
tracing::warn!(
"no identity keypair found — push will be unsigned (v0.1 local alpha only)"
);
}
}
// Attach the body after signing so the pack bytes are moved, not cloned —
// packs can be large and the clone doubled peak memory on push.
@@ -318,10 +298,7 @@ fn handle_connect<R: Read>(
.with_context(|| format!("POST {post_url}"))?;
if !pack_resp.status().is_success() {
let status = pack_resp.status();
let body = read_error_body(pack_resp);
let path = format!("/{service}");
bail!("{}", http_error_message("POST", &path, status, &body, None));
bail!("POST /{} returned {}", service, pack_resp.status());
}
let pack_bytes = pack_resp.bytes().context("reading pack response")?;
@@ -333,68 +310,6 @@ fn handle_connect<R: Read>(
Ok(())
}
// ── Smart-protocol request builders ───────────────────────────────────────────
const USER_AGENT: &str = "git/2.0 git-remote-gitlawb/0.1.0";
/// Build the Phase-1 ref-advertisement GET, signing it when an identity is
/// present. The node gates the ref advertisement on read visibility for BOTH
/// services, so a private repo's advertisement is denied (404) to an
/// unauthenticated caller; without this signature the repo's own owner can
/// neither fetch (upload-pack) nor push (receive-pack) it. Public repos still
/// work anonymously (no keypair present, or the gate admits anonymous). Sign over
/// the path *and* query (?service=...) because the node verifies the signature
/// over its path_and_query.
fn build_advertisement_request(
client: &reqwest::blocking::Client,
refs_url: &str,
keypair: Option<&Keypair>,
) -> reqwest::blocking::RequestBuilder {
let mut req = client.get(refs_url).header("User-Agent", USER_AGENT);
if let Some(kp) = keypair {
let signed = sign_request(kp, "GET", &url_path(refs_url), b"");
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
tracing::debug!("signed info/refs advertisement (DID: {})", kp.did());
}
req
}
/// Build the Phase-2 pack POST, signing it when an identity is present, for BOTH
/// services. The node read-gates the git-upload-pack POST with visibility_check
/// at "/", and separately owner-gates the git-receive-pack POST (signature
/// required, enforced by middleware). The Phase-1 advertisement signature does
/// not carry to this separate request, so a private repo's owner must
/// authenticate here too or their fetch/push is denied.
/// Public-repo fetch still works anonymously when no keypair is present. The body
/// is signed (content-digest) but NOT attached here, so the caller can move the
/// (possibly large) pack bytes into `.body()` rather than clone them.
fn build_pack_post_request(
client: &reqwest::blocking::Client,
post_url: &str,
service: &str,
body: &[u8],
keypair: Option<&Keypair>,
) -> reqwest::blocking::RequestBuilder {
let mut req = client
.post(post_url)
.header("Content-Type", format!("application/x-{}-request", service))
.header("User-Agent", USER_AGENT);
if let Some(kp) = keypair {
let signed = sign_request(kp, "POST", &url_path(post_url), body);
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
tracing::debug!("signed {service} POST (DID: {})", kp.did());
} else if service == "git-receive-pack" {
tracing::warn!("no identity keypair found, push will be unsigned (v0.1 local alpha only)");
}
req
}
// ── URL helpers ───────────────────────────────────────────────────────────────
/// Parse a `gitlawb://did:key:z6Mk.../repo-name` URL.
@@ -432,7 +347,7 @@ fn parse_gitlawb_url(url: &str) -> Result<(String, String, String)> {
///
/// We also handle the flush-only case (`"0000"`) that git sends when it already
/// has everything it needs (up-to-date clone).
fn read_upload_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
fn read_upload_pack_request(stdin: &mut io::BufReader<io::Stdin>) -> Result<Vec<u8>> {
let mut buf = Vec::new();
loop {
@@ -518,85 +433,6 @@ fn url_path(url: &str) -> String {
.unwrap_or_else(|| "/".to_string())
}
const MAX_ERROR_BODY_BYTES: u64 = 4096;
const MAX_ERROR_BODY_CHARS: usize = 2000;
fn read_error_body(mut response: reqwest::blocking::Response) -> String {
let mut body = Vec::new();
let mut limited = (&mut response).take(MAX_ERROR_BODY_BYTES);
if limited.read_to_end(&mut body).is_err() {
return String::new();
}
String::from_utf8_lossy(&body).into_owned()
}
fn http_error_message(
method: &str,
path: &str,
status: reqwest::StatusCode,
body: &str,
empty_body_hint: Option<&str>,
) -> String {
let mut message = format!("{method} {path} returned {status}");
let body = safe_error_body_excerpt(body);
if !body.is_empty() {
message.push_str(": ");
message.push_str(&body);
} else if let Some(hint) = empty_body_hint {
message.push(' ');
message.push_str(hint);
}
message
}
fn safe_error_body_excerpt(body: &str) -> String {
let mut excerpt = String::new();
let mut pending_space = false;
let mut kept_chars = 0;
for ch in body.trim().chars() {
if kept_chars >= MAX_ERROR_BODY_CHARS {
break;
}
if ch.is_control() {
if matches!(ch, '\n' | '\r' | '\t') {
pending_space = true;
}
continue;
}
if gitlawb_core::sanitize::is_bidi_format(ch) {
// Drop Cf bidi/format controls (INV-6, #183) without marking
// pending_space: unlike a newline/tab they are not whitespace, so
// "a\u{202e}c" collapses to "ac" with no phantom gap.
continue;
}
if ch.is_whitespace() {
pending_space = true;
continue;
}
if pending_space && !excerpt.is_empty() {
excerpt.push(' ');
kept_chars += 1;
if kept_chars >= MAX_ERROR_BODY_CHARS {
break;
}
}
excerpt.push(ch);
kept_chars += 1;
pending_space = false;
}
excerpt
}
// ── Keypair loading ───────────────────────────────────────────────────────────
fn load_keypair() -> Option<Keypair> {
@@ -640,467 +476,6 @@ fn resolve_key_path() -> std::path::PathBuf {
#[cfg(test)]
mod tests {
use super::*;
use std::net::{TcpListener, TcpStream};
use std::thread::JoinHandle;
struct TestResponse {
request_line: &'static str,
status: &'static str,
body: &'static str,
}
fn serve_http(responses: Vec<TestResponse>) -> (String, JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = std::thread::spawn(move || {
let mut requests = Vec::new();
for response in responses {
let (mut stream, _) = listener.accept().unwrap();
let request = read_http_request(&mut stream);
assert!(
request.starts_with(response.request_line),
"unexpected request:\n{request}"
);
let response_text = format!(
"HTTP/1.1 {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
response.status,
response.body.len(),
response.body
);
stream.write_all(response_text.as_bytes()).unwrap();
requests.push(request);
}
requests
});
(base_url, handle)
}
fn read_http_request(stream: &mut TcpStream) -> String {
let mut buf = Vec::new();
let mut chunk = [0u8; 512];
let mut header_len = None;
loop {
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if header_len.is_none() {
header_len = buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4);
}
if let Some(header_len) = header_len {
let header = String::from_utf8_lossy(&buf[..header_len]);
let content_len = header
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if buf.len() >= header_len + content_len {
break;
}
}
}
String::from_utf8_lossy(&buf).into_owned()
}
/// The regression that round-1 missed: the Phase-2 `git-upload-pack` POST was
/// left unsigned, so an owner's fetch of a private repo cleared the (now signed)
/// advertisement and then 404'd on the pack POST. Drive BOTH request builders
/// against a mock node and assert the RFC-9421 headers actually go out on the
/// wire for BOTH services when a keypair is present (the mock only matches when
/// the headers exist, so `.assert()` fails if any are dropped).
#[test]
fn advertisement_and_pack_post_are_signed_for_both_services_with_keypair() {
let kp = Keypair::generate();
let client = reqwest::blocking::Client::new();
let body = b"0009done\n".to_vec();
for service in ["git-upload-pack", "git-receive-pack"] {
let mut server = mockito::Server::new();
let repo_base = format!("{}/zOwner/myrepo", server.url());
let get_mock = server
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.match_header("content-digest", mockito::Matcher::Any)
.with_status(200)
.create();
let refs_url = format!("{repo_base}/info/refs?service={service}");
let resp = build_advertisement_request(&client, &refs_url, Some(&kp))
.send()
.unwrap();
assert!(resp.status().is_success());
get_mock.assert();
let post_mock = server
.mock("POST", mockito::Matcher::Regex(format!("/{service}$")))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.match_header("content-digest", mockito::Matcher::Any)
.with_status(200)
.create();
let post_url = format!("{repo_base}/{service}");
let resp = build_pack_post_request(&client, &post_url, service, &body, Some(&kp))
.body(body.clone())
.send()
.unwrap();
assert!(resp.status().is_success());
post_mock.assert();
}
}
/// Without an identity, the pack POST must go out UNSIGNED so public fetch (and
/// alpha unsigned push) still work. `Matcher::Missing` matches only when the
/// header is absent, so the mock is hit only if NO signature was attached, for
/// BOTH services (the receive-pack iteration also exercises the no-keypair warn
/// branch and confirms it does not block the request).
#[test]
fn pack_post_is_unsigned_without_keypair_for_both_services() {
let client = reqwest::blocking::Client::new();
let body = b"0009done\n".to_vec();
for service in ["git-upload-pack", "git-receive-pack"] {
let mut server = mockito::Server::new();
let post_url = format!("{}/zOwner/myrepo/{service}", server.url());
let post_mock = server
.mock("POST", mockito::Matcher::Regex(format!("/{service}$")))
.match_header("signature", mockito::Matcher::Missing)
.match_header("signature-input", mockito::Matcher::Missing)
.match_header("content-digest", mockito::Matcher::Missing)
.with_status(200)
.create();
let resp = build_pack_post_request(&client, &post_url, service, &body, None)
.body(body.clone())
.send()
.unwrap();
assert!(resp.status().is_success());
post_mock.assert();
}
}
/// Symmetric negative case for Phase 1: with no identity the advertisement GET
/// carries no signature, so public clones stay anonymous.
#[test]
fn advertisement_get_is_unsigned_without_keypair() {
let client = reqwest::blocking::Client::new();
let mut server = mockito::Server::new();
let refs_url = format!(
"{}/zOwner/myrepo/info/refs?service=git-upload-pack",
server.url()
);
let get_mock = server
.mock("GET", mockito::Matcher::Regex(r"/info/refs".to_string()))
.match_header("signature", mockito::Matcher::Missing)
.match_header("signature-input", mockito::Matcher::Missing)
.match_header("content-digest", mockito::Matcher::Missing)
.with_status(200)
.create();
let resp = build_advertisement_request(&client, &refs_url, None)
.send()
.unwrap();
assert!(resp.status().is_success());
get_mock.assert();
}
// ── Phase-1 retry-on-404 (P2, #119): fetch stays anonymous until the node
// denies it, then escalates to ONE signed retry. Drives handle_connect end to
// end against the recording mock, asserting what actually goes on the wire.
// A request is "signed" iff it carries the `signature-input` header.
/// Public fetch: the node serves the anonymous advertisement (200), so the
/// client never signs — not the advertisement GET, not the upload-pack POST —
/// even though an identity is present. This is the privacy fix: a public clone
/// must not disclose the caller's DID.
#[test]
fn fetch_public_stays_anonymous_even_with_keypair() {
let kp = Keypair::generate();
let (base_url, server) = serve_http(vec![
TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
status: "200 OK",
body: "0000",
},
TestResponse {
request_line: "POST /zOwner/myrepo/git-upload-pack",
status: "200 OK",
body: "",
},
]);
let repo_base = format!("{base_url}/zOwner/myrepo");
let mut stdin = std::io::Cursor::new(b"0009done\n".to_vec());
handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin)
.expect("public fetch should succeed");
let requests = server.join().unwrap();
assert_eq!(requests.len(), 2, "public fetch must not retry");
assert!(
!requests[0].to_lowercase().contains("signature-input"),
"the advertisement GET must be anonymous for a public fetch"
);
assert!(
!requests[1].to_lowercase().contains("signature-input"),
"the upload-pack POST must be anonymous for a public fetch"
);
}
/// Private fetch: the anonymous advertisement is denied (404); with an identity
/// present the client retries the GET signed and — because Phase 1 needed
/// signing — also signs the Phase-2 upload-pack POST. The FIRST request is still
/// anonymous: the DID is not disclosed until the node demands authentication.
#[test]
fn fetch_private_retries_signed_on_404_and_signs_the_pack_post() {
let kp = Keypair::generate();
let (base_url, server) = serve_http(vec![
TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
status: "404 Not Found",
body: r#"{"message":"repository is not registered on this node"}"#,
},
TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
status: "200 OK",
body: "0000",
},
TestResponse {
request_line: "POST /zOwner/myrepo/git-upload-pack",
status: "200 OK",
body: "",
},
]);
let repo_base = format!("{base_url}/zOwner/myrepo");
let mut stdin = std::io::Cursor::new(b"0009done\n".to_vec());
handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin)
.expect("signed retry should succeed");
let requests = server.join().unwrap();
assert_eq!(
requests.len(),
3,
"expected anon GET, signed GET retry, signed POST"
);
assert!(
!requests[0].to_lowercase().contains("signature-input"),
"the first advertisement GET must be anonymous"
);
assert!(
requests[1].to_lowercase().contains("signature-input"),
"the retry advertisement GET must be signed"
);
assert!(
requests[2].to_lowercase().contains("signature-input"),
"the upload-pack POST must be signed once the fetch needed auth"
);
}
/// Private fetch with no identity: the anonymous advertisement 404s and there is
/// no keypair to retry with, so the client surfaces the error after exactly one
/// request — no retry, no hang.
#[test]
fn fetch_without_keypair_does_not_retry_on_404() {
let (base_url, server) = serve_http(vec![TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
status: "404 Not Found",
body: r#"{"message":"not found"}"#,
}]);
let repo_base = format!("{base_url}/zOwner/myrepo");
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
assert!(err.to_string().contains("returned 404 Not Found"));
let requests = server.join().unwrap();
assert_eq!(requests.len(), 1, "no keypair means no signed retry");
assert!(!requests[0].to_lowercase().contains("signature-input"));
}
/// Private fetch where the SIGNED retry is also denied (caller is not a reader):
/// the client retries exactly once, then surfaces the 404 rather than looping.
/// Bounds the escalation to a single signed attempt.
#[test]
fn fetch_signed_retry_still_denied_surfaces_error_once() {
let kp = Keypair::generate();
let (base_url, server) = serve_http(vec![
TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
status: "404 Not Found",
body: r#"{"message":"not found"}"#,
},
TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-upload-pack",
status: "404 Not Found",
body: r#"{"message":"not found"}"#,
},
]);
let repo_base = format!("{base_url}/zOwner/myrepo");
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
let err = handle_connect(&repo_base, "git-upload-pack", Some(&kp), &mut stdin).unwrap_err();
assert!(err.to_string().contains("returned 404 Not Found"));
let requests = server.join().unwrap();
assert_eq!(requests.len(), 2, "exactly one signed retry, then surface");
assert!(!requests[0].to_lowercase().contains("signature-input"));
assert!(requests[1].to_lowercase().contains("signature-input"));
}
/// Push (git-receive-pack) is unchanged: it signs from the FIRST request — you
/// cannot push anonymously — so there is no anonymous-first probe for push.
#[test]
fn push_advertisement_is_signed_from_the_first_request() {
let kp = Keypair::generate();
let (base_url, server) = serve_http(vec![
TestResponse {
request_line: "GET /zOwner/myrepo/info/refs?service=git-receive-pack",
status: "200 OK",
body: "0000",
},
TestResponse {
request_line: "POST /zOwner/myrepo/git-receive-pack",
status: "200 OK",
body: "",
},
]);
let repo_base = format!("{base_url}/zOwner/myrepo");
let mut stdin = std::io::Cursor::new(b"0000".to_vec());
handle_connect(&repo_base, "git-receive-pack", Some(&kp), &mut stdin)
.expect("push should succeed");
let requests = server.join().unwrap();
assert_eq!(requests.len(), 2);
assert!(
requests[0].to_lowercase().contains("signature-input"),
"push advertisement must be signed from the first request"
);
assert!(
requests[1].to_lowercase().contains("signature-input"),
"push POST must be signed"
);
}
/// Cross-crate seam: the signature the CLIENT actually emits (via the real
/// request builders) verifies under the SAME `gitlawb_core::http_sig`
/// primitives the node's `require_signature` uses, over the `@path` the client
/// genuinely transmits (derived from the built reqwest request, exactly as the
/// node derives it from `uri.path_and_query()`). A tampered `@path` must fail:
/// this is the byte-match the whole A1 client fix depends on, now executed end
/// to end (sign here, verify with the node's verifier), not reasoned.
#[test]
fn client_signature_verifies_under_node_verification_for_both_services() {
use gitlawb_core::http_sig::{build_signing_string, compute_content_digest, HttpSignature};
use gitlawb_core::identity::verify;
use std::collections::HashMap;
let kp = Keypair::generate();
let client = reqwest::blocking::Client::new();
let body = b"0009done\n".to_vec();
// Re-implements the node's require_signature verification (auth/mod.rs):
// parse headers, recompute content-digest from the body, rebuild the signing
// string over @method/@path/content-digest, Ed25519-verify. Ok iff the node
// would accept it.
let node_verifies = |method: &str,
path_and_query: &str,
body: &[u8],
sig_input: &str,
sig_header: &str,
content_digest: &str|
-> anyhow::Result<()> {
let sig = HttpSignature::parse(sig_input, sig_header)?;
sig.check_created()?;
assert!(
sig.missing_components().is_empty(),
"signature must cover all required components"
);
assert_eq!(sig.alg, "ed25519");
assert_eq!(
content_digest,
compute_content_digest(body),
"content-digest must match the body"
);
let vk = sig.key_id.to_verifying_key()?;
let mut values = HashMap::new();
values.insert("@method".to_string(), method.to_uppercase());
values.insert("@path".to_string(), path_and_query.to_string());
values.insert("content-digest".to_string(), content_digest.to_string());
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(sig_input);
let components: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string = build_signing_string(&components, sig_params_value, &values)?;
let sig_array: [u8; 64] = sig.signature_bytes.as_slice().try_into()?;
verify(&vk, signing_string.as_bytes(), &sig_array)?;
Ok(())
};
// @path exactly as the node reconstructs it from the request it receives.
let path_and_query = |req: &reqwest::blocking::Request| match req.url().query() {
Some(q) => format!("{}?{}", req.url().path(), q),
None => req.url().path().to_string(),
};
let header = |req: &reqwest::blocking::Request, name: &str| {
req.headers()
.get(name)
.unwrap_or_else(|| panic!("missing {name}"))
.to_str()
.unwrap()
.to_string()
};
for service in ["git-upload-pack", "git-receive-pack"] {
// Phase-1 advertisement GET (empty body).
let refs_url = format!("http://node.example/zOwner/myrepo/info/refs?service={service}");
let req = build_advertisement_request(&client, &refs_url, Some(&kp))
.build()
.unwrap();
node_verifies(
"GET",
&path_and_query(&req),
b"",
&header(&req, "signature-input"),
&header(&req, "signature"),
&header(&req, "content-digest"),
)
.expect("client GET signature must verify under the node's verifier");
// Phase-2 pack POST (real body).
let post_url = format!("http://node.example/zOwner/myrepo/{service}");
let req = build_pack_post_request(&client, &post_url, service, &body, Some(&kp))
.body(body.clone())
.build()
.unwrap();
let pq = path_and_query(&req);
node_verifies(
"POST",
&pq,
&body,
&header(&req, "signature-input"),
&header(&req, "signature"),
&header(&req, "content-digest"),
)
.expect("client POST signature must verify under the node's verifier");
// Negative: a tampered @path (the A1 attack surface) must NOT verify.
let tampered = pq.replace(service, "git-evil");
assert!(
node_verifies(
"POST",
&tampered,
&body,
&header(&req, "signature-input"),
&header(&req, "signature"),
&header(&req, "content-digest"),
)
.is_err(),
"a tampered @path must fail verification"
);
}
}
#[test]
fn parse_standard_url() {
@@ -1149,176 +524,6 @@ mod tests {
);
}
#[test]
fn http_error_message_sanitizes_response_body_controls() {
let message = http_error_message(
"GET",
"/info/refs",
reqwest::StatusCode::BAD_GATEWAY,
"\u{1b}[2Jrepository\r\nis\tblocked\u{7}",
None,
);
assert!(message.contains("repository is blocked"));
assert!(!message.contains('\u{1b}'));
assert!(!message.contains('\u{7}'));
assert!(!message.contains('\r'));
assert!(!message.contains('\n'));
}
#[test]
fn http_error_message_strips_bidi_format_controls() {
// Each Cf bidi class must be stripped (INV-6, #183), not just U+202E:
// override (RLO), embedding + pop (LRE/PDF), isolate (LRI/PDI), and marks
// (LRM/ALM). Asserted per code point so a partial predicate fails loudly.
// The visible words must survive and JOIN with no phantom space at the
// strip points: a bidi char is dropped like a control byte, but unlike a
// newline/tab it is not whitespace, so it must not set pending_space. The
// leading bidi char (index 0) also exercises the empty-excerpt path.
let body = "\u{202e}err\u{202a}\u{202c}\u{2066}\u{2069}\u{200e}\u{061c}ok";
let message = http_error_message(
"GET",
"/info/refs",
reqwest::StatusCode::BAD_GATEWAY,
body,
None,
);
for c in [
'\u{202e}', '\u{202a}', '\u{202c}', '\u{2066}', '\u{2069}', '\u{200e}', '\u{061c}',
] {
assert!(
!message.contains(c),
"bidi U+{:04X} leaked: {message:?}",
c as u32
);
}
assert!(
message.contains("errok"),
"words should join with no phantom space: {message:?}"
);
}
#[test]
fn http_error_message_preserves_legitimate_and_rtl_text() {
// Must not over-strip: plain text, a genuine RTL SCRIPT letter (Arabic
// U+0627, category Lo), and ZWJ (U+200D, a legitimate Cf char) survive.
let message = http_error_message(
"GET",
"/info/refs",
reqwest::StatusCode::BAD_GATEWAY,
"ok \u{0627}\u{200D}b",
None,
);
assert!(
message.ends_with("ok \u{0627}\u{200D}b"),
"legitimate text was altered: {message:?}"
);
}
#[test]
fn http_error_message_truncates_long_response_body() {
let body = "x".repeat(MAX_ERROR_BODY_CHARS + 100);
let message = http_error_message(
"POST",
"/git-receive-pack",
reqwest::StatusCode::FORBIDDEN,
&body,
None,
);
let prefix = "POST /git-receive-pack returned 403 Forbidden: ";
assert_eq!(message.len(), prefix.len() + MAX_ERROR_BODY_CHARS);
assert!(message.starts_with(prefix));
}
#[test]
fn connect_get_error_includes_response_body() {
let (base_url, server) = serve_http(vec![TestResponse {
request_line: "GET /z6Mk/myrepo/info/refs?service=git-upload-pack HTTP/1.1",
status: "404 Not Found",
body: r#"{"message":"repository is not registered on this node"}"#,
}]);
let repo_base = format!("{base_url}/z6Mk/myrepo");
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
let message = err.to_string();
assert!(message.contains("GET /info/refs returned 404 Not Found"));
assert!(message.contains("repository is not registered on this node"));
let requests = server.join().unwrap();
assert_eq!(requests.len(), 1);
}
#[test]
fn connect_error_strips_bidi_from_response_body() {
// Transport-path proof: a bidi override in the node's real HTTP error body
// must not reach the surfaced error text. Drives the full handle_connect
// error path, not just the safe_error_body_excerpt unit boundary (INV-6).
let (base_url, server) = serve_http(vec![TestResponse {
request_line: "GET /z6Mk/myrepo/info/refs?service=git-upload-pack HTTP/1.1",
status: "404 Not Found",
body: "not\u{202e}found",
}]);
let repo_base = format!("{base_url}/z6Mk/myrepo");
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
let message = err.to_string();
assert!(
!message.contains('\u{202e}'),
"bidi override reached the terminal: {message:?}"
);
assert!(
message.contains("notfound"),
"body should survive with the bidi char stripped: {message:?}"
);
let _ = server.join();
}
#[test]
fn connect_post_error_includes_response_body() {
let (base_url, server) = serve_http(vec![
TestResponse {
request_line: "GET /z6Mk/myrepo/info/refs?service=git-receive-pack HTTP/1.1",
status: "200 OK",
body: "",
},
TestResponse {
request_line: "POST /z6Mk/myrepo/git-receive-pack HTTP/1.1",
status: "403 Forbidden",
body: r#"{"error":"forbidden","message":"push rejected: only the repo owner may push"}"#,
},
]);
let repo_base = format!("{base_url}/z6Mk/myrepo");
let mut stdin = std::io::Cursor::new(b"0000".to_vec());
let err = handle_connect(&repo_base, "git-receive-pack", None, &mut stdin).unwrap_err();
let message = err.to_string();
assert!(message.contains("POST /git-receive-pack returned 403 Forbidden"));
assert!(message.contains("push rejected: only the repo owner may push"));
let requests = server.join().unwrap();
assert_eq!(requests.len(), 2);
assert!(requests[1].contains("\r\n\r\n0000"));
}
#[test]
fn url_path_preserves_query_for_signed_advertisement() {
// The Phase-1 advertisement GET is signed over url_path(refs_url), and the
// node verifies the signature over its path_and_query, so the ?service=
// query MUST survive verbatim or the signatures will not byte-match.
assert_eq!(
url_path("http://127.0.0.1:7545/z6Mk/myrepo/info/refs?service=git-upload-pack"),
"/z6Mk/myrepo/info/refs?service=git-upload-pack"
);
assert_eq!(
url_path("http://127.0.0.1:7545/z6Mk/myrepo/info/refs?service=git-receive-pack"),
"/z6Mk/myrepo/info/refs?service=git-receive-pack"
);
}
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version = "0.7.1" # x-release-please-version
version = "0.4.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.7.1" # x-release-please-version
version = "0.4.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
-1
View File
@@ -5,7 +5,6 @@ pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod identity;
pub mod sanitize;
pub mod ucan;
pub use error::Error;
-69
View File
@@ -1,69 +0,0 @@
//! Text sanitization predicates shared across the workspace.
//!
//! Untrusted peer/remote bytes that reach an operator's terminal (node error
//! bodies, node-advertised strings) must be stripped of BOTH `Cc` control bytes
//! (`char::is_control()`) AND `Cf` bidi/format controls before display: a
//! right-to-left override or directional isolate can visually reorder the line
//! and spoof the text (Trojan Source, CWE-451). This is INV-6.
//!
//! `is_bidi_format` is the single workspace definition of the second half of
//! that obligation. It was split out of `gl`'s local copy so the two terminal
//! sanitizers that shipped with only the `Cc` half (#183) — and any future
//! sanitizer — reuse one audited predicate instead of re-deriving the set.
/// True for the Unicode bidirectional/directional-isolate format characters
/// (the reordering subset of category `Cf`) that can visually reorder a terminal
/// line. These are NOT matched by [`char::is_control()`], which covers `Cc` only.
///
/// This is deliberately the reordering subset, not the whole `Cf` category:
/// legitimate `Cf` characters such as ZWJ (`U+200D`, emoji sequences) and the
/// soft hyphen (`U+00AD`) are NOT matched, so honest international text passes
/// through unchanged. Widening this set is a behavior change for every consumer.
///
/// Pair it with `!c.is_control()` in a terminal-bound sanitizer (INV-6); see the
/// wrappers in `gl`, `git-remote-gitlawb`, and `icaptcha-client`.
pub fn is_bidi_format(c: char) -> bool {
matches!(c,
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_every_reordering_code_point() {
// Asserted individually so a partial predicate (e.g. one that covers the
// marks but not the isolates) fails loudly rather than passing.
for c in [
'\u{200E}', '\u{200F}', '\u{061C}', // LRM, RLM, ALM
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', // LRE, RLE, PDF, LRO, RLO
'\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // LRI, RLI, FSI, PDI
] {
assert!(
is_bidi_format(c),
"U+{:04X} must be a bidi-format char",
c as u32
);
}
}
#[test]
fn passes_legitimate_and_control_chars() {
// Must NOT over-strip: plain text, a genuine RTL SCRIPT letter (Lo, not a
// format char), ZWJ (legitimate Cf), and soft hyphen (legitimate Cf) are
// not bidi-format. ESC is a Cc control char — owned by is_control(), not
// this predicate, which is bidi-only.
for c in ['a', '\u{0627}', '\u{200D}', '\u{00AD}', '\u{1b}'] {
assert!(
!is_bidi_format(c),
"U+{:04X} must NOT be a bidi-format char",
c as u32
);
}
}
}
+4 -67
View File
@@ -187,17 +187,11 @@ impl Ucan {
}
/// Check if this UCAN grants a specific capability on a resource.
///
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
/// requested resource/action, since a valid delegation chain can produce
/// exactly that capability (see `is_attenuated_by`).
pub fn can(&self, resource: &str, action: &str) -> bool {
self.payload.att.iter().any(|cap| {
let resource_ok = cap.with == resource || cap.with == "*";
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
resource_ok && action_ok
})
self.payload
.att
.iter()
.any(|cap| cap.with == resource && cap.can == action)
}
/// Encode to a compact JSON string (the wire format).
@@ -318,63 +312,6 @@ mod tests {
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_resource_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("*", caps::GIT_PUSH)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_repo_admin_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new(
"gitlawb://repos/test/repo",
caps::REPO_ADMIN,
)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn can_honors_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("gitlawb://repos/test/repo", "*")],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn bootstrap_ucan() {
let issuer = Keypair::generate();
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version = "0.7.1" # x-release-please-version
version = "0.4.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+130
View File
@@ -0,0 +1,130 @@
//! Admin subcommands (one-off maintenance), invoked as `gitlawb-node <cmd>`.
//! These run instead of the daemon and exit.
use anyhow::Result;
use crate::config::{Config, PurgeSpamArgs};
use crate::db::Db;
use crate::git::tigris::TigrisClient;
/// How many repos to delete per query/commit batch.
const BATCH: i64 = 500;
/// Bulk-delete repos whose name matches a Postgres regex, removing DB rows,
/// the on-disk bare repo, and (unless skipped) the Tigris archive.
///
/// Defaults to a dry run: it reports the match count and a sample and deletes
/// nothing. Pass `--execute` to actually delete.
pub async fn purge_spam(config: &Config, args: &PurgeSpamArgs) -> Result<()> {
let db = Db::connect(&config.database_url).await?;
let total = db.count_repos_by_name_regex(&args.regex).await?;
println!("repos matching /{}/ : {total}", args.regex);
let sample = db.list_repos_by_name_regex(&args.regex, 20).await?;
println!("sample (up to 20):");
for (id, name, owner, _disk) in &sample {
println!(" {name} owner={owner} id={id}");
}
if !args.execute {
println!(
"\nDRY RUN — nothing deleted. Re-run with --execute to delete these {total} repos \
({}).",
if args.bulk {
"bulk set-based DB delete + on-disk .git"
} else if args.skip_tigris {
"DB rows + on-disk .git"
} else {
"DB rows + on-disk .git + Tigris archive"
}
);
return Ok(());
}
// Bulk mode: set-based DB delete (a few statements) + on-disk removal. Far
// faster on large match sets / small DB instances than per-repo cascade.
if args.bulk {
let disk_paths = db.list_disk_paths_by_name_regex(&args.regex).await?;
println!(
"collected {} on-disk paths; running bulk DB delete…",
disk_paths.len()
);
let deleted = db.bulk_delete_by_name_regex(&args.regex).await?;
println!("bulk DB delete done: {deleted} repos removed; removing on-disk repos…");
let mut disk_removed = 0u64;
for p in &disk_paths {
if std::path::Path::new(p).exists() {
match std::fs::remove_dir_all(p) {
Ok(()) => disk_removed += 1,
Err(e) => eprintln!("warning: rm {p} failed: {e}"),
}
}
}
println!("done: {deleted} repos purged (bulk), {disk_removed} on-disk dirs removed");
return Ok(());
}
// Tigris client (best-effort; archive deletion failures are logged, not fatal).
let tigris = if !args.skip_tigris && !config.tigris_bucket.is_empty() {
match TigrisClient::new(&config.tigris_bucket).await {
Ok(t) => Some(t),
Err(e) => {
eprintln!("warning: could not init Tigris ({e}); skipping archive deletion");
None
}
}
} else {
None
};
let mut deleted = 0i64;
let mut disk_removed = 0i64;
let mut tigris_removed = 0i64;
loop {
let take = match args.limit {
n if n > 0 => (n - deleted).min(BATCH),
_ => BATCH,
};
if take <= 0 {
break;
}
let batch = db.list_repos_by_name_regex(&args.regex, take).await?;
if batch.is_empty() {
break;
}
for (id, name, owner_did, disk_path) in &batch {
let short = owner_did.rsplit(':').next().unwrap_or(owner_did);
let slug = format!("{short}/{name}");
// DB first: once the row is gone the repo is invisible to all serve/
// list paths even if disk/Tigris cleanup lags.
db.delete_repo_cascade(id, &slug).await?;
deleted += 1;
if !disk_path.is_empty() && std::path::Path::new(disk_path).exists() {
match std::fs::remove_dir_all(disk_path) {
Ok(()) => disk_removed += 1,
Err(e) => eprintln!("warning: rm {disk_path} failed: {e}"),
}
}
if let Some(t) = &tigris {
let owner_slug = owner_did.replace([':', '/'], "_");
match t.delete(&owner_slug, name).await {
Ok(()) => tigris_removed += 1,
Err(e) => eprintln!("warning: tigris delete {owner_slug}/{name} failed: {e}"),
}
}
}
println!("… deleted {deleted}/{total} (disk {disk_removed}, tigris {tigris_removed})");
}
println!(
"done: {deleted} repos purged (disk dirs removed: {disk_removed}, tigris archives removed: {tigris_removed})"
);
Ok(())
}
+16 -103
View File
@@ -110,28 +110,19 @@ pub async fn create_bounty(
Ok((StatusCode::CREATED, Json(bounty)))
}
const MAX_BOUNTY_LIMIT: i64 = 200;
/// GET /api/v1/repos/{owner}/{repo}/bounties
pub async fn list_repo_bounties(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let limit = q.limit.unwrap_or(50).clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state
.db
.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
limit,
None,
None,
q.limit.unwrap_or(50),
)
.await?;
@@ -142,92 +133,25 @@ pub async fn list_repo_bounties(
pub async fn list_all_bounties(
State(state): State<AppState>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let raw_limit = q.limit.unwrap_or(50);
let limit = raw_limit.clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state
.db
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
.await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let mut memo = std::collections::HashMap::new();
let mut allowed: Vec<BountyRecord> = Vec::new();
let page_size = (limit * 5).clamp(1, 200);
let max_scanned = 10_000i64;
let mut total_scanned: i64 = 0;
let mut cursor: Option<(String, String)> = None;
while (allowed.len() as i64) < limit {
let after = cursor.as_ref().map(|(ts, id)| (ts.as_str(), id.as_str()));
let bounties = state
.db
.list_bounties(
None,
None,
q.status.as_deref(),
page_size,
after.map(|(ts, _)| ts),
after.map(|(_, id)| id),
)
.await?;
if bounties.is_empty() {
break;
}
total_scanned += bounties.len() as i64;
if total_scanned > max_scanned {
return Err(AppError::Incomplete(
"too many rows scanned; refine your filters".into(),
));
}
let last = bounties.last().unwrap();
for b in &bounties {
if (allowed.len() as i64) >= limit {
break;
}
let key = format!("{}/{}", b.repo_owner, b.repo_name);
let admitted = if let Some(&result) = memo.get(&key) {
result
} else {
let result = crate::api::authorize_repo_read(
&state,
&b.repo_owner,
&b.repo_name,
caller,
"/",
)
.await
.is_ok();
memo.insert(key, result);
result
};
if admitted {
allowed.push(b.clone());
}
}
cursor = Some((last.created_at.clone(), last.id.clone()));
}
Ok(Json(serde_json::json!({ "bounties": allowed })))
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
/// GET /api/v1/bounties/{id}
pub async fn get_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
auth: Option<Extension<AuthenticatedDid>>,
) -> 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)?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if crate::api::authorize_repo_read(&state, &bounty.repo_owner, &bounty.repo_name, caller, "/")
.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")))?;
Ok(Json(bounty))
}
@@ -238,22 +162,11 @@ pub async fn claim_bounty(
Path(id): Path<String>,
Json(req): Json<ClaimBountyRequest>,
) -> 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!(
+15 -41
View File
@@ -1,44 +1,23 @@
//! API handlers for ref certificates.
use std::collections::HashMap;
use axum::extract::{Extension, Path, Query, State};
use axum::extract::{Path, State};
use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50
/// GET /api/v1/repos/{owner}/{repo}/certs
pub async fn list_certs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let limit = params
.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.map(|v| v.max(1))
.unwrap_or(50)
.min(200);
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
// When a prefix is given (short-ID resolution from the CLI) use a
// generous limit and delegate to the prefix-matched query so the
// caller can resolve IDs regardless of how many certs exist.
let prefix = params.get("prefix").filter(|p| !p.is_empty());
let certs = if let Some(prefix) = prefix {
state
.db
.list_ref_certificates_by_prefix(&record.id, prefix, 200)
.await?
} else {
state.db.list_ref_certificates(&record.id, limit).await?
};
let certs = state.db.list_ref_certificates(&record.id).await?;
let certs_json: Vec<serde_json::Value> = certs
.iter()
.map(|c| {
@@ -56,21 +35,20 @@ pub async fn list_certs(
})
.collect();
let count = certs_json.len();
Ok(Json(
serde_json::json!({ "certificates": certs_json, "count": count }),
))
Ok(Json(serde_json::json!({ "certificates": certs_json })))
}
/// GET /api/v1/repos/{owner}/{repo}/certs/{id}
pub async fn get_cert(
State(state): State<AppState>,
Path((owner, name, id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
// Verify the repo exists
let _record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let cert = state
.db
@@ -78,10 +56,6 @@ pub async fn get_cert(
.await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
if cert.repo_id != record.id {
return Err(AppError::NotFound(format!("certificate {id}")));
}
Ok(Json(serde_json::json!({
"id": cert.id,
"repo_id": cert.repo_id,
File diff suppressed because it is too large. Load diff
+47 -80
View File
@@ -27,7 +27,7 @@ use std::str::FromStr;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::git::store;
use crate::git::visibility_pack::{allowed_blob_set_for_caller, has_path_scoped_rule};
use crate::git::visibility_pack::{has_path_scoped_rule, withheld_blob_oids};
use crate::state::AppState;
use crate::visibility::{visibility_check, Decision};
@@ -36,24 +36,16 @@ use crate::visibility::{visibility_check, Decision};
/// Search all repos on the node for a git object whose SHA-256 hash matches
/// the given CIDv1, returning its raw content if the caller may read it.
///
/// Visibility (#110, #126): the object is served only from a repo row the
/// caller passes. For each iterated row we gate against that row's OWN rules
/// Visibility (#110): the object is served only from a repo row the caller
/// passes. For each iterated row we gate against that row's OWN rules
/// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read`
/// — `get_repo`'s fuzzy match could otherwise authorize a different physical
/// row than the one read (KTD2a). We check object existence via
/// `store::object_type` *before* the expensive reachability walk so random-CID
/// spray cannot trigger full-history git walks on repos that don't carry the
/// object. When the row carries path-scoped rules (KTD4) the served object
/// must be either a non-blob (trees/commits are structural; KTD3) OR a blob
/// in the caller's *reachable* allowed-set (`allowed_blob_set_for_caller`).
/// The reachable allowed-set excludes dangling blobs — a blob written via
/// `git hash-object -w` and never committed has no path to gate, so it is
/// fail-closed 404'd under path-scoped rules (#126). Denial and genuine
/// not-found both fall through to an opaque 404.
/// row than the one read (KTD2a). When the row carries path-scoped rules, a
/// blob withheld from the caller (`withheld_blob_oids`) is skipped. Denial and
/// genuine not-found both fall through to an opaque 404.
///
/// Scope: this closes the direct unauthenticated scan, including the dangling
/// case. A stale-public mirror row still serves withheld content (tracked
/// separately, #124).
/// Scope: this closes the direct unauthenticated scan. A stale-public mirror
/// row still serves withheld content (tracked separately, #124).
pub async fn get_by_cid(
Path(cid_str): Path<String>,
State(state): State<AppState>,
@@ -93,16 +85,11 @@ pub async fn get_by_cid(
.await
.map_err(AppError::Internal)?;
// Request-scoped memo of the per-repo allowed-blob set (KTD1, #126). The
// caller is constant for one request, so `repo.id` alone is a safe,
// sufficient key — never a coarse caller "class", which
// `visibility_check`'s exact full-DID reader match would make unsafe.
//
// We flipped from a deny-set (`withheld_blob_oids`) to an allowed-set
// (`allowed_blob_set_for_caller`) so dangling blobs — never enumerated by
// the reachable walk — fail closed instead of slipping through an empty
// deny entry (#126).
let mut allowed_memo: HashMap<String, HashSet<String>> = HashMap::new();
// Request-scoped memo of the per-repo withheld set (KTD1). The caller is
// constant for one request, so `repo.id` alone is a safe, sufficient key —
// never a coarse caller "class", which `visibility_check`'s exact full-DID
// reader match would make unsafe.
let mut withheld_memo: HashMap<String, HashSet<String>> = HashMap::new();
for repo in &repos {
// Repo-level read gate against THIS row's own rules (KTD2a).
@@ -119,24 +106,9 @@ pub async fn get_by_cid(
Err(_) => continue,
};
// Check whether the object exists in this repo before any expensive
// reachability walk. This prevents random-CID spray from triggering
// full-history git walks on repos that don't carry the object.
let obj_type = match store::object_type(&repo_path, &sha256_hex) {
Ok(Some(t)) => t,
Ok(None) => continue,
Err(e) => {
tracing::warn!(repo = %repo.name, err = %e, "error checking git object type");
continue;
}
};
// Per-blob gating only applies when a path-scoped rule exists (KTD4).
// Without any path-scoped rule, the "/" gate above is the whole story.
// Trees/commits are always served under path-scoped rules (KTD3).
let path_scoped = has_path_scoped_rule(rules);
if path_scoped && obj_type == "blob" {
if !allowed_memo.contains_key(&repo.id) {
// Per-blob withholding only applies when a path-scoped rule exists (KTD4).
if has_path_scoped_rule(rules) {
if !withheld_memo.contains_key(&repo.id) {
let rp = repo_path.clone();
let r = rules.to_vec();
let is_public = repo.is_public;
@@ -144,13 +116,7 @@ pub async fn get_by_cid(
let caller_for_walk = caller_owned.clone();
// Full-history walk shells out to git — keep it off the async runtime.
let walk = tokio::task::spawn_blocking(move || {
allowed_blob_set_for_caller(
&rp,
&r,
is_public,
&owner,
caller_for_walk.as_deref(),
)
withheld_blob_oids(&rp, &r, is_public, &owner, caller_for_walk.as_deref())
})
.await;
// Fail closed on EITHER a task panic (JoinError) or a walk error:
@@ -159,50 +125,51 @@ pub async fn get_by_cid(
let set = match walk {
Ok(Ok(set)) => set,
Ok(Err(e)) => {
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo");
tracing::warn!(repo = %repo.name, err = %e, "withheld walk failed; skipping repo");
continue;
}
Err(e) => {
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo");
tracing::warn!(repo = %repo.name, err = %e, "withheld walk task panicked; skipping repo");
continue;
}
};
allowed_memo.insert(repo.id.clone(), set);
withheld_memo.insert(repo.id.clone(), set);
}
let in_allowed = allowed_memo
if withheld_memo
.get(&repo.id)
.is_some_and(|set| set.contains(&sha256_hex));
if !in_allowed {
.is_some_and(|set| set.contains(&sha256_hex))
{
continue;
}
}
// Now that we've passed the gate, read the content.
let content = match store::read_object_content(&repo_path, &sha256_hex, &obj_type) {
Ok(c) => c,
match store::read_object(&repo_path, &sha256_hex) {
Ok(Some((_obj_type, content))) => {
// 3. Return the content with IPFS-style headers
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/octet-stream"),
);
headers.insert(
HeaderName::from_static("x-content-cid"),
HeaderValue::from_str(&cid_str)
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
);
headers.insert(
HeaderName::from_static("x-git-hash"),
HeaderValue::from_str(&sha256_hex)
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
);
return Ok((StatusCode::OK, headers, content).into_response());
}
Ok(None) => continue,
Err(e) => {
tracing::warn!(repo = %repo.name, err = %e, "error reading git object content");
tracing::warn!(repo = %repo.name, err = %e, "error reading git object");
continue;
}
};
// 3. Return the content with IPFS-style headers
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/octet-stream"),
);
headers.insert(
HeaderName::from_static("x-content-cid"),
HeaderValue::from_str(&cid_str).unwrap_or_else(|_| HeaderValue::from_static("invalid")),
);
headers.insert(
HeaderName::from_static("x-git-hash"),
HeaderValue::from_str(&sha256_hex)
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
);
return Ok((StatusCode::OK, headers, content).into_response());
}
}
// Not found in any repo
+16 -31
View File
@@ -90,11 +90,12 @@ pub async fn create_issue(
pub async fn list_issues(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
@@ -119,11 +120,12 @@ pub async fn list_issues(
pub async fn get_issue(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
@@ -189,31 +191,14 @@ pub async fn create_issue_comment(
pub async fn list_issue_comments(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let _record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
// Resolve the full issue ID (accepts 8-char prefix) so the DB fetch
// below uses the same canonical id as the git ref.
let full_id = match git_issues::resolve_issue_id(&disk_path, &issue_id)
.map_err(|e| AppError::Git(e.to_string()))?
{
Some(id) => id,
None => {
return Err(AppError::RepoNotFound(format!(
"issue {issue_id} not found"
)))
}
};
let comments = state.db.list_issue_comments(&full_id).await?;
let comments = state.db.list_issue_comments(&issue_id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
+5 -8
View File
@@ -71,18 +71,15 @@ pub async fn remove_label(
}
/// GET /api/v1/repos/:owner/:repo/labels
///
/// Read-visibility-gated (INV-2 root listing): a public repo's labels stay
/// anonymously listable; a private repo's label names are hidden (404) from
/// anyone who cannot read it at the root.
pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let labels = state.db.list_labels(&record.id).await?;
Ok(Json(serde_json::json!({ "labels": labels })))
+35 -167
View File
@@ -22,7 +22,6 @@ pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
pub mod status;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
@@ -68,14 +67,17 @@ pub(crate) async fn authorize_repo_read(
/// representation only within `did:key`; never let a bare id match across methods —
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
///
/// The collapse itself lives in exactly one place, [`crate::db::normalize_owner_key`],
/// which is also the function the stored `owner_did` / `authorizing_did` columns
/// are normalized through and which `OWNER_KEY_CASE_SQL` mirrors byte for byte.
/// Two identities match when they normalize to the same key; a second copy of the
/// rule here is how the Rust gate and the SQL filters would drift apart.
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
crate::db::normalize_owner_key(a) == crate::db::normalize_owner_key(b)
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
@@ -112,12 +114,6 @@ mod did_tests {
assert!(!did_matches("did:key:zABC", "did:key:zXYZ"));
assert!(!did_matches("zABC", "zXYZ"));
}
#[test]
fn empty_did_matches() {
assert!(did_matches("", ""));
assert!(did_matches("", "did:key:"));
}
}
/// Drift guard (plan 002 §Gate-type table, Step 5). Every in-scope mutation
@@ -170,10 +166,8 @@ mod authz_guard {
let issues = include_str!("issues.rs");
let bounties = include_str!("bounties.rs");
let replicas = include_str!("replicas.rs");
let events = include_str!("events.rs");
let tasks = include_str!("tasks.rs");
let stars = include_str!("stars.rs");
let status = include_str!("status/mod.rs");
let protect = include_str!("protect.rs");
let visibility = include_str!("visibility.rs");
let profiles = include_str!("profiles.rs");
@@ -187,20 +181,6 @@ mod authz_guard {
(pulls, "merge_pr", "require_repo_owner("),
(webhooks, "create_webhook", "require_repo_owner("),
(webhooks, "delete_webhook", "require_repo_owner("),
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
// authorize_repo_read 404s a non-reader of a private repo, then
// require_repo_owner 403s a non-owner of a public one. Both halves
// are pinned: the authorize_repo_read marker guards the read half
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
// Same two-half shape as list_webhooks: authorize_repo_read runs
// first (a quarantined or unreadable repo gets the missing-repo
// not-found, never a 403 that would confirm existence), then
// require_repo_owner 403s a non-owner of a readable repo. Both halves
// are pinned, because dropping either changes what a stranger learns.
(status, "create_status", "authorize_repo_read("),
(status, "create_status", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
@@ -217,25 +197,6 @@ mod authz_guard {
// get_by_cid gates each iterated repo row directly via visibility_check
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
(ipfs, "get_by_cid", "visibility_check("),
// #94 sibling read surfaces: gate private-repo metadata on read
// visibility (public repos stay anonymous; private repos 404).
(replicas, "list_replicas", "authorize_repo_read("),
(protect, "list_protected_branches", "authorize_repo_read("),
(labels, "list_labels", "authorize_repo_read("),
(events, "list_repo_events", "authorize_repo_read("),
// The catch-up poll surface gates before any push-event row is read,
// so a stranger cannot use it to learn that a private repo exists or
// is being pushed to.
(events, "list_repo_push_events", "authorize_repo_read("),
// The status read gates before any claim data is loaded, so its deny
// is the repo's own not-found rather than an existence oracle over
// which commits were reported on.
(status, "commit_status", "authorize_repo_read("),
// The rollup gates on the same helper before the pull request row is
// loaded, so a caller who cannot read the repo cannot learn which
// pull request numbers exist on it — and the fallback's branch
// resolve and head persist sit behind that same gate.
(status, "pull_request_status", "authorize_repo_read("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
@@ -363,118 +324,6 @@ mod authz_guard {
})
}
/// Collect `.rs` source files under `dir`. Recursive so the completeness scan
/// covers nested API modules (`api/<module>/mod.rs` and deeper), not only the
/// immediate `api/*.rs` children.
fn collect_rs_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).expect("read api dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
out.extend(collect_rs_files(&path));
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
out
}
/// The `.rs` files under `api_root` (as paths RELATIVE to it) that the
/// completeness scan must inspect: everything except the top-level `mod.rs` (the
/// guard file itself) and the top-level files already covered by the per-handler
/// `sources` loop. A nested `api/<module>/<name>.rs` is a distinct source file
/// even when its basename matches a listed top-level file, so it stays in scope.
fn unlisted_source_files(
api_root: &std::path::Path,
listed: &std::collections::HashSet<&str>,
) -> Vec<String> {
collect_rs_files(api_root)
.iter()
.filter_map(|path| {
let rel = path
.strip_prefix(api_root)
.ok()?
.to_string_lossy()
.replace('\\', "/");
if rel == "mod.rs" || listed.contains(rel.as_str()) {
None
} else {
Some(rel)
}
})
.collect()
}
#[test]
fn collect_rs_files_recurses_subdirs() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join("a.rs"), "").unwrap();
std::fs::write(root.join("note.txt"), "").unwrap();
std::fs::create_dir_all(root.join("sub/deep")).unwrap();
std::fs::write(root.join("sub/mod.rs"), "").unwrap();
std::fs::write(root.join("sub/deep/c.rs"), "").unwrap();
let names: std::collections::HashSet<String> = collect_rs_files(root)
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(names.contains("a.rs"));
assert!(
names.contains("sub/mod.rs"),
"nested module file must be collected"
);
assert!(
names.contains("sub/deep/c.rs"),
"deeply nested file must be collected"
);
assert!(
!names.iter().any(|n| n.ends_with(".txt")),
"non-rs files excluded"
);
assert_eq!(names.len(), 3);
}
// P3 (#119): the completeness scan must skip already-covered files by their path
// RELATIVE to api_root, not by basename. A nested api/<module>/repos.rs is a
// distinct source file from the covered top-level repos.rs and must still be
// scanned, or a new nested module could smuggle in an ungated repo-scoped handler
// behind a colliding filename.
#[test]
fn unlisted_source_files_scans_nested_file_with_colliding_basename() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(root.join("repos.rs"), "").unwrap();
std::fs::write(root.join("mod.rs"), "").unwrap();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/repos.rs"), "").unwrap();
std::fs::write(root.join("sub/fresh.rs"), "").unwrap();
let listed: std::collections::HashSet<&str> = ["repos.rs"].into_iter().collect();
let unlisted = unlisted_source_files(root, &listed);
assert!(
unlisted.contains(&"sub/repos.rs".to_string()),
"a nested file whose basename matches a listed top-level file must still be scanned"
);
assert!(
unlisted.contains(&"sub/fresh.rs".to_string()),
"a nested file with a unique name must be scanned"
);
assert!(
!unlisted.contains(&"repos.rs".to_string()),
"the listed top-level file is covered by the per-handler loop"
);
assert!(
!unlisted.contains(&"mod.rs".to_string()),
"the top-level guard file is skipped"
);
}
/// Egress gate guard: every repo-scoped handler (`Path<(String, String..)>`)
/// must carry an authz marker — a read gate (`authorize_repo_read` /
/// `visibility_check`), or a write gate (`require_repo_owner` / `require_owner`
@@ -509,7 +358,6 @@ mod authz_guard {
(include_str!("replicas.rs"), "replicas.rs"),
(include_str!("repos.rs"), "repos.rs"),
(include_str!("stars.rs"), "stars.rs"),
(include_str!("status/mod.rs"), "status/mod.rs"),
(include_str!("visibility.rs"), "visibility.rs"),
(include_str!("webhooks.rs"), "webhooks.rs"),
];
@@ -528,22 +376,42 @@ mod authz_guard {
"read-guard `sources` lists {f} but the file does not exist"
);
}
let api_root = std::path::Path::new(api_dir);
for rel in unlisted_source_files(api_root, &listed) {
let src = std::fs::read_to_string(api_root.join(&rel)).expect("read api file");
for entry in std::fs::read_dir(api_dir).expect("read src/api") {
let path = entry.expect("dir entry").path();
let fname = path.file_name().unwrap().to_string_lossy().into_owned();
if !fname.ends_with(".rs") || fname == "mod.rs" || listed.contains(fname.as_str()) {
continue;
}
let src = std::fs::read_to_string(&path).expect("read api file");
let has_repo_handler = handler_names(&src)
.iter()
.any(|n| is_repo_scoped(&fn_body(&src, n)));
assert!(
!has_repo_handler,
"api/{rel} declares a repo-scoped handler but is not in the egress \
"api/{fname} declares a repo-scoped handler but is not in the egress \
guard `sources` list — add it so its handlers are gate-checked"
);
}
// Repo-scoped reads known to be ungated today, each tracked by an issue.
// Remove an entry the moment its gate lands (the staleness assert enforces it).
let known_ungated: &[(&str, &str)] = &[];
let known_ungated: &[(&str, &str)] = &[
// info/refs gates only git-upload-pack today; git-receive-pack
// advertisement is ungated until #119 makes the gate unconditional.
("git_info_refs", "#119"),
("list_certs", "#120"),
("get_cert", "#120"),
("list_issues", "#120"),
("get_issue", "#120"),
("list_issue_comments", "#120"),
("list_labels", "#120"),
("list_repo_bounties", "#120"),
("get_star_status", "#120"),
("list_repo_events", "#94 (PR #113)"),
("list_webhooks", "#94 (PR #113)"),
("list_replicas", "PR #113"),
("list_protected_branches", "PR #113"),
];
let is_known = |n: &str| known_ungated.iter().any(|(k, _)| *k == n);
// Any one of these = the handler binds the caller to an authz decision: the
// first two are read gates, the rest are the write/owner/self-binding forms.
+2 -730
View File
@@ -296,8 +296,8 @@ pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_js
repo.get("name").and_then(|v| v.as_str()),
) {
(Some(owner), Some(name)) => {
// Use short owner (did:key-only normalization) matching DB convention
let short = crate::db::normalize_owner_key(owner);
// Use short owner (last colon segment) matching DB convention
let short = owner.split(':').next_back().unwrap_or(owner);
format!("{short}/{name}")
}
_ => continue,
@@ -347,10 +347,6 @@ pub struct NotifyRequest {
pub timestamp: Option<String>,
#[serde(default)]
pub cert_id: Option<String>,
/// Full owner DID — added in #144 for DID-aware feed gating.
/// Optional for backward compat with older senders.
#[serde(default)]
pub owner_did: Option<String>,
}
pub async fn notify_sync(
@@ -381,14 +377,6 @@ pub async fn notify_sync(
)));
}
// #272: the slug reaches a PathBuf::join in the sync worker, where an extra
// separator or a traversal segment places the mirror outside repos_dir. Reject
// it here, before the queue row and the ref-update row are written. The message
// names the field and nothing about where repos live on disk.
if let Err(e) = crate::git::repo_store::validate_repo_slug(&req.repo) {
return Err(AppError::BadRequest(format!("invalid repo field: {e}")));
}
state
.db
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
@@ -403,7 +391,6 @@ pub async fn notify_sync(
node_did: req.node_did.clone(),
pusher_did: req.pusher_did.clone().unwrap_or_default(),
repo: req.repo.clone(),
owner_did: req.owner_did.clone(),
ref_name: req.ref_name.clone(),
old_sha: req.old_sha.clone().unwrap_or_default(),
new_sha: req.new_sha.clone(),
@@ -550,719 +537,4 @@ mod tests {
// pins the `o[0] == 0` check against an off-by-one into 1.x.
assert!(is_public_http_url("http://1.0.0.0/"));
}
// ── #82: /sync/trigger signature gate + per-IP brake on the peer-sync routes ──
//
// These drive the FULL production router (crate::server::build_router) so the
// route wiring, layer order, and config-mode branching are all under test.
// The positive-path and DID-agnostic cases mount the handler directly because
// signed_request_as injects only an AuthenticatedDid extension (not a real
// RFC-9421 signature), which require_signature rejects on the full router.
use crate::rate_limit::{IpRateLimiter, RateLimiter, TrustedProxy};
use crate::state::AppState;
use crate::test_support::{signed_request_as, test_state};
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{header, Method, Request, StatusCode};
use axum::routing::post;
use axum::{middleware, Extension, Router};
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use sqlx::PgPool;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
fn unsigned_post(uri: &str, body: &str, peer: &str) -> Request<Body> {
let mut req = Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
req
}
fn require_signed_peer_writes(state: &mut AppState) {
let mut cfg = (*state.config).clone();
cfg.require_signed_peer_writes = true;
state.config = Arc::new(cfg);
}
// The slug must be a well-formed owner/name (#272); these brake tests are about
// the rate limiter, so the body carries a valid slug and reaches the brake.
const NOTIFY_BODY: &str = r#"{"repo":"z6Mkfoo/hello","ref_name":"refs/heads/main","new_sha":"0000000000000000000000000000000000000000","node_did":"PEER_DID"}"#;
// ── trigger: mandatory signature ──────────────────────────────────────────
#[sqlx::test]
async fn sync_trigger_rejects_unsigned_in_default_mode(pool: PgPool) {
// The hole: with require_signed_peer_writes=false (default), an anonymous
// caller reaches the fan-out. Must be 401 regardless of the flag.
let state = test_state(pool).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/trigger",
"{}",
"203.0.113.1:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn sync_trigger_rejects_unsigned_in_signed_writes_mode(pool: PgPool) {
// The signature requirement must not depend on the flag.
let mut state = test_state(pool).await;
require_signed_peer_writes(&mut state);
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/trigger",
"{}",
"203.0.113.2:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn sync_trigger_admits_signed_caller(pool: PgPool) {
// must-not-over-throttle: a signed caller reaches the handler and (with no
// peers seeded) gets 200, not 429. Direct-mount because a real signature
// cannot be forged through the full router in tests.
let state = test_state(pool).await;
let did = Keypair::generate().did().to_string();
let app = Router::new()
.route("/api/v1/sync/trigger", post(super::trigger_sync))
.layer(middleware::from_fn(crate::rate_limit::rate_limit_by_ip))
.layer(Extension(IpRateLimiter {
limiter: RateLimiter::new(60, Duration::from_secs(3600)),
trust: TrustedProxy::None,
}))
.with_state(state);
let mut req =
signed_request_as(&did, Method::POST, "/api/v1/sync/trigger", Body::from("{}"));
req.extensions_mut().insert(ConnectInfo(
"203.0.113.3:5000".parse::<SocketAddr>().unwrap(),
));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// ── trigger: IP brake ─────────────────────────────────────────────────────
#[sqlx::test]
async fn sync_trigger_ip_flood_is_throttled(pool: PgPool) {
// The brake is outermost, so an over-limit request 429s before it ever
// reaches require_signature — an unsigned request suffices to prove it.
let mut state = test_state(pool).await;
state.sync_trigger_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let peer = "203.0.113.4:5000";
// Exhaust the single-request budget up front.
assert!(
state
.sync_trigger_rate_limiter
.check(&peer.parse::<SocketAddr>().unwrap().ip().to_string())
.await
);
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post("/api/v1/sync/trigger", "{}", peer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[sqlx::test]
async fn sync_trigger_brake_is_did_agnostic(pool: PgPool) {
// Why per-IP, not per-DID: a DID farm (fresh did:key per request) does not
// bypass an IP-keyed brake. Two DISTINCT DIDs from one IP, brake limit 1 →
// the second is 429. A per-DID limiter would give each DID its own bucket.
let state = test_state(pool).await;
let app = Router::new()
.route("/api/v1/sync/trigger", post(super::trigger_sync))
.layer(middleware::from_fn(crate::rate_limit::rate_limit_by_ip))
.layer(Extension(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(3600)),
trust: TrustedProxy::None,
}))
.with_state(state);
let peer = "203.0.113.5:5000".parse::<SocketAddr>().unwrap();
let mk = |did: &str| {
let mut r =
signed_request_as(did, Method::POST, "/api/v1/sync/trigger", Body::from("{}"));
r.extensions_mut().insert(ConnectInfo(peer));
r
};
let first = app
.clone()
.oneshot(mk(&Keypair::generate().did().to_string()))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let second = app
.oneshot(mk(&Keypair::generate().did().to_string()))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[sqlx::test]
async fn sync_trigger_forged_forwarded_header_cannot_bypass(pool: PgPool) {
// TrustedProxy::None keys on the socket peer, so rotating X-Forwarded-For
// from one socket peer does not refill the bucket. limit 1: first request
// consumes it (then 401 unsigned), second from the same socket → 429
// regardless of a different XFF value.
let mut state = test_state(pool).await;
state.sync_trigger_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let router = crate::server::build_router(state);
let mut a = unsigned_post("/api/v1/sync/trigger", "{}", "203.0.113.6:5000");
a.headers_mut()
.insert("x-forwarded-for", "1.1.1.1".parse().unwrap());
assert_eq!(
router.clone().oneshot(a).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let mut b = unsigned_post("/api/v1/sync/trigger", "{}", "203.0.113.6:5000");
b.headers_mut()
.insert("x-forwarded-for", "2.2.2.2".parse().unwrap());
assert_eq!(
router.oneshot(b).await.unwrap().status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[sqlx::test]
async fn sync_trigger_rate_limit_zero_disables_but_signature_holds(pool: PgPool) {
// 0 disables the brake (RateLimiter::check early-returns), proving the two
// halves are independent: no 429 even under a flood, but the signature gate
// still 401s every unsigned request.
let mut state = test_state(pool).await;
state.sync_trigger_rate_limiter = RateLimiter::new(0, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let router = crate::server::build_router(state);
for _ in 0..3 {
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/sync/trigger",
"{}",
"203.0.113.7:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
// ── notify: braked, but signature behavior unchanged ──────────────────────
async fn seed_peer(state: &AppState) -> String {
let did = Keypair::generate().did().to_string();
state
.db
.upsert_peer(&did, "https://peer.example.com")
.await
.unwrap();
did
}
#[sqlx::test]
async fn sync_notify_unsigned_flood_is_throttled(pool: PgPool) {
// notify still accepts an unsigned known-peer notification (rolling-upgrade
// compat), but a flood from one IP is now braked. brake limit 1: first
// enqueues (200), second from the same IP → 429.
let mut state = test_state(pool).await;
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let peer_did = seed_peer(&state).await;
let body = NOTIFY_BODY.replace("PEER_DID", &peer_did);
let router = crate::server::build_router(state);
let first = router
.clone()
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&body,
"203.0.113.8:5000",
))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let second = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&body,
"203.0.113.8:5000",
))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[sqlx::test]
async fn sync_notify_distinct_ips_are_not_collaterally_throttled(pool: PgPool) {
// must-not-over-throttle: per-IP keying means one peer's volume does not
// throttle another. brake limit 1, two DIFFERENT source IPs → both 200.
let mut state = test_state(pool).await;
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let peer_did = seed_peer(&state).await;
let body = NOTIFY_BODY.replace("PEER_DID", &peer_did);
let router = crate::server::build_router(state);
for ip in ["203.0.113.20:5000", "203.0.113.21:5000"] {
let resp = router
.clone()
.oneshot(unsigned_post("/api/v1/sync/notify", &body, ip))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"distinct IP {ip} must not be throttled"
);
}
}
#[sqlx::test]
async fn notify_flood_does_not_exhaust_trigger_bucket(pool: PgPool) {
// Separate buckets: draining peer_write via an unsigned notify flood must
// NOT throttle the signed trigger caller from the same IP. After the notify
// bucket is spent, an unsigned trigger from that IP hits its own (unspent)
// bucket and is rejected by the SIGNATURE gate (401), not the brake (429).
let mut state = test_state(pool).await;
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.sync_trigger_rate_limiter = RateLimiter::new(60, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let peer_did = seed_peer(&state).await;
let body = NOTIFY_BODY.replace("PEER_DID", &peer_did);
let router = crate::server::build_router(state);
let ip = "203.0.113.9:5000";
assert_eq!(
router
.clone()
.oneshot(unsigned_post("/api/v1/sync/notify", &body, ip))
.await
.unwrap()
.status(),
StatusCode::OK
);
assert_eq!(
router
.clone()
.oneshot(unsigned_post("/api/v1/sync/notify", &body, ip))
.await
.unwrap()
.status(),
StatusCode::TOO_MANY_REQUESTS,
"peer_write bucket should now be exhausted for this IP"
);
let trigger = router
.oneshot(unsigned_post("/api/v1/sync/trigger", "{}", ip))
.await
.unwrap();
assert_eq!(
trigger.status(),
StatusCode::UNAUTHORIZED,
"trigger must hit its own bucket (401 from the sig gate), not the drained peer_write bucket (429)"
);
}
// ── notify: repo slug validation (#272) ───────────────────────────────────
//
// An unsigned caller who has announced their own DID is a "known peer", so
// the slug they send reaches the sync worker's path join. `a//tmp/probe`
// resolved to /tmp/probe.git, outside repos_dir entirely. notify must reject
// anything that is not a well-formed owner/name slug before it is queued.
/// Every slug shape the boundary must refuse. `a//tmp/gitlawb-probe` is the
/// verified escape; the rest are the traversal and empty-half neighbours.
const MALFORMED_SLUGS: [&str; 9] = [
"a//tmp/gitlawb-probe",
"../hello",
"./hello",
"../../etc/evil",
"a/../../x",
"..",
"demo",
"/hello",
"z6Mkfoo/",
];
fn notify_body(repo: &str, peer_did: &str) -> String {
serde_json::json!({
"repo": repo,
"ref_name": "refs/heads/main",
"new_sha": "0000000000000000000000000000000000000000",
"node_did": peer_did,
})
.to_string()
}
#[sqlx::test]
async fn sync_notify_rejects_malformed_repo_slugs(pool: PgPool) {
let state = test_state(pool).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
for (i, slug) in MALFORMED_SLUGS.iter().enumerate() {
// Distinct source IPs so the per-IP brake never masks the 400.
let ip = format!("198.51.100.{}:5000", i + 1);
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body(slug, &peer_did),
&ip,
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"slug {slug:?} must be rejected"
);
}
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert!(
queued.is_empty(),
"no malformed slug may be queued, got {:?}",
queued.iter().map(|q| &q.repo).collect::<Vec<_>>()
);
}
#[sqlx::test]
async fn sync_notify_rejection_body_names_field_not_path(pool: PgPool) {
// R3: the client learns which field was bad, never where repos live on
// disk. A message carrying the resolved path would hand an unsigned
// caller the server's layout.
let state = test_state(pool).await;
let repos_dir = state.config.repos_dir.display().to_string();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &peer_did),
"198.51.100.20:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("repo"), "body must name the field: {body}");
assert!(
!body.contains(&repos_dir),
"body must not leak repos_dir ({repos_dir}): {body}"
);
for trimmed in repos_dir.trim_start_matches("./").split('/') {
assert!(
trimmed.is_empty() || !body.contains(trimmed),
"body must not leak a repos_dir segment ({trimmed}): {body}"
);
}
// A rooted path is a leading '/' with something after it. A bare quoted
// '/' is the separator the message is allowed to talk about.
assert!(
!body
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.any(|tok| tok.starts_with('/') && tok.len() > 1),
"body must not contain a rooted filesystem path: {body}"
);
}
#[sqlx::test]
async fn sync_notify_rejection_writes_no_ref_update(pool: PgPool) {
// The second half of R1: the ref-update feed row sits after enqueue_sync,
// so the early return must skip it too. Asserted, not argued.
let state = test_state(pool.clone()).await;
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &peer_did),
"198.51.100.21:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM received_ref_updates")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 0,
"a rejected notify must not seed the ref-update feed"
);
}
#[sqlx::test]
async fn sync_notify_accepts_valid_slug_and_records_ref_update(pool: PgPool) {
// must-not-break, and the control that keeps the two negative assertions
// above from being vacuous: a well-formed slug still queues and still
// writes its ref-update row.
let state = test_state(pool.clone()).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("z6Mkfoo/hello", &peer_did),
"198.51.100.22:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].repo, "z6Mkfoo/hello");
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM received_ref_updates WHERE repo = $1")
.bind("z6Mkfoo/hello")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 1,
"the valid path must still write the ref-update row"
);
}
#[sqlx::test]
async fn sync_notify_unknown_peer_wins_over_malformed_slug(pool: PgPool) {
// Ordering: the slug check sits after the peer gate, so an unknown peer
// still gets the unknown-peer message rather than a slug complaint.
let state = test_state(pool).await;
let stranger = Keypair::generate().did().to_string();
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &stranger),
"198.51.100.23:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
body.contains("unknown peer DID"),
"peer gate must still fire first: {body}"
);
}
#[sqlx::test]
async fn unsigned_announce_then_notify_cannot_queue_a_repos_dir_escape(pool: PgPool) {
// The reachability case for #272, committed in its attack form. Two
// unsigned requests through the shipped router, no signature and no
// config change: announce the attacker's own DID (which is what makes
// them a "known peer"), then notify with `a/<abs>/gitlawb-probe`. That
// slug used to reach PathBuf::join in the sync worker, where the
// absolute second component discarded repos_dir and planted a mirror at
// /<abs>/gitlawb-probe.git. The notify must now be refused outright.
//
// This has to run over crate::server::build_router rather than a
// directly mounted handler: the point of the test is that the default
// layer stack (optional_signature, not require_signature) leaves the
// route open, so mounting the handler alone would assert nothing about
// reachability.
let state = test_state(pool).await;
let db = state.db.clone();
let attacker = Keypair::generate().did().to_string();
let router = crate::server::build_router(state);
let announce =
format!(r#"{{"did":"{attacker}","http_url":"https://attacker.example.com"}}"#);
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/peers/announce",
&announce,
"198.51.100.40:5000",
))
.await
.unwrap();
// Asserted, not assumed: this plan does not change announce, and the
// whole attack rests on an unsigned announce still succeeding.
assert_eq!(
resp.status(),
StatusCode::OK,
"unsigned announce still admits an attacker-generated DID"
);
let outside = tempfile::TempDir::new().unwrap();
let escape_dir = outside.path().join("gitlawb-probe");
let slug = format!("a/{}", escape_dir.display());
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body(&slug, &attacker),
"198.51.100.41:5000",
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"notify must refuse the escaping slug {slug:?}"
);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert!(
queued.is_empty(),
"sync_queue must stay empty, got {:?}",
queued.iter().map(|q| &q.repo).collect::<Vec<_>>()
);
assert!(
!escape_dir.with_extension("git").exists(),
"nothing may be written at the escape target"
);
}
#[sqlx::test]
async fn announce_still_accepts_unsigned_in_default_mode(pool: PgPool) {
// must-not-over-reach: adding the brake must not tighten announce's
// rolling-upgrade behavior — an unsigned announce with a public URL still
// succeeds (it only gains the brake, which a single request is under).
let state = test_state(pool).await;
let did = Keypair::generate().did().to_string();
let body = format!(r#"{{"did":"{did}","http_url":"https://peer.example.com"}}"#);
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/peers/announce",
&body,
"203.0.113.10:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// Build a request carrying a REAL RFC 9421 signature (not just an injected
// extension), so it passes require_signature on the full production router.
fn real_signed_trigger(peer: &str) -> Request<Body> {
let kp = Keypair::generate();
let body = b"{}";
let s = sign_request(&kp, "POST", "/api/v1/sync/trigger", body);
let mut req = Request::builder()
.method(Method::POST)
.uri("/api/v1/sync/trigger")
.header(header::CONTENT_TYPE, "application/json")
.header("content-digest", s.content_digest)
.header("signature-input", s.signature_input)
.header("signature", s.signature)
.body(Body::from(body.to_vec()))
.unwrap();
req.extensions_mut()
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
req
}
#[sqlx::test]
async fn sync_trigger_admits_real_signature_through_full_router(pool: PgPool) {
// The positive path through the REAL gate: a validly-signed trigger passes
// require_signature on the full router and reaches the handler (no peers
// seeded → 200), in BOTH config modes.
let state = test_state(pool.clone()).await;
let resp = crate::server::build_router(state)
.oneshot(real_signed_trigger("203.0.113.30:5000"))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"a real signature must reach the handler (default mode)"
);
let mut state = test_state(pool).await;
require_signed_peer_writes(&mut state);
let resp = crate::server::build_router(state)
.oneshot(real_signed_trigger("203.0.113.30:5001"))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"a real signature must reach the handler (signed-writes mode)"
);
}
#[sqlx::test]
async fn announce_flood_is_throttled(pool: PgPool) {
// The peer_write brake covers /peers/announce too (co-benefit): limit 1,
// first unsigned announce from an IP is 200, the second from that IP 429.
let mut state = test_state(pool).await;
state.peer_write_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let did = Keypair::generate().did().to_string();
let body = format!(r#"{{"did":"{did}","http_url":"https://peer.example.com"}}"#);
let router = crate::server::build_router(state);
let ip = "203.0.113.31:5000";
assert_eq!(
router
.clone()
.oneshot(unsigned_post("/api/v1/peers/announce", &body, ip))
.await
.unwrap()
.status(),
StatusCode::OK
);
assert_eq!(
router
.oneshot(unsigned_post("/api/v1/peers/announce", &body, ip))
.await
.unwrap()
.status(),
StatusCode::TOO_MANY_REQUESTS,
"announce must be braked by the peer_write limiter"
);
}
#[sqlx::test]
async fn sync_trigger_429_body_is_generic_not_push(pool: PgPool) {
// The shared 429 middleware now serves the sync routes, so its body must
// not claim "push" (RED before the message was genericized).
let mut state = test_state(pool).await;
state.sync_trigger_rate_limiter = RateLimiter::new(1, Duration::from_secs(3600));
state.push_limiter_trust = TrustedProxy::None;
let peer = "203.0.113.32:5000";
assert!(
state
.sync_trigger_rate_limiter
.check(&peer.parse::<SocketAddr>().unwrap().ip().to_string())
.await
);
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post("/api/v1/sync/trigger", "{}", peer))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("rate limit exceeded"), "429 body: {s:?}");
assert!(
!s.contains("push"),
"a 429 on a sync route must not say 'push': {s:?}"
);
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ pub async fn set_profile(
&state.http_client,
&state.config.pinata_upload_url,
&state.config.pinata_jwt,
&format!("profile-{}", did),
&format!("profile-{}", &did),
&profile_json,
)
.await
+5 -7
View File
@@ -79,14 +79,12 @@ pub async fn unprotect_branch(
pub async fn list_protected_branches(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// Read-visibility-gated (INV-2 root listing): a public repo's protected
// branches stay anonymously listable; a private repo's branch names are
// hidden (404) from anyone who cannot read it at the root.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let branches = state.db.list_protected_branches(&record.id).await?;
+1 -174
View File
@@ -65,7 +65,6 @@ pub async fn create_pr(
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
head_commit: None,
created_at: now.clone(),
updated_at: now,
};
@@ -217,19 +216,6 @@ pub async fn merge_pr(
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
// The source head this merge is about to consume, read under the write lock.
// The PR row above was loaded before the lock, so a push landing in between
// already moved the branch and the row's own `head_commit` is stale; the
// value frozen on the merged PR has to be what was merged. `None` when the
// ref cannot be resolved, which leaves whatever the push path last stored.
let merged_source_head = store::list_refs(&disk_path).ok().and_then(|refs| {
let want = format!("refs/heads/{}", pr.source_branch);
refs.into_iter()
.find(|(name, _)| *name == want)
.map(|(_, sha)| sha)
});
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
@@ -243,10 +229,7 @@ pub async fn merge_pr(
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
state
.db
.merge_pr(&pr.id, &merger_did, merged_source_head.as_deref())
.await?;
state.db.merge_pr(&pr.id, &merger_did).await?;
let _ = state.db.touch_repo(&record.id).await;
webhooks::fire_event(
@@ -441,159 +424,3 @@ pub async fn list_comments(
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, StatusCode};
use axum::routing::post;
use axum::Router;
use std::process::Command;
use tower::ServiceExt;
const OWNER_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
const STALE_SHA: &str = "9999999999999999999999999999999999999999";
fn git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "[email protected]")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "[email protected]")
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A bare repo with `main` and a `feature` branch one commit ahead.
/// Returns the bare path and the real `feature` tip.
fn bare_repo_with_feature(
root: &std::path::Path,
owner_slug: &str,
) -> (std::path::PathBuf, String) {
let work = root.join("work");
std::fs::create_dir_all(&work).unwrap();
git(&work, &["init", "-q", "-b", "main", "."]);
std::fs::write(work.join("a.txt"), "one").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-qm", "one"]);
git(&work, &["checkout", "-qb", "feature"]);
std::fs::write(work.join("b.txt"), "two").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-qm", "two"]);
git(&work, &["checkout", "-q", "main"]);
let bare_dir = root.join(owner_slug);
std::fs::create_dir_all(&bare_dir).unwrap();
let bare = bare_dir.join("demo.git");
git(
root,
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
let tip = Command::new("git")
.args(["rev-parse", "refs/heads/feature"])
.current_dir(&bare)
.output()
.unwrap();
let tip = String::from_utf8_lossy(&tip.stdout).trim().to_string();
assert_eq!(tip.len(), 40, "feature tip must resolve");
(bare, tip)
}
/// Merging freezes the stored head at the source commit the merge actually
/// consumed. The PR row is loaded before the write lock is taken, so a push
/// that lands in between leaves the row's own `head_commit` stale; seeding a
/// value that matches nothing on disk stands in for that race, and the
/// merged PR must still come out pointing at the real `feature` tip.
#[sqlx::test]
async fn merge_stamps_the_source_head_it_merged_over_a_racing_value(pool: sqlx::PgPool) {
let tmp = tempfile::tempdir().unwrap();
let owner_slug = OWNER_DID.replace([':', '/'], "_");
let (bare, feature_tip) = bare_repo_with_feature(tmp.path(), &owner_slug);
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store =
crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool);
let now = Utc::now();
let repo = crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: "demo".into(),
owner_did: OWNER_DID.into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: now,
updated_at: now,
disk_path: bare.to_string_lossy().into_owned(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&repo).await.unwrap();
let pr = PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo.id.clone(),
number: 1,
title: "add b".into(),
body: None,
author_did: OWNER_DID.into(),
source_branch: "feature".into(),
target_branch: "main".into(),
status: "open".into(),
merged_by_did: None,
merged_at: None,
head_commit: None,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
};
state.db.create_pr(&pr).await.unwrap();
// The stale value the racing push scenario leaves on the row.
state
.db
.set_open_pr_heads(&repo.id, "feature", STALE_SHA)
.await
.unwrap();
let db = state.db.clone();
let app = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
post(merge_pr),
)
.with_state(state);
let response = app
.oneshot(crate::test_support::signed_request_as(
OWNER_DID,
Method::POST,
"/api/v1/repos/z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH/demo/pulls/1/merge",
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let merged = db.get_pr(&repo.id, 1).await.unwrap().unwrap();
assert_eq!(merged.status, "merged");
assert_eq!(
merged.head_commit,
Some(feature_tip),
"the merge must stamp the source head it consumed, not the racing value"
);
}
}
+6 -7
View File
@@ -113,17 +113,16 @@ pub async fn unregister_replica(
}
/// GET /api/v1/repos/:owner/:repo/replicas
/// Read-visibility-gated: a PUBLIC repo's replica list stays anonymously
/// listable (mirror-discovery), but a PRIVATE repo's replica URLs are hidden
/// (404) from anyone who cannot read the repo at its root.
/// Public — returns the list of replicas (DID + URL + registration timestamp).
pub async fn list_replicas(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replicas = state.db.list_replicas(&record.id).await?;
File diff suppressed because it is too large. Load diff
+18 -11
View File
@@ -2,14 +2,14 @@
//!
//! Any authenticated agent can star or unstar a repo.
//! One agent = one star per repo (enforced by UNIQUE constraint in repo_stars).
//! Star count is gated on repo read access.
//! Star count is public — no auth required on GET.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::Result;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// PUT /api/v1/repos/:owner/:repo/star
@@ -19,8 +19,11 @@ pub async fn star_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
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 caller = &auth.0;
let inserted = state.db.star_repo(&record.id, caller).await?;
@@ -51,8 +54,11 @@ pub async fn unstar_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
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 caller = &auth.0;
state.db.unstar_repo(&record.id, caller).await?;
@@ -68,15 +74,16 @@ pub async fn unstar_repo(
}
/// GET /api/v1/repos/:owner/:repo/star
/// Returns star count for callers who can read the repo.
/// Returns star count — unauthenticated.
pub async fn get_star_status(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let count = state.db.count_stars(&record.id).await?;
-657
View File
@@ -1,657 +0,0 @@
//! Commit status claims: the write path and the combined read.
//!
//! POST /api/v1/repos/:owner/:repo/statuses/:sha — append one claim (owner only)
//! GET /api/v1/repos/:owner/:repo/commits/:sha/status — the combined projection
//!
//! Claims are append-only: a producer reporting twice for the same context
//! leaves both rows and the visible status is a projection over the history.
//! Reporting twice means two REQUESTS, though. An exact repeat of one already
//! accepted is answered with 200 and the row it wrote, not a second row: the
//! projection elects the highest `seq`, so a replayed claim would not duplicate
//! a verdict but overturn the one that superseded it.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::StatusClaim;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// Claims for one (repo, commit, producer, context). Generous for a real
/// producer; a producer past it is misbehaving.
const MAX_CLAIMS_PER_TUPLE: i64 = 100;
/// Distinct contexts for one (repo, commit). The context string is caller-chosen
/// and free-form, so the tuple cap alone bounds nothing.
const MAX_CONTEXTS_PER_COMMIT: i64 = 50;
/// Claim rows for one repo within the trailing window the db layer counts over.
/// The commit SHA is caller-chosen and never existence-checked, so a writer at
/// the two caps above can still fan out over fresh 40-hex strings without this
/// one. It bounds the RATE of that fan-out rather than a lifetime total, because
/// nothing prunes the table: a lifetime bound would close the surface on a repo
/// permanently while answering with a status every client retries.
const MAX_CLAIMS_PER_REPO_PER_WINDOW: i64 = 10_000;
#[derive(Deserialize)]
pub struct CreateStatusRequest {
pub state: String,
pub context: String,
pub target_url: Option<String>,
pub description: Option<String>,
}
/// The wire state set (KTD-1): GitHub's four commit-status states, so absence
/// stays distinguishable without a fifth value no client understands.
const CLAIM_STATES: [&str; 4] = ["error", "failure", "pending", "success"];
/// Bounds on the signature material this write path persists verbatim. Every one
/// of the four is caller-influenced: the headers come off the request, the
/// signing string grows with the component list the caller's Signature-Input
/// chose, and the body is the caller's. Without a bound the claim log's row size
/// is set by whoever writes to it, so these are explicit and generous rather than
/// implicit and absent. A conforming `sign_request` produces roughly 100, 200 and
/// 400 bytes for the first three.
const MAX_SIGNATURE_CHARS: usize = 512;
const MAX_SIGNATURE_INPUT_CHARS: usize = 1024;
const MAX_SIGNING_STRING_CHARS: usize = 4096;
/// The body is a `CreateStatusRequest`, whose own fields are already capped at
/// roughly 3.3 KB in total by the three limits below; this leaves room for JSON
/// framing and nothing more.
///
/// Enforced twice, at two different moments, because the handler's check alone
/// is not the bound it advertises: `require_signature` buffers the whole body
/// before any handler runs, so by the time [`create_status`] measures it the
/// allocation has already happened. `build_router` applies the same number as a
/// `RequestBodyLimitLayer` at the transport, outside the auth layers, which is
/// what makes it a bound on what is ALLOCATED and not only on what is STORED.
/// The handler keeps its own check regardless — it is the one that holds for a
/// handler reached by any other route table, and it is what the material size
/// tests drive.
pub(crate) const MAX_REQUEST_BODY_BYTES: usize = 8192;
const MAX_CONTEXT_CHARS: usize = 255;
const MAX_TARGET_URL_CHARS: usize = 2048;
const MAX_DESCRIPTION_CHARS: usize = 1024;
/// The 201 body for an accepted claim: what the client needs to identify the row
/// it just wrote, and nothing else.
///
/// Deliberately not the stored [`StatusClaim`]. That struct carries the signature
/// material, and serializing it echoed the signature, the signing string and the
/// whole request body back on every write — the body as a JSON array of integers.
/// Write-time provenance belongs in the row, not on the wire, which is the same
/// line [`StatusEntry`] draws on the read side.
#[derive(Serialize)]
pub struct CreatedStatus {
pub id: String,
/// The database-assigned ordering key, so the client can name its own row.
pub seq: i64,
pub repo_id: String,
pub commit_sha: String,
pub state: String,
pub context: String,
pub target_url: Option<String>,
pub description: Option<String>,
pub producer_did: String,
pub created_at: String,
}
impl CreatedStatus {
fn from_claim(claim: StatusClaim, seq: i64) -> Self {
Self {
id: claim.id,
seq,
repo_id: claim.repo_id,
commit_sha: claim.commit_sha,
state: claim.state,
context: claim.context,
target_url: claim.target_url,
description: claim.description,
producer_did: claim.producer_did,
created_at: claim.created_at,
}
}
}
/// POST /api/v1/repos/:owner/:repo/statuses/:sha
pub async fn create_status(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, sha)): Path<(String, String, String)>,
material: Option<Extension<crate::auth::SignatureMaterial>>,
Json(req): Json<CreateStatusRequest>,
) -> Result<(StatusCode, Json<CreatedStatus>)> {
// Read-visibility first, then owner, and the order is the security property.
// authorize_repo_read denies a quarantined repo before the visibility gate and
// answers with the repo's own not-found, byte-identical to a missing repo, so
// a caller who cannot read the repo cannot learn it exists. Loading the repo
// and comparing the owner would answer 403 there, turning this endpoint into
// an existence oracle. require_repo_owner then 403s a non-owner of a repo the
// caller can read, where existence is not secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(&auth.0), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
let commit_sha = normalize_sha(&sha)?;
let claim_state = validate_state(&req.state)?;
let context = validate_context(&req.context)?;
let target_url = validate_target_url(req.target_url.as_deref())?;
let description = validate_description(req.description.as_deref())?;
// KTD-5: the signature material is captured here because the request carries
// the only copy, and `signing_string` is the exact byte sequence the
// middleware verified. Its absence means this handler was reached without
// `require_signature` — a server misconfiguration (a route group that lost
// its auth layer, a handler mounted somewhere new), not anything the client
// did, so it is a 500 and no row is written. Storing an empty payload instead
// would leave every claim from that moment on unverifiable with nothing going
// red, which is exactly the absence-renders-as-success shape.
let Some(Extension(material)) = material else {
return Err(AppError::Internal(anyhow::anyhow!(
"status claim write reached without verified signature material — \
the route is not behind require_signature"
)));
};
// The body is carried only behind the `PersistsSignedBody` marker, which
// this route's group applies outside the auth layers. Its absence means the
// marker did not reach the middleware, because the layer was dropped or
// reordered. That is the same class of misconfiguration as the branch above
// and gets the same answer. Storing an empty body instead would write claims
// that carry a signature over a digest of bytes nobody kept, which is
// exactly the case `stored_claim_re_verifies_and_a_tampered_row_does_not`
// exists to make impossible.
let Some(body) = material.body.clone() else {
return Err(AppError::Internal(anyhow::anyhow!(
"status claim write reached with signature material carrying no request body: \
the route lost its persist-body marker or applied it inside the auth layers"
)));
};
// Bounded before anything is written. The material is verified, which says
// the caller holds the key, not that what they signed is a reasonable size.
bound("signature", material.signature.len(), MAX_SIGNATURE_CHARS)?;
bound(
"signature_input",
material.signature_input.len(),
MAX_SIGNATURE_INPUT_CHARS,
)?;
bound(
"signing_string",
material.signing_string.len(),
MAX_SIGNING_STRING_CHARS,
)?;
bound("request body", body.len(), MAX_REQUEST_BODY_BYTES)?;
// After the bounds, so the scan below walks a signing string of known size.
require_body_bound_signature(&material, &body)?;
let digest = request_digest(&material, &body);
let (signature, signature_input, signing_string, request_body) = (
material.signature,
material.signature_input,
material.signing_string,
body.to_vec(),
);
let claim = StatusClaim {
id: Uuid::new_v4().to_string(),
// Ignored on insert: the database assigns the ordering key (KTD-3).
seq: 0,
repo_id: record.id.clone(),
commit_sha,
state: claim_state,
context,
target_url,
description,
// Both are the owner identity today (KTD-5); they split when delegated
// capabilities land.
producer_did: auth.0.clone(),
authorizing_did: auth.0,
signature,
signature_input,
signing_string,
request_body,
request_digest: digest,
created_at: Utc::now().to_rfc3339(),
};
let caps = crate::db::ClaimCaps {
per_tuple: MAX_CLAIMS_PER_TUPLE,
contexts_per_commit: MAX_CONTEXTS_PER_COMMIT,
per_repo_window: MAX_CLAIMS_PER_REPO_PER_WINDOW,
};
match state.db.insert_status_claim_capped(&claim, &caps).await? {
crate::db::ClaimInsert::Inserted(seq) => Ok((
StatusCode::CREATED,
Json(CreatedStatus::from_claim(claim, seq)),
)),
// Already recorded, so 200 and the original row rather than 201 and a
// second one. The claim the caller gets back is the stored one, id and
// seq included, which is what makes a retry safe to treat as success.
crate::db::ClaimInsert::AlreadyRecorded(existing) => {
let seq = existing.seq;
Ok((
StatusCode::OK,
Json(CreatedStatus::from_claim(*existing, seq)),
))
}
crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!(
"claim limit reached for {which}"
))),
}
}
/// Refuse a claim whose signature does not cover the body about to be stored.
///
/// A claim IS provenance, so the signature stored beside it has to bind the
/// bytes stored beside it. It does not do that directly: `signing_string`
/// reaches the body only through a `content-digest` line. And a caller can sign
/// a content-digest the request then never carries. `require_signature` rebuilds
/// the covered values from the request, so an ABSENT `Content-Digest` header
/// makes the covered digest the empty string on the signing side and the
/// verifying side alike; the Ed25519 check passes over method, path and an empty
/// digest, and any body at all rides along. The row that write produces asserts
/// a provenance that cannot be re-verified: the signing string covers a digest
/// of bytes that are not the ones in `request_body`.
///
/// The predicate here is the write-time form of the one `re_verify` runs against
/// a stored row in the tests — recompute the digest of the bytes about to be
/// persisted and require the signing string to carry exactly that line — so what
/// this route accepts and what a third party can later re-verify are the same
/// question, asked at both ends.
///
/// This is a route property, checked where the claim becomes history, and it is
/// deliberately not the only line of defence: requiring the header in
/// `require_signature` closes the same hole one layer earlier and for every
/// signed route, and that change belongs there rather than here because no other
/// route's correctness should depend on the status handler. The two agree and
/// neither substitutes for the other — without the middleware's requirement the
/// empty digest is still accepted everywhere else, and without this one a later
/// relaxation of the middleware would silently resume writing unverifiable
/// history.
///
/// A 400, not a 500: the material is exactly what the client signed, and what
/// they signed does not cover what they sent.
fn require_body_bound_signature(
material: &crate::auth::SignatureMaterial,
body: &[u8],
) -> Result<()> {
let covered = format!(
"\"content-digest\": {}",
gitlawb_core::http_sig::compute_content_digest(body)
);
// Line-exact rather than a substring search: `build_signing_string` emits one
// component per line, and a `contains` would also be satisfied by the digest
// appearing INSIDE some other covered component's value, which proves
// nothing about what the signature covered.
if material.signing_string.lines().any(|line| line == covered) {
return Ok(());
}
Err(AppError::BadRequest(
"the signature does not cover this request body: send a Content-Digest header \
computed over the body and name \"content-digest\" among the signature's covered \
components"
.into(),
))
}
/// The identity of one signed write, as a hex sha-256.
///
/// All four inputs together, because a replay is the same bytes arriving twice:
/// the signature, the input that names what it covers, the canonical string it
/// was verified over, and the body that string covers through a content-digest.
/// The signature alone would nearly do (it covers the other three transitively),
/// but hashing what is actually stored means the digest describes the row rather
/// than a claim about it.
///
/// Each field is length-prefixed so no two different requests can concatenate to
/// the same bytes: without it, a signature ending in some prefix of the next
/// field would collide with the shorter signature that absorbed it.
///
/// This is not a nonce, and deliberately so. A nonce table needs pruning and a
/// pruning window is a second replay window; the claim row IS the record of what
/// was accepted, so uniqueness on it is self-maintaining.
fn request_digest(material: &crate::auth::SignatureMaterial, body: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for part in [
material.signature.as_bytes(),
material.signature_input.as_bytes(),
material.signing_string.as_bytes(),
body,
] {
h.update((part.len() as u64).to_be_bytes());
h.update(part);
}
format!("{:x}", h.finalize())
}
/// One reported context in the combined response. Signature material is
/// deliberately absent: it is write-time provenance, not read-surface data.
#[derive(Serialize)]
pub struct StatusEntry {
pub state: String,
pub context: String,
pub target_url: Option<String>,
pub description: Option<String>,
pub producer_did: String,
pub created_at: String,
}
/// The combined commit status. `state` never leaves the four-value set (KTD-1),
/// so absence is carried by `total_count` 0 with the pending state rather than a
/// fifth value, and `reported_only` (R19) says out loud that the state covers the
/// contexts that reported, not every check a caller expected.
#[derive(Serialize)]
pub struct CombinedStatus {
pub state: String,
pub sha: String,
pub total_count: usize,
pub statuses: Vec<StatusEntry>,
pub reported_only: bool,
}
/// GET /api/v1/repos/:owner/:repo/commits/:sha/status
///
/// The auth extension is optional and MUST be last in the extractor list: the
/// route group carries `optional_signature`, so an unsigned caller reaches here
/// with no `AuthenticatedDid` at all and a public repo still answers.
pub async fn commit_status(
State(state): State<AppState>,
Path((owner, name, sha)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<CombinedStatus>> {
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
// The gate runs first and on the requested path, before any claim data is
// touched. Its deny is the repo's own not-found, byte-identical to a missing
// repo, so the status surface cannot answer "does this repo exist".
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let commit_sha = normalize_sha(&sha)?;
let statuses = project_claims(&state, &record, &commit_sha).await?;
Ok(Json(CombinedStatus {
state: combined_state(&statuses).to_string(),
sha: commit_sha,
total_count: statuses.len(),
statuses,
reported_only: true,
}))
}
/// The projection, in one place. Both read surfaces call it, so R3's "the rollup
/// is derived by the same projection as the commit read" holds by construction
/// rather than by two implementations happening to agree.
///
/// KTD-5: current authorization, not write-time authorization. An ownership
/// transfer drops the prior owner's claims from the projection while the
/// append-only history keeps them.
async fn project_claims(
state: &AppState,
record: &crate::db::RepoRecord,
commit_sha: &str,
) -> Result<Vec<StatusEntry>> {
let claims = state
.db
.latest_status_claims(&record.id, commit_sha, &record.owner_did)
.await?;
Ok(claims
.into_iter()
.map(|c| StatusEntry {
state: c.state,
context: c.context,
target_url: c.target_url,
description: c.description,
producer_did: c.producer_did,
created_at: c.created_at,
})
.collect())
}
/// The pull request head rollup (R11). `state` stays inside the four wire values
/// whatever happened to the head (KTD-1): an unresolvable head is carried by
/// `head_resolved`, alongside the pull request's own state, so a client can tell
/// "the head could not be resolved" from "the head resolved and nothing reported"
/// without a fifth state value no client understands.
#[derive(Serialize)]
pub struct PullRequestStatus {
pub number: i64,
/// The pull request's own state: open, closed, or merged.
pub pull_request_state: String,
pub head_resolved: bool,
/// The target commit, absent exactly when `head_resolved` is false.
pub sha: Option<String>,
pub state: String,
pub total_count: usize,
pub statuses: Vec<StatusEntry>,
pub reported_only: bool,
}
/// GET /api/v1/repos/:owner/:repo/pulls/:number/status
///
/// Same optional-auth shape as the commit read, and the auth extension MUST stay
/// last in the extractor list.
pub async fn pull_request_status(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<PullRequestStatus>> {
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
// The gate runs first, on the requested path, before the pull request row is
// loaded. Its deny is the repo's own not-found, byte-identical to a missing
// repo. Once it passes, a missing pull request number is the plain not-found:
// existence is no longer secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let head = rollup_head(&state, &record, &pr).await?;
let statuses = match &head {
Some(sha) => project_claims(&state, &record, sha).await?,
None => Vec::new(),
};
Ok(Json(PullRequestStatus {
number: pr.number,
pull_request_state: pr.status,
head_resolved: head.is_some(),
sha: head,
state: combined_state(&statuses).to_string(),
total_count: statuses.len(),
statuses,
reported_only: true,
}))
}
/// Branch-head lookups performed by the rollup fallback, counted so a test can
/// assert WORK DONE rather than only the answer returned. A fallback that
/// re-resolved on every read and then discarded the answer would be invisible in
/// the response and visible here.
#[cfg(test)]
pub(crate) static BRANCH_RESOLVES: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
/// The rollup's target commit (KTD-4).
///
/// The stored head wins whenever it is set. It is frozen at close or merge, so a
/// closed or merged pull request keeps the commit the decision was made against,
/// and only an OPEN pull request with no stored head falls back to the branch.
///
/// The fallback is one database read: the latest push this node recorded for the
/// pull request's source branch, from `repo_push_events`. It deliberately does
/// NOT go through `git::store` ref listing, which needs an acquired repository
/// path — on a cold node that downloads the whole repository from object storage,
/// and this read group carries no rate limiter, so an anonymous caller could
/// drive repeated downloads with a URL.
///
/// `repo_push_events` is the source rather than `branch_cids` because
/// `record_push_events` writes it for every ref update unconditionally, whereas
/// the sole writer of `branch_cids` sits behind a pin CID. A node with no object
/// pinning configured never writes that table, so a resolve keyed on it could
/// never answer there and every open pull request without a stored head reported
/// `head_resolved: false` forever. The residual limit is that only pushes taken
/// after this shipped have rows, so a branch last pushed before then still does
/// not resolve.
///
/// The resolved head is persisted, which makes an unauthenticated GET write. That
/// is only acceptable because the write is self-limiting: it fires exactly when
/// `head_commit` is absent and it fills it, and the fill is conditioned on that
/// same absence in SQL, so the PERSIST happens at most once per pull request.
/// The resolve attempt itself is not once-only — a pull request whose branch does
/// not resolve stores nothing, so there is nothing to short-circuit on and the
/// lookup runs again on every read until it succeeds.
async fn rollup_head(
state: &AppState,
record: &crate::db::RepoRecord,
pr: &crate::db::PullRequest,
) -> Result<Option<String>> {
if let Some(stored) = pr.head_commit.clone() {
return Ok(Some(stored));
}
if pr.status != "open" {
return Ok(None);
}
// The push path records the full ref while a pull request stores the bare
// branch name, the same mismatch `crate::api::repos::branch_from_ref`
// reconciles on the write side; this is that mapping run backwards. The empty
// branch is excluded for the same reason it is there: `refs/heads/` is not a
// branch and nothing can have written it.
if pr.source_branch.is_empty() {
return Ok(None);
}
let ref_name = format!("refs/heads/{}", pr.source_branch);
#[cfg(test)]
BRANCH_RESOLVES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let resolved = state
.db
.latest_push_sha_for_ref(&record.id, &ref_name)
.await?
// A recorded head that is not a commit SHA is no target at all, and
// storing it would leave the rollup pointing at nothing forever.
.and_then(|sha| normalize_sha(&sha).ok());
let Some(sha) = resolved else {
return Ok(None);
};
// Best effort, and deliberately not `?`. The head is already resolved and the
// response does not depend on this write landing, so a transient database
// failure here must not turn a served read into a 500; the next read resolves
// again and re-attempts the fill. Same catch-and-log the sibling writes on the
// push path use (`update_open_pr_heads`, `record_push_events`).
if let Err(e) = state.db.set_pr_head_if_absent(&pr.id, &sha).await {
tracing::warn!(
err = %e,
pr_id = %pr.id,
"failed to persist a resolved pull request head; serving the resolved head anyway"
);
}
Ok(Some(sha))
}
/// KTD-1's fold: any error or failure yields failure, else any pending yields
/// pending, else success. An empty set is pending, never success — total absence
/// of a verdict is its own state and must not read as a pass (R10).
fn combined_state(statuses: &[StatusEntry]) -> &'static str {
if statuses.is_empty() {
return "pending";
}
if statuses
.iter()
.any(|s| s.state == "error" || s.state == "failure")
{
"failure"
} else if statuses.iter().any(|s| s.state == "pending") {
"pending"
} else {
"success"
}
}
/// 40 hex characters, lowercased. The SHA is never existence-checked (KTD-7):
/// a claim describes a commit object, and the object may not have arrived yet.
fn normalize_sha(sha: &str) -> Result<String> {
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(sha.to_ascii_lowercase())
} else {
Err(AppError::BadRequest(
"commit sha must be exactly 40 hexadecimal characters".into(),
))
}
}
/// One size bound on persisted signature material, measured in bytes.
fn bound(what: &str, len: usize, max: usize) -> Result<()> {
if len > max {
return Err(AppError::BadRequest(format!(
"{what} must be at most {max} bytes"
)));
}
Ok(())
}
fn validate_state(state: &str) -> Result<String> {
if CLAIM_STATES.contains(&state) {
Ok(state.to_string())
} else {
Err(AppError::BadRequest(format!(
"state must be one of {}",
CLAIM_STATES.join(", ")
)))
}
}
/// The context is the projection key, so it is trimmed, bounded, and required to
/// be free of control characters.
fn validate_context(context: &str) -> Result<String> {
let trimmed = context.trim();
if trimmed.is_empty() || trimmed.chars().count() > MAX_CONTEXT_CHARS {
return Err(AppError::BadRequest(format!(
"context must be 1 to {MAX_CONTEXT_CHARS} characters"
)));
}
if trimmed.chars().any(char::is_control) {
return Err(AppError::BadRequest(
"context must not contain control characters".into(),
));
}
Ok(trimmed.to_string())
}
fn validate_target_url(url: Option<&str>) -> Result<Option<String>> {
let Some(url) = url else { return Ok(None) };
if url.chars().count() > MAX_TARGET_URL_CHARS {
return Err(AppError::BadRequest(format!(
"target_url must be at most {MAX_TARGET_URL_CHARS} characters"
)));
}
let lower = url.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Ok(Some(url.to_string()))
} else {
Err(AppError::BadRequest(
"target_url must be an http or https URL".into(),
))
}
}
fn validate_description(description: Option<&str>) -> Result<Option<String>> {
match description {
Some(d) if d.chars().count() > MAX_DESCRIPTION_CHARS => Err(AppError::BadRequest(format!(
"description must be at most {MAX_DESCRIPTION_CHARS} characters"
))),
other => Ok(other.map(str::to_string)),
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large. Load diff
+8 -22
View File
@@ -1,8 +1,8 @@
//! Webhook CRUD API.
//!
//! POST /api/v1/repos/:owner/:repo/hooks — create (owner only)
//! GET /api/v1/repos/:owner/:repo/hooks — list (owner only; auth required)
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (owner only)
//! POST /api/v1/repos/:owner/:repo/hooks — create (auth required)
//! GET /api/v1/repos/:owner/:repo/hooks — list
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (auth required)
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
@@ -73,26 +73,12 @@ pub async fn create_webhook(
pub async fn list_webhooks(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// This route sits on `optional_signature`, so the DID is optional. Webhook
// callback URLs are owner-secret config and there is no anonymous form, so a
// headerless caller is rejected before any lookup (401, which fires for an
// existing-private and an absent repo alike, so it leaks no existence).
let Some(Extension(AuthenticatedDid(caller))) = auth else {
return Err(AppError::Unauthorized(
"listing webhooks requires authentication".into(),
));
};
// Read-visibility first, then owner. authorize_repo_read returns 404 on a
// visibility deny, so a non-reader of a private repo cannot tell it exists
// (uniform with the sibling read surfaces); require_repo_owner then yields
// 403 only for a non-owner of a public/readable repo, where existence is not
// secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(&caller), "/").await?;
crate::api::require_repo_owner(&record, &caller)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let hooks = state.db.list_webhooks(&record.id).await?;
// Redact secrets in list response
-164
View File
@@ -17,53 +17,6 @@ use crate::state::AppState;
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
/// The RFC 9421 material this request was actually verified against, injected
/// alongside [`AuthenticatedDid`] by `require_signature`.
///
/// A handler that records a signed claim as history has to store it: the header
/// values and the canonical signing string are gone once the request ends, and a
/// stored claim nobody can re-verify is not history. `signing_string` is the exact
/// byte sequence the Ed25519 verification succeeded over, not a rebuild — a
/// handler reconstructing it from headers would be verifying a different string
/// than the middleware did.
#[derive(Clone, Debug)]
pub struct SignatureMaterial {
/// The `Signature` header value.
pub signature: String,
/// The `Signature-Input` header value.
pub signature_input: String,
/// The canonical bytes the signature covered.
pub signing_string: String,
/// The buffered request body, on the routes that persist it. The signing
/// string covers it only through the content-digest, so without these bytes
/// a stored claim can be shown to carry *a* valid signature over *some*
/// digest and nothing more. Carried here because the body is consumed
/// downstream by the extractor and cannot be recovered afterwards.
///
/// `Option`, and populated only behind [`PersistsSignedBody`], because a
/// route that never stores the body has no use for a second handle to it.
/// The unlike-sized field is this one: the three above are header-derived
/// and bounded by the server's header limit, while a receive-pack POST
/// carries up to GITLAWB_MAX_PACK_BYTES (2 GB by default). `Bytes`, not
/// `Vec<u8>`, so the handle the marked routes do take is a refcount bump
/// over the buffer the middleware already holds rather than a copy.
pub body: Option<axum::body::Bytes>,
}
/// Marks a route group whose handler persists the signed request body.
///
/// Applied as an extension layer OUTSIDE the auth layers, so it is on the
/// request before [`require_signature`] decides whether to carry the body. A
/// marker applied inside them is never seen, and the failure is silent: the
/// handler still compiles, tests that inject material by hand still pass, and
/// production stores nothing. The status write handler refuses an absent body
/// for that reason.
///
/// A marker rather than a path check because `require_signature` must not learn
/// which URL persists claims; that knowledge belongs at the route table.
#[derive(Clone, Copy, Debug)]
pub struct PersistsSignedBody;
/// Whether `caller` is authorized to push to `record`.
///
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
@@ -285,28 +238,10 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
tracing::info!(did = %sig.key_id, "✓ authenticated request");
// The body travels only where it is stored. On every other signed route the
// middleware's handle would keep the buffer alive for the rest of the layer
// chain, until the handler's own extractor consumes the request.
let body = parts
.extensions
.get::<PersistsSignedBody>()
.map(|_| body_bytes.clone());
let material = SignatureMaterial {
signature: sig_header,
signature_input: sig_input,
signing_string,
body,
};
let mut request = Request::from_parts(parts, Body::from(body_bytes));
request
.extensions_mut()
.insert(AuthenticatedDid(sig.key_id.to_string()));
// Carry the verified material forward for handlers that persist a signed
// claim; none of it can be recovered once the request is gone.
request.extensions_mut().insert(material);
next.run(request).await
}
@@ -579,109 +514,10 @@ mod tests {
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)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
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)),
shutdown_tx: tokio::sync::watch::channel(false).0,
}
}
/// Reports what the signature middleware put in `SignatureMaterial.body`,
/// so a test can tell "captured" from "deliberately not captured" apart
/// from "the middleware never ran".
async fn report_body(material: Option<axum::Extension<SignatureMaterial>>) -> String {
match material {
Some(axum::Extension(m)) => match m.body {
Some(bytes) => format!("captured:{}", bytes.len()),
None => "absent".to_string(),
},
None => "no-material".to_string(),
}
}
fn signed_post(path: &str, body: &[u8]) -> Request {
let kp = Keypair::generate();
let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", path, body);
Request::builder()
.method("POST")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(axum::body::Body::from(body.to_vec()))
.unwrap()
}
/// The body handle travels only on a route that asked for it.
///
/// Both arms drive a genuinely signed request through `require_signature`;
/// the only difference is the [`PersistsSignedBody`] marker layer. A
/// middleware that captured unconditionally would make both arms read
/// `captured`, and one that never read the marker would make both read
/// `absent`, so the pair pins the decision rather than the mechanism.
#[tokio::test]
async fn the_body_is_carried_only_when_the_route_marks_itself_as_persisting_it() {
let body = br#"{"state":"success"}"#;
// The marker layer is added AFTER the middleware layer, which is what
// makes it the outer of the two and therefore the one that runs first.
// This is the same relative position `build_router` uses on the status
// write group, where the marker sits outside `add_auth_layers`.
let marked = Router::new()
.route("/", axum::routing::post(report_body))
.layer(middleware::from_fn(require_signature))
.layer(axum::Extension(PersistsSignedBody));
let resp = marked.oneshot(signed_post("/", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
assert_eq!(
String::from_utf8(seen.to_vec()).unwrap(),
format!("captured:{}", body.len()),
"a route carrying the persist marker must receive the buffered body"
);
let unmarked = Router::new()
.route("/", axum::routing::post(report_body))
.layer(middleware::from_fn(require_signature));
let resp = unmarked.oneshot(signed_post("/", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
assert_eq!(
String::from_utf8(seen.to_vec()).unwrap(),
"absent",
"a signed route that never persists the body must not be handed a handle to it"
);
}
/// The marker is read from the request the middleware was given, and the
/// request it hands downstream still carries it. Both halves matter: the
/// first is the capture decision, the second is that rebuilding the request
/// from its parts does not drop extensions a later layer may want.
#[tokio::test]
async fn the_marker_survives_the_request_rebuild() {
async fn report_marker(marker: Option<axum::Extension<PersistsSignedBody>>) -> String {
match marker {
Some(_) => "marked".to_string(),
None => "unmarked".to_string(),
}
}
let app = Router::new()
.route("/", axum::routing::post(report_marker))
.layer(middleware::from_fn(require_signature))
.layer(axum::Extension(PersistsSignedBody));
let resp = app.oneshot(signed_post("/", b"{}")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
assert_eq!(
String::from_utf8(seen.to_vec()).unwrap(),
"marked",
"the marker must reach the handler, so the middleware saw it too"
);
}
#[tokio::test]
async fn require_ucan_chain_no_header_passes_through() {
let state = make_test_state(Keypair::generate().did());
+2 -3
View File
@@ -52,7 +52,6 @@ pub async fn issue_ref_certificate(
issued_at,
};
// 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
state.db.insert_ref_certificate(&cert).await?;
Ok(cert)
}
+40 -105
View File
@@ -1,9 +1,48 @@
use clap::Parser;
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
/// Admin subcommands. When absent, the binary runs the node daemon as usual.
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Bulk-delete repos whose name matches a Postgres regex, removing their DB
/// rows, on-disk bare repo, and Tigris archive. Intended for spam cleanup.
/// Defaults to a dry run; pass --execute to actually delete.
PurgeSpam(PurgeSpamArgs),
}
#[derive(Args, Debug, Clone)]
pub struct PurgeSpamArgs {
/// Postgres regex matched against `repos.name` (e.g. '^keep[0-9]').
#[arg(long)]
pub regex: String,
/// Actually delete. Without this flag the command only reports what would
/// be removed (dry run).
#[arg(long, default_value_t = false)]
pub execute: bool,
/// Stop after processing at most this many repos (0 = no limit).
#[arg(long, default_value_t = 0)]
pub limit: i64,
/// Skip deleting the Tigris archive (DB + on-disk only).
#[arg(long, default_value_t = false)]
pub skip_tigris: bool,
/// Bulk mode: delete all matching rows with set-based SQL (a handful of
/// statements) + remove their on-disk repos, instead of one transaction per
/// repo. Far faster on large match sets / small DB instances. Skips Tigris.
#[arg(long, default_value_t = false)]
pub bulk: bool,
}
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
pub struct Config {
/// Optional admin subcommand. When omitted, runs the node daemon.
#[command(subcommand)]
pub command: Option<Command>,
/// Directory where bare git repositories are stored
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
pub repos_dir: PathBuf,
@@ -140,20 +179,6 @@ pub struct Config {
#[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)]
pub max_pack_bytes: usize,
/// Per-client-IP rate limit for `POST /api/v1/sync/trigger`, in requests per
/// hour. `/sync/trigger` requires a signature and drives an O(peers) outbound
/// fan-out per call, so it gets a tight bucket. `0` disables. Default: 60.
#[arg(long, env = "GITLAWB_SYNC_TRIGGER_RATE_LIMIT", default_value_t = 60)]
pub sync_trigger_rate_limit: usize,
/// Per-client-IP rate limit for the peer-write routes (`/peers/announce`,
/// `/sync/notify`), in requests per hour. These accept unsigned requests from
/// known peers and run at higher frequency, so the bucket is generous. Keeping
/// it separate from the trigger bucket stops an unsigned notify flood from
/// draining the signed trigger caller's quota. `0` disables. Default: 600.
#[arg(long, env = "GITLAWB_PEER_WRITE_RATE_LIMIT", default_value_t = 600)]
pub peer_write_rate_limit: usize,
/// Optional address to bind a Prometheus `/metrics` exposition endpoint on.
/// Example: `127.0.0.1:9091`. Leave empty (default) to disable.
/// Bind to localhost or a private interface — the metrics endpoint is
@@ -166,74 +191,6 @@ pub struct Config {
/// in flight and exits. Default: 30s.
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
pub shutdown_grace_secs: u64,
/// Maximum wall-clock time a single served git operation (upload-pack /
/// receive-pack through `run_git_service`) may run before it is aborted and
/// its process group torn down, in seconds. Bounds a git that neither
/// finishes nor disconnects. Must be positive; set it very large to
/// effectively disable the bound. Default: 600s (10 min), generous for large
/// clones. Does not cover the ref advertisement (`info/refs`) or the
/// withheld-blob fetch path (`upload_pack_excluding`, a blocking
/// `spawn_blocking` a tokio timeout cannot cancel); both remain unbounded.
#[arg(
long,
env = "GITLAWB_GIT_SERVICE_TIMEOUT_SECS",
default_value_t = 600,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub git_service_timeout_secs: u64,
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
/// (connections open lazily). Size against the database server's
/// max_connections, remembering admin tooling opens its own pool.
#[arg(
long,
env = "GITLAWB_DB_MAX_CONNECTIONS",
default_value_t = 20,
value_parser = clap::value_parser!(u32).range(1..)
)]
pub db_max_connections: u32,
/// Maximum time a request waits for a pool connection before failing with
/// 503, in seconds. Bounds queueing when the database is slow or down.
#[arg(
long,
env = "GITLAWB_DB_ACQUIRE_TIMEOUT_SECS",
default_value_t = 5,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub db_acquire_timeout_secs: u64,
/// Upper bound on each startup connect-and-migrate attempt, in seconds.
/// Migrations wait on a cross-instance advisory lock, so this must be
/// generous enough for a peer instance to finish migrating; on expiry the
/// attempt is retried (migrations are idempotent).
#[arg(
long,
env = "GITLAWB_DB_CONNECT_TIMEOUT_SECS",
default_value_t = 60,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub db_connect_timeout_secs: u64,
/// First retry delay when the database is unavailable at startup, in
/// seconds. Doubles each attempt up to --db-retry-max-secs.
#[arg(
long,
env = "GITLAWB_DB_RETRY_INITIAL_SECS",
default_value_t = 5,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub db_retry_initial_secs: u64,
/// Ceiling on the startup retry delay, in seconds.
#[arg(
long,
env = "GITLAWB_DB_RETRY_MAX_SECS",
default_value_t = 60,
value_parser = clap::value_parser!(u64).range(1..)
)]
pub db_retry_max_secs: u64,
}
impl Config {
@@ -251,25 +208,3 @@ impl Config {
PathBuf::from(&self.key_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
assert_eq!(
Config::parse_from(["gitlawb-node"]).git_service_timeout_secs,
600
);
assert_eq!(
Config::parse_from(["gitlawb-node", "--git-service-timeout-secs", "30"])
.git_service_timeout_secs,
30
);
// 0 is a footgun (immediate-504 on every request); clap must reject it.
assert!(
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).is_err()
);
}
}
File diff suppressed because it is too large. Load diff
+13 -112
View File
@@ -23,98 +23,24 @@ pub enum AppError {
#[allow(dead_code)]
Forbidden(String),
#[error("icaptcha proof required: {message}")]
IcaptchaProofRequired {
message: String,
/// iCaptcha service base URL the client should solve against.
url: String,
/// Minimum proof level this node requires.
level: u32,
},
#[error("icaptcha proof required: {0}")]
IcaptchaProofRequired(String),
#[error("invalid request: {0}")]
BadRequest(String),
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("incomplete: {0}")]
Incomplete(String),
#[error("git error: {0}")]
Git(String),
#[error("git service timed out: {0}")]
Timeout(String),
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(anyhow::Error),
}
/// Shared error code/message for "the database is unreachable", also used by
/// the degraded startup server (main.rs) and the readiness probe (server.rs)
/// so clients see one vocabulary for the condition.
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
pub const DB_UNAVAILABLE_MESSAGE: &str = "database is temporarily unavailable";
/// Connection-level sqlx failures that mean the database is unreachable right
/// now (retryable, 503), as opposed to server-reported query errors.
fn db_unavailable(e: &sqlx::Error) -> bool {
matches!(
e,
sqlx::Error::PoolTimedOut
| sqlx::Error::PoolClosed
| sqlx::Error::Io(_)
| sqlx::Error::Tls(_)
)
}
/// The db layer returns `anyhow::Result`, so sqlx errors reach handlers inside
/// anyhow chains. Downcast them back out so the status mapping below can see
/// them — without this, every database outage surfaces as a 500 instead of a
/// 503. anyhow preserves downcastability through `.context()` layers.
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
match err.downcast::<sqlx::Error>() {
Ok(sql) => AppError::Db(sql),
Err(err) => AppError::Internal(err),
}
}
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// iCaptcha challenges carry structured discovery so clients don't have to
// scrape the message: the service URL and required level are returned as
// both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers
// (mirroring the header-bearing `human_detected` response in auth/mod.rs).
if let AppError::IcaptchaProofRequired {
message,
url,
level,
} = &self
{
use axum::http::HeaderValue;
let body = Json(json!({
"error": "icaptcha_proof_required",
"message": message,
"icaptcha_url": url,
"required_level": level,
}));
let mut resp = (StatusCode::FORBIDDEN, body).into_response();
let headers = resp.headers_mut();
if let Ok(v) = HeaderValue::from_str(url) {
headers.insert("x-icaptcha-url", v);
}
if let Ok(v) = HeaderValue::from_str(&level.to_string()) {
headers.insert("x-icaptcha-level", v);
}
return resp;
}
let (status, code, message) = match &self {
AppError::RepoNotFound(r) => (
StatusCode::NOT_FOUND,
@@ -129,24 +55,17 @@ impl IntoResponse for AppError {
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()),
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::TooManyRequests(msg) => {
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
}
AppError::Incomplete(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
}
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
// 504, distinct from the 500 git_error and from the read-gate's 404 /
// the auth 401, so the client can tell a deadline from a failure.
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
AppError::Db(e) if db_unavailable(e) => (
StatusCode::SERVICE_UNAVAILABLE,
DB_UNAVAILABLE_CODE,
DB_UNAVAILABLE_MESSAGE.into(),
// 403, not 401: the caller IS an authenticated agent (credentials are
// valid) but is forbidden from this action without a valid, fresh
// iCaptcha proof. The distinct `icaptcha_proof_required` code — which
// clients branch on — keeps it separable from a plain `forbidden`.
AppError::IcaptchaProofRequired(msg) => (
StatusCode::FORBIDDEN,
"icaptcha_proof_required",
msg.clone(),
),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
AppError::Internal(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -165,21 +84,3 @@ impl IntoResponse for AppError {
}
pub type Result<T> = std::result::Result<T, AppError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeout_maps_to_504_distinct_from_git_500() {
assert_eq!(
AppError::Timeout("x".into()).into_response().status(),
StatusCode::GATEWAY_TIMEOUT
);
// Guard against a swap with the generic git failure (500).
assert_eq!(
AppError::Git("x".into()).into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
-370
View File
@@ -303,131 +303,6 @@ fn validate_path_components(owner_did: &str, repo_name: &str) -> Result<()> {
Ok(())
}
/// Validate a peer-supplied `owner/name` sync slug and return its two halves.
///
/// The sync queue carries a single `repo` string that peers control, and the
/// worker turns it into a filesystem path. `PathBuf::join` does not normalize,
/// and an absolute second component replaces the accumulated path, so an
/// unvalidated `a//tmp/x` resolved to `/tmp/x.git` outside `repos_dir` (#272).
///
/// The halves are checked with the same validators that guard
/// `RepoStore::local_path`, so there is one owner rule and one name rule in the
/// crate. The one rule added here is the leading `.`/`-` check on the owner
/// half: `validate_owner_did` has no such rule (it also serves full DIDs, which
/// always start with `d`), and without it an owner half of `.` puts a
/// peer-controlled mirror at the `repos_dir` root, which canonicalizes back
/// inside the root and so passes containment.
pub(crate) fn validate_repo_slug(slug: &str) -> Result<(&str, &str)> {
let mut parts = slug.split('/');
let (Some(owner), Some(name)) = (parts.next(), parts.next()) else {
anyhow::bail!("repo slug must be 'owner/name'");
};
if parts.next().is_some() {
anyhow::bail!("repo slug must contain exactly one '/'");
}
if owner.is_empty() || name.is_empty() {
anyhow::bail!("repo slug has an empty owner or name");
}
if owner.starts_with('.') || owner.starts_with('-') {
anyhow::bail!("repo slug owner must not start with '.' or '-'");
}
// The owner half becomes one path component, so it is bounded by NAME_MAX
// (255), not by the DID column's 256. The two differ by exactly one, and
// that one length is the gap that matters: validate_owner_did accepts 256,
// create_dir_all then fails with ENAMETOOLONG on every attempt, and the
// worker leaves such a row pending, so it is re-picked forever. Rejecting
// it here means an undeliverable slug never enters the queue at all.
if owner.len() > 255 {
anyhow::bail!("repo slug owner exceeds 255 chars");
}
validate_owner_did(owner)?;
validate_repo_name(name)?;
Ok((owner, name))
}
/// The answer from [`path_within_root`].
///
/// Three-valued rather than a bool because the two negative answers call for
/// opposite handling. `Outside` is a deterministic verdict about a hostile or
/// misconfigured path: the same input fails the same way forever, so the caller
/// can retire the work. `IoError` says the question could not be answered at
/// all (EACCES, an unmounted root), which is transient, so the caller must keep
/// the work and try again rather than permanently retire a legitimate repo.
#[derive(Debug)]
pub(crate) enum Containment {
/// The candidate resolves inside the root.
Contained,
/// The candidate resolves outside the root.
Outside,
/// The filesystem could not answer the question.
IoError(std::io::Error),
}
/// Does `candidate` canonically resolve inside `root`?
///
/// The third layer of path defence, after the character allowlist and the
/// component walk on `RepoStore::local_path`. Those two read the path as text
/// and cannot see a symlink standing between the root and the target (#272).
///
/// One contract covers both the clone and the fetch branch. `symlink_metadata`
/// decides which:
///
/// * The candidate exists (including as a symlink), so the candidate itself is
/// canonicalized. That resolves the link and catches a mirror path that is a
/// symlink to a bare repo outside the root, which a parent-only check misses
/// entirely: the parent canonicalizes clean, `exists()` follows the link, and
/// the fetch then writes through it.
/// * The candidate does not exist, so its parent is canonicalized instead.
/// This is the first-clone case. Canonicalizing the candidate unconditionally
/// would reject every first clone, since `canonicalize` errors on a path that
/// does not exist.
///
/// Pure: it reads the filesystem and never creates, moves, or removes anything.
/// Callers that need the parent directory to exist create it themselves before
/// asking, because a predicate that created a directory as a side effect of
/// being asked would be wrong for a caller asking about a path it is about to
/// delete.
pub(crate) fn path_within_root(candidate: &Path, root: &Path) -> Containment {
let root = match root.canonicalize() {
Ok(p) => p,
// A root that cannot be resolved is an operator condition, never a
// verdict on the candidate, so every error kind is retryable here.
Err(e) => return Containment::IoError(e),
};
let resolved = match std::fs::symlink_metadata(candidate) {
// The candidate exists as an entry: resolve it, links and all. A failure
// now (a dangling symlink, a permission change mid-flight) is an I/O
// answer, since the entry was there a moment ago.
Ok(_) => match candidate.canonicalize() {
Ok(p) => p,
Err(e) => return Containment::IoError(e),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(parent) = candidate.parent() else {
return Containment::Outside;
};
match parent.canonicalize() {
Ok(p) => p,
// A parent that is not there is a real answer about where this
// path sits; anything else is the filesystem failing to answer.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Containment::Outside;
}
Err(e) => return Containment::IoError(e),
}
}
// Neither "it is there" nor "it is not there": we cannot tell.
Err(e) => return Containment::IoError(e),
};
if resolved.starts_with(&root) {
Containment::Contained
} else {
Containment::Outside
}
}
fn validate_owner_did(owner_did: &str) -> Result<()> {
if owner_did.is_empty() {
anyhow::bail!("owner_did is empty");
@@ -530,251 +405,6 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
mod tests {
use super::*;
// ── sync slug validation (#272) ────────────────────────────────────────
#[test]
fn slug_accepts_owner_and_name() {
let (owner, name) = validate_repo_slug("z6Mkfoo/hello").expect("valid slug");
assert_eq!(owner, "z6Mkfoo");
assert_eq!(name, "hello");
}
#[test]
fn slug_rejects_traversal_in_owner_half() {
assert!(validate_repo_slug("../hello").is_err());
}
#[test]
fn slug_rejects_owner_half_only_the_did_validator_catches() {
// These are the cases that isolate the `validate_owner_did` delegation.
// `../hello` does NOT: the leading-character rule above rejects it
// first, so deleting the delegation leaves that case green. Each owner
// half here has exactly one separator, a non-empty name, and a leading
// character the slug rules allow, so only the delegation can reject it.
for bad in [
"a..b/hello", // interior `..` sequence
"a%2e%2e/hello", // percent-encoded, disallowed `%`
"own\\er/hello", // backslash
] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_extra_separator() {
// The verified #272 escape: `a//tmp/x` joined to an absolute
// `/tmp/x.git` outside repos_dir.
assert!(validate_repo_slug("a//tmp/gitlawb-probe").is_err());
assert!(validate_repo_slug("../../etc/evil").is_err());
assert!(validate_repo_slug("a/../../x").is_err());
}
#[test]
fn slug_rejects_trailing_segment_only_the_separator_count_catches() {
// The case that isolates the separator-count rule. Every slug in
// `slug_rejects_extra_separator` is caught by some earlier rule
// instead: `a//tmp/...` has an empty name half, `../../etc/evil` trips
// the leading-character rule, and `a/../../x` has `..` as its name. Here
// both halves are individually valid, so only the count can reject it.
// It matters because the worker would otherwise join
// `repos_dir/z6Mkfoo/hello.git` while composing the remote URL from the
// full three-segment slug, silently mirroring one repo under another's
// path.
assert!(validate_repo_slug("z6Mkfoo/hello/extra").is_err());
}
#[test]
fn slug_rejects_missing_separator() {
for bad in ["..", "demo", ""] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_empty_half() {
for bad in ["/hello", "z6Mkfoo/"] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_leading_dot_or_dash_owner() {
// `./hello` would otherwise resolve to a mirror at the repos_dir root,
// which the containment check would approve.
for bad in ["./hello", "-owner/hello"] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_bad_name_half() {
for bad in [
"z6Mkfoo/he\0llo",
"z6Mkfoo/.hidden",
"z6Mkfoo/-dash",
"z6Mkfoo/a..b",
] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_overlong_halves() {
let long_owner = format!("{}/hello", "z".repeat(257));
let long_name = format!("z6Mkfoo/{}", "n".repeat(101));
assert!(validate_repo_slug(&long_owner).is_err());
assert!(validate_repo_slug(&long_name).is_err());
}
#[test]
fn slug_rejects_owner_half_at_the_filesystem_name_limit() {
// The owner half becomes a single path component, and Linux NAME_MAX is
// 255, so 256 is accepted by validate_owner_did (which bails only above
// 256) but can never be created on disk. That made the sync row
// permanently un-runnable: create_dir_all failed with ENAMETOOLONG on
// every pass and the worker left the row pending, so ten unsigned
// requests could hold the whole oldest-first batch forever.
assert!(validate_repo_slug(&format!("{}/hello", "z".repeat(256))).is_err());
// 255 is the largest creatable component and must still be accepted, so
// the bound is not quietly over-tightened.
assert!(validate_repo_slug(&format!("{}/hello", "z".repeat(255))).is_ok());
}
// ── canonical containment (#272) ───────────────────────────────────────
use tempfile::TempDir;
#[test]
fn containment_accepts_a_path_inside_the_root() {
let root = TempDir::new().unwrap();
let inside = root.path().join("z6Mkfoo");
std::fs::create_dir_all(&inside).unwrap();
assert!(matches!(
path_within_root(&inside.join("hello.git"), root.path()),
Containment::Contained
));
}
#[test]
fn containment_rejects_a_sibling_outside_the_root() {
let base = TempDir::new().unwrap();
let root = base.path().join("root");
let sibling = base.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&sibling).unwrap();
assert!(matches!(
path_within_root(&sibling, &root),
Containment::Outside
));
}
#[cfg(unix)]
#[test]
fn containment_rejects_a_symlinked_directory_inside_the_root() {
use std::os::unix::fs::symlink;
let base = TempDir::new().unwrap();
let root = base.path().join("root");
let outside = base.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let link = root.join("owner");
symlink(&outside, &link).unwrap();
assert!(matches!(
path_within_root(&link, &root),
Containment::Outside
));
}
#[cfg(unix)]
#[test]
fn containment_rejects_a_symlinked_file_inside_the_root() {
use std::os::unix::fs::symlink;
let base = TempDir::new().unwrap();
let root = base.path().join("root");
std::fs::create_dir_all(&root).unwrap();
let outside = base.path().join("secret.txt");
std::fs::write(&outside, b"x").unwrap();
let link = root.join("hello.git");
symlink(&outside, &link).unwrap();
assert!(matches!(
path_within_root(&link, &root),
Containment::Outside
));
}
#[test]
fn containment_accepts_a_missing_candidate_whose_parent_is_inside() {
// The first-clone case: the mirror path does not exist yet, so only the
// parent can be canonicalized. Rejecting this is total loss of mirroring.
let root = TempDir::new().unwrap();
let owner = root.path().join("z6Mkfoo");
std::fs::create_dir_all(&owner).unwrap();
let candidate = owner.join("hello.git");
assert!(!candidate.exists());
assert!(matches!(
path_within_root(&candidate, root.path()),
Containment::Contained
));
}
#[cfg(unix)]
#[test]
fn containment_rejects_a_missing_candidate_under_a_symlinked_parent() {
use std::os::unix::fs::symlink;
let base = TempDir::new().unwrap();
let root = base.path().join("root");
let outside = base.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, root.join("z6Mkfoo")).unwrap();
let candidate = root.join("z6Mkfoo").join("hello.git");
assert!(matches!(
path_within_root(&candidate, &root),
Containment::Outside
));
}
#[cfg(unix)]
#[test]
fn containment_reports_io_error_for_a_dangling_symlink() {
// The link entry exists, so the candidate is the thing to resolve, and
// resolving it fails. That is an I/O answer, not a verdict of Outside:
// the worker must retry rather than permanently retire the row.
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let link = root.path().join("hello.git");
symlink(root.path().join("nothing-here"), &link).unwrap();
assert!(matches!(
path_within_root(&link, root.path()),
Containment::IoError(_)
));
}
#[test]
fn containment_reports_io_error_for_an_uncanonicalizable_root() {
// A repos_dir that cannot be resolved is an operator condition (an
// unmounted volume, a bad config), not a hostile path.
let base = TempDir::new().unwrap();
let root = base.path().join("not-mounted");
let candidate = base.path().join("not-mounted").join("hello.git");
assert!(matches!(
path_within_root(&candidate, &root),
Containment::IoError(_)
));
}
#[test]
fn containment_creates_nothing_on_disk() {
// The predicate is pure: the admin purge path asks it about directories
// it is about to delete, so creating one as a side effect would be wrong.
let root = TempDir::new().unwrap();
let candidate = root.path().join("z6Mkfoo").join("hello.git");
let _ = path_within_root(&candidate, root.path());
assert!(!candidate.exists());
assert!(!root.path().join("z6Mkfoo").exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
}
// ── repo_name validation ───────────────────────────────────────────────
#[test]
+23 -781
View File
@@ -6,8 +6,7 @@ use bytes::Bytes;
use std::collections::HashSet;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
/// Handle `GET /:owner/:repo/info/refs?service=git-upload-pack`
@@ -52,13 +51,8 @@ pub async fn info_refs(repo_path: &Path, service: &str) -> Result<Response> {
///
/// Serves pack data for a clone or fetch. This is stateless — the entire
/// negotiation happens in a single request/response.
pub async fn upload_pack(
repo_path: &Path,
request_body: Bytes,
timeout: Duration,
) -> Result<Response> {
let output =
run_git_service("git", "git-upload-pack", repo_path, request_body, timeout).await?;
pub async fn upload_pack(repo_path: &Path, request_body: Bytes) -> Result<Response> {
let output = run_git_service("git-upload-pack", repo_path, request_body).await?;
Ok(Response::builder()
.status(StatusCode::OK)
@@ -71,13 +65,8 @@ pub async fn upload_pack(
///
/// Accepts a push. The caller MUST verify HTTP Signature auth before
/// calling this function.
pub async fn receive_pack(
repo_path: &Path,
request_body: Bytes,
timeout: Duration,
) -> Result<Response> {
let output =
run_git_service("git", "git-receive-pack", repo_path, request_body, timeout).await?;
pub async fn receive_pack(repo_path: &Path, request_body: Bytes) -> Result<Response> {
let output = run_git_service("git-receive-pack", repo_path, request_body).await?;
Ok(Response::builder()
.status(StatusCode::OK)
@@ -86,127 +75,6 @@ pub async fn receive_pack(
.body(Body::from(output))?)
}
// ── receive-pack report-status ────────────────────────────────────────────
//
// PORTED VERBATIM from PR #384 (`fix/issue-26-split-1-durable-post-receive`),
// which introduced these two functions for the same reason: `receive_pack`
// forwards git's protocol response untouched, and a zero exit says nothing
// about whether any individual ref was taken. Kept byte-for-byte identical so
// the two branches converge on one parser rather than growing two that disagree
// about what "accepted" means; whichever lands second should drop its copy.
/// Parse the git-receive-pack report-status output to determine per-ref
/// success/failure. Returns `(unpack_ok, per_ref_results)` where
/// `per_ref_results` is a list of `(ref_name, is_ok)`.
///
/// The report-status format (after the sideband framing) is:
/// ```text
/// unpack ok\n (or: unpack fail\n)
/// ok <refname>\n (per successful ref)
/// ng <refname> <reason>\n (per rejected ref)
/// \n (empty line terminates)
/// ```
///
/// Returns `None` if the output cannot be parsed (e.g. the client did
/// not request report-status, or the output is truncated). In that
/// case the caller should treat all refs as uncertain.
pub fn parse_report_status(output: &[u8]) -> Option<(bool, Vec<(String, bool)>)> {
let text = std::str::from_utf8(output).ok()?;
// Strip sideband framing: each line starts with a pkt-line length
// prefix and a channel byte (1 = stdout, 2 = stderr). The actual
// data starts after the first `0000` flush packet or after we
// strip sideband bytes.
let stripped = strip_sideband(text)?;
// DIVERGES FROM #384 by these three lines, and deliberately.
//
// When the client asked for `side-band-64k` — which `git push` over smart
// HTTP does — receive-pack multiplexes the report onto band 1, and what
// travels on that band is itself a pkt-line stream. One pass of
// `strip_sideband` peels the outer framing and leaves `000eunpack ok\n...`,
// whose first line is not `unpack ok`, so the parse below returns None and
// every ref falls back to inconclusive: the parser would answer "cannot
// tell" for exactly the pushes it exists to classify. A second pass peels
// the inner layer. It is a no-op on the single-wrapped shape, because
// plain report text does not begin with four hex digits and
// `strip_sideband` declines it.
let stripped = strip_sideband(&stripped).unwrap_or(stripped);
let lines: Vec<&str> = stripped.lines().collect();
if lines.is_empty() {
return None;
}
// First non-empty line is "unpack ok" or "unpack fail".
let unpack_line = lines.iter().find(|l| !l.is_empty())?;
let unpack_ok = if unpack_line.starts_with("unpack ok") {
true
} else if unpack_line.starts_with("unpack fail") {
false
} else {
return None;
};
let mut results = Vec::new();
for line in &lines[1..] {
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("ok ") {
results.push((rest.to_string(), true));
} else if let Some(rest) = line.strip_prefix("ng ") {
// "ng <refname> <reason>" — skip the reason
let ref_name = rest.split_whitespace().next()?.to_string();
results.push((ref_name, false));
}
}
Some((unpack_ok, results))
}
/// Strip git sideband framing from a pkt-line encoded output.
/// Sideband-encoded lines start with a 4-hex-digit length, then a
/// channel byte (0x01=stdout, 0x02=stderr), then payload. Returns
/// the decoded payload lines concatenated, or `None` if the framing
/// is malformed.
fn strip_sideband(text: &str) -> Option<String> {
let mut output = String::new();
let mut pos = 0;
let bytes = text.as_bytes();
loop {
if pos + 4 > bytes.len() {
break;
}
let len_str = std::str::from_utf8(&bytes[pos..pos + 4]).ok()?;
let len = usize::from_str_radix(len_str, 16).ok()?;
if len == 0 {
// Flush packet — end of sideband stream
break;
}
if len < 4 || pos + len > bytes.len() {
break;
}
let pkt_data = std::str::from_utf8(&bytes[pos + 4..pos + len]).ok()?;
pos += len;
// Sideband: first byte is channel (1=stdout, 2=stderr)
if let Some(payload) = pkt_data.strip_prefix('\x01') {
output.push_str(payload);
} else if pkt_data.starts_with('\x02') {
// stderr — skip (git error messages)
} else {
// Not sideband encoded — pass through
output.push_str(pkt_data);
}
}
if output.is_empty() {
None
} else {
Some(output)
}
}
/// Sends SIGTERM to a child's whole process group on drop, unless disarmed first.
///
/// A served `git upload-pack`/`receive-pack` forks helpers such as `pack-objects`.
@@ -244,87 +112,8 @@ impl Drop for KillGroupOnDrop {
}
}
/// Error returned when [`run_git_service`] aborts a git op on its timeout.
///
/// Carried through the `anyhow` chain so the HTTP handler can `downcast_ref` it
/// and map to 504 Gateway Timeout, distinct from the generic 500 git error.
#[derive(Debug, thiserror::Error)]
#[error("git service timed out")]
pub struct GitServiceTimeout;
/// On a served-git timeout, tear the process group down AND reap the leader
/// before returning, so a caller that releases a write lock (receive-pack) can't
/// race a still-live git touching the same repo. SIGTERM first (lets git remove
/// its `.git/*.lock` files), escalate to SIGKILL if the leader lingers past a
/// grace, then reap. Bounded, so a git that ignores SIGTERM can't block the
/// response unboundedly.
#[cfg(unix)]
async fn reap_group_on_timeout(child: &mut tokio::process::Child) {
let Some(pid) = child.id() else {
// Never got a pid; nothing to signal, best-effort reap.
let _ = child.wait().await;
return;
};
let pgid = pid as i32;
// SAFETY: kill(2) takes only integers and borrows no Rust memory; ESRCH on an
// already-gone group is ignored.
unsafe {
libc::kill(-pgid, libc::SIGTERM);
}
// Wait for the WHOLE group to exit before returning — not just the leader but
// grandchildren (index-pack / pack-objects) that reparent to init and can
// still be touching the repo, so a caller releasing a write lock can't race
// them. Reap our direct child along the way (`try_wait`, else it lingers as a
// zombie) and poll the group's liveness with `kill(-pgid, 0)` (ESRCH once
// every member is gone). SIGTERM grace, then SIGKILL, then a hard cap so a
// stuck process can never block the response unboundedly.
for step in 0..400u32 {
let _ = child.try_wait();
if unsafe { libc::kill(-pgid, 0) } != 0 {
return; // ESRCH: every group member has exited
}
if step == 200 {
// ~2s SIGTERM grace elapsed; force the group down. `step == 200` fires
// exactly once in the 0..400 loop, so no re-entry guard is needed.
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
// ~4s hard cap. Only reached if a group member survives SIGKILL, which means
// it is wedged in uninterruptible (D-state) I/O — a fsync on stuck storage, a
// hung mount — that no signal interrupts until the kernel returns. Nothing in
// userspace can kill it; blocking here (or holding the write lock) would just
// re-create the "hung git pins the repo" problem this bounds. Reap best-effort
// (tokio's orphan reaper collects it once the syscall unblocks) and return;
// git-level quarantine/ref-locking still guards the brief window before the
// D-state process finishes.
tracing::warn!(
pid,
"served git survived SIGKILL past the teardown cap (uninterruptible I/O?); \
releasing without a confirmed group reap"
);
let _ = child.try_wait();
}
/// Run a stateless-rpc git service and return its stdout.
///
/// `git_bin` is the git executable to spawn; production callers pass `"git"`
/// (resolved via `PATH`). It is injectable purely so the process-group teardown
/// wiring (`process_group(0)` + [`KillGroupOnDrop`]) can be driven end-to-end by
/// a fake `git` in tests without mutating the process-global `PATH`.
///
/// `timeout` bounds the whole child interaction; on expiry the op is aborted with
/// [`GitServiceTimeout`] and its process group is torn down.
async fn run_git_service(
git_bin: &str,
service: &str,
repo_path: &Path,
input: Bytes,
timeout: Duration,
) -> Result<Vec<u8>> {
let mut command = Command::new(git_bin);
async fn run_git_service(service: &str, repo_path: &Path, input: Bytes) -> Result<Vec<u8>> {
let mut command = Command::new("git");
command
.arg(service_to_command(service))
.arg("--stateless-rpc")
@@ -342,85 +131,33 @@ async fn run_git_service(
// Arm the group-kill guard for the lifetime of the request. With
// process_group(0) the child is its own group leader, so pgid == its pid.
// This fires on a client disconnect (the whole future is dropped mid-request).
#[cfg(unix)]
let mut group_guard = KillGroupOnDrop {
pgid: child.id().map(|id| id as i32),
};
// Own the pipes so `child` stays reap-able after a timeout: wait_with_output
// would consume it, but on timeout we must actively reap the group before
// returning (see reap_group_on_timeout).
let mut stdin = child.stdin.take();
let mut stdout = child.stdout.take().context("git stdout was not piped")?;
let mut stderr = child.stderr.take().context("git stderr was not piped")?;
let mut out = Vec::new();
let mut err = Vec::new();
// Bound the whole child interaction: a git that neither finishes nor
// disconnects would otherwise pin the pid forever. Write stdin, drain stdout
// and stderr, and wait for the child all concurrently (draining while writing
// avoids a pipe deadlock on a large body), under one deadline.
let interact = async {
// Don't early-return on a write error: the join still waits on the child,
// so an error is surfaced only after the child has been waited on.
let write = async {
match stdin.take() {
Some(mut s) => s.write_all(&input).await,
None => Ok(()),
}
};
let (write_result, r_out, r_err, status) = tokio::join!(
write,
stdout.read_to_end(&mut out),
stderr.read_to_end(&mut err),
child.wait(),
);
r_out?;
r_err?;
Ok::<_, anyhow::Error>((write_result, status?))
// Write the request body to git's stdin, but don't early-return on a write
// error: always reap the child first (below), so the guard only ever fires on
// an actual future-drop (client disconnect), never on a pid we just reaped.
let write_result: std::io::Result<()> = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(&input).await,
None => Ok(()),
};
let timed = tokio::time::timeout(timeout, interact).await;
let (write_result, status) = match timed {
Ok(result) => {
// The join runs all arms to completion, so the child is reaped: disarm
// before surfacing any interaction error (a read/wait error), else the
// guard's drop would fire SIGTERM on the reaped, possibly-reused pgid.
#[cfg(unix)]
group_guard.disarm();
result?
}
Err(_elapsed) => {
// Timeout: tear the whole group down and reap it before returning so a
// caller releasing a write lock can't race a still-live git. Then disarm
// the (now redundant) guard so its drop can't hit a reused pgid.
#[cfg(unix)]
{
reap_group_on_timeout(&mut child).await;
group_guard.disarm();
}
#[cfg(not(unix))]
{
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
}
return Err(GitServiceTimeout.into());
}
};
let output = child.wait_with_output().await?;
// Surface git's own failure (its stderr, which the handler may classify as a
// 400) before any stdin-write error: when git rejects a malformed body it
// exits non-zero and closes stdin, so the write's EPIPE would otherwise mask
// the real cause.
if !status.success() {
let stderr = String::from_utf8_lossy(&err);
bail!("{service} failed: {stderr}");
}
// Child reaped, so its group is gone: disarm before surfacing any error.
#[cfg(unix)]
group_guard.disarm();
write_result.context("failed to write to git stdin")?;
Ok(out)
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("{service} failed: {stderr}");
}
Ok(output.stdout)
}
fn service_to_command(service: &str) -> &str {
@@ -1139,499 +876,4 @@ mod tests {
let _ = child.kill().await;
let _ = child.wait().await;
}
// ── #62 PR1: end-to-end teardown wiring through run_git_service ─────────
//
// The kill_group_guard_* tests above build a KillGroupOnDrop by hand and
// never call run_git_service, so deleting `process_group(0)` (spawn site) or
// the guard-arming, or the post-reap disarm, would leave them green. These
// drive the REAL run_git_service via an injected fake `git` (the `git_bin`
// seam) and assert the production spawn path actually wires the teardown up.
// Faithful to the real invocation: run_git_service spawns
// `<git_bin> <cmd> --stateless-rpc <repo_path>` in its own process group.
/// SIGKILLs the given pids on drop (ignoring all kill errors, e.g. ESRCH for
/// an already-dead pid) so a panicking assertion can't leak the fake `git` or
/// its grandchild onto the test runner.
#[cfg(unix)]
struct ReapOnPanic(Vec<i32>);
#[cfg(unix)]
impl Drop for ReapOnPanic {
fn drop(&mut self) {
for &pid in &self.0 {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
}
}
/// Write an executable fake `git` (named `git`) into `dir`; return its path.
#[cfg(unix)]
fn write_fake_git(dir: &std::path::Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let path = dir.join("git");
std::fs::write(&path, body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
/// Read a two-line `leader\ngrandchild` pidfile once both lines are present.
#[cfg(unix)]
fn read_two_pids(pidfile: &std::path::Path) -> Option<(i32, i32)> {
let s = std::fs::read_to_string(pidfile).ok()?;
let mut lines = s.lines();
let leader: i32 = lines.next()?.trim().parse().ok()?;
let grandchild: i32 = lines.next()?.trim().parse().ok()?;
Some((leader, grandchild))
}
/// Retry policy for the fake-git spawn race, shared by `fake_git_run_with_pids`
/// and the drop test's inline loop (which can't use the helper — it must keep
/// the winning future pending). `nth` retry waits `n * STEP_MS` ms.
#[cfg(unix)]
const FAKE_GIT_RETRY_ATTEMPTS: u64 = 12;
#[cfg(unix)]
const FAKE_GIT_BACKOFF_STEP_MS: u64 = 100;
/// Run a fake-git-based `run` under the parallel test runner, retrying if the
/// fake transiently fails to record its pids. Under `cargo test --workspace`
/// fork-storm load a freshly-written fake `git` can fail to exec (ETXTBSY: a
/// concurrent worker forked while its write fd was still open) or be killed by
/// the service timeout before it is scheduled to write its pidfile; both leave
/// no pids. These misses are *correlated* (bursty) under fork pressure, so each
/// retry backs off (growing) to let a spike subside rather than burning every
/// attempt inside one. `pidfile` is removed before each attempt. Returns the
/// successful attempt's outcome together with the recorded pids; a genuine
/// never-spawns bug still fails loudly after the cap. Retried attempts don't
/// leak in practice: a miss almost always means the fake never exec'd (nothing
/// to clean up), and a spawn that got far enough is reaped by the timeout/drop
/// path before this returns.
#[cfg(unix)]
async fn fake_git_run_with_pids<R, F, Fut>(
pidfile: &std::path::Path,
mut run: F,
) -> (R, (i32, i32))
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = R>,
{
for i in 0..FAKE_GIT_RETRY_ATTEMPTS {
let _ = std::fs::remove_file(pidfile);
let outcome = run().await;
// A successful run writes its pids before it returns, so a short poll
// catches them (allowing only for fs-visibility lag).
for _ in 0..50 {
if let Some(p) = read_two_pids(pidfile) {
return (outcome, p);
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
// Miss: back off before retrying so a bursty fork-pressure spike
// subsides (see the doc note on correlated ETXTBSY/EAGAIN failures).
tokio::time::sleep(std::time::Duration::from_millis(
FAKE_GIT_BACKOFF_STEP_MS * (i + 1),
))
.await;
}
panic!(
"fake git failed to spawn and record its pids after {FAKE_GIT_RETRY_ATTEMPTS} \
attempts (persistent failure, not a transient parallel-runner miss)"
);
}
// Dropping the request future mid-flight (client disconnect) must SIGTERM the
// whole group so git AND its pack-objects grandchild die together. Goes RED
// if `process_group(0)` or the guard-arming is removed: without its own
// group, kill(-pgid) hits no group and the grandchild survives.
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_tears_down_group_when_future_dropped() {
let tmp = tempfile::TempDir::new().unwrap();
let pidfile = tmp.path().join("pids");
// Fork a grandchild (stands in for pack-objects), record leader+grandchild
// pids, then hang so run_git_service parks in wait_with_output.
let body = format!(
"#!/bin/sh\nsleep 300 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nwait\n",
pidfile.display()
);
let git_bin = write_fake_git(tmp.path(), &body);
// Retry under fork-storm load: a freshly-written fake `git` can transiently
// fail to exec (ETXTBSY) or exit before recording its pids; both leave no
// pids and are independent per attempt (see fake_git_run_with_pids). The
// winning attempt's future is kept PENDING so the drop below exercises the
// client-disconnect teardown. Dropping a losing attempt's future makes its
// guard reap anything that spawned, so retries don't leak.
let (fut, grandchild) = {
let mut attempt = 0u64;
loop {
attempt += 1;
let _ = std::fs::remove_file(&pidfile);
let mut fut = Box::pin(run_git_service(
git_bin.to_str().unwrap(),
"git-upload-pack",
tmp.path(),
Bytes::new(),
Duration::from_secs(60),
));
// Advance the future a slice at a time until the fake records its
// pids. `Ok(_)` means run_git_service returned before the pidfile
// appeared (spawn error / early exit); stop polling then, since
// re-polling a completed future panics with `async fn resumed after
// completion`. Read the pidfile first so a fake that wrote its pids
// and then exited is still captured.
let mut pids = None;
for _ in 0..500 {
let finished =
tokio::time::timeout(std::time::Duration::from_millis(10), &mut fut)
.await
.is_ok();
if let Some(p) = read_two_pids(&pidfile) {
pids = Some(p);
break;
}
if finished {
break;
}
}
// Only the grandchild needs panic-cleanup: dropping the future makes
// tokio reap the fake leader, and carrying the reaped leader pid
// risks SIGKILLing a recycled pid under parallel test load.
match pids {
Some((_leader, g)) => break (fut, g),
None => {
// Transient spawn miss: drop the still-armed future so its
// guard reaps anything that spawned, then back off (growing)
// so a bursty fork-pressure spike subsides before retrying.
drop(fut);
assert!(
attempt < FAKE_GIT_RETRY_ATTEMPTS,
"fake git failed to spawn and record its pids after \
{FAKE_GIT_RETRY_ATTEMPTS} attempts (persistent failure, \
not a transient parallel-runner miss)"
);
tokio::time::sleep(std::time::Duration::from_millis(
FAKE_GIT_BACKOFF_STEP_MS * attempt,
))
.await;
}
}
}
};
let _cleanup = ReapOnPanic(vec![grandchild]);
assert!(
alive(grandchild),
"grandchild should be running before the drop"
);
// Client disconnect: drop the request future. The armed KillGroupOnDrop
// must SIGTERM the whole group.
drop(fut);
let mut gone = false;
for _ in 0..500 {
if !alive(grandchild) {
gone = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(
gone,
"grandchild must be torn down via the process group on future-drop \
(proves run_git_service sets process_group(0) and arms the guard)"
);
}
// A request that runs to completion must DISARM the guard after reaping, so
// no stray group SIGTERM fires. The fake exits non-zero (surfacing as Err)
// but leaves a grandchild alive; the grandchild must survive. Goes RED if the
// post-reap disarm is removed: the guard would then fire on return and, since
// the grandchild still holds the group open, sweep it.
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_disarms_on_completion_leaving_group_alive() {
let tmp = tempfile::TempDir::new().unwrap();
let pidfile = tmp.path().join("pids");
// The grandchild is long-lived with stdio redirected to /dev/null:
// /dev/null so it doesn't inherit (and hold open) run_git_service's stdout
// pipe (which would block wait_with_output until it exits); long-lived so
// the "still alive" assertion below can't race the sleep exiting on its own
// under a starved scheduler. ReapOnPanic cleans it up.
let body = format!(
"#!/bin/sh\nsleep 300 >/dev/null 2>&1 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nexit 1\n",
pidfile.display()
);
let git_bin = write_fake_git(tmp.path(), &body);
// wait_with_output already reaped the fake leader, so only the grandchild
// needs panic-cleanup — SIGKILLing the reaped leader pid could hit a
// recycled pid under parallel test load. Retry the run so a transient spawn
// miss under fork-storm load doesn't fail the test (see fake_git_run_with_pids).
let (result, (_leader, grandchild)) = fake_git_run_with_pids(&pidfile, || {
run_git_service(
git_bin.to_str().unwrap(),
"git-upload-pack",
tmp.path(),
Bytes::new(),
Duration::from_secs(60),
)
})
.await;
let _cleanup = ReapOnPanic(vec![grandchild]);
assert!(result.is_err(), "non-zero git exit must surface as Err");
// A mutant that left the guard armed fires SIGTERM on return; poll a short
// window so a killed grandchild is reliably observed dead — a single
// alive() check can race the SIGTERM+reap and miss it.
for _ in 0..30 {
assert!(
alive(grandchild),
"grandchild must survive: run_git_service must disarm the guard after \
reaping, not fire a group SIGTERM on the completion path"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
// The SUCCESS path (exit 0, Ok) must disarm too — the disarm test above only
// exercises the exit-1/Err branch. Goes RED if disarm is gated on failure
// (e.g. moved inside the `!status.success()` branch): the guard would then
// fire after a clean completion and sweep the still-live grandchild.
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_disarms_on_success_leaving_group_alive() {
let tmp = tempfile::TempDir::new().unwrap();
let pidfile = tmp.path().join("pids");
// Long-lived, stdio to /dev/null (same reasons as the exit-1 test).
let body = format!(
"#!/bin/sh\nsleep 300 >/dev/null 2>&1 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nexit 0\n",
pidfile.display()
);
let git_bin = write_fake_git(tmp.path(), &body);
// Retry the run so a transient spawn miss under fork-storm load doesn't
// fail the test (see fake_git_run_with_pids).
let (result, (_leader, grandchild)) = fake_git_run_with_pids(&pidfile, || {
run_git_service(
git_bin.to_str().unwrap(),
"git-upload-pack",
tmp.path(),
Bytes::new(),
Duration::from_secs(60),
)
})
.await;
let _cleanup = ReapOnPanic(vec![grandchild]);
assert!(result.is_ok(), "zero-exit git must surface as Ok");
// Poll a short window (see the exit-1 test): a mutant that disarms only on
// failure fires the guard on this success path, and a single alive() check
// can race the SIGTERM+reap.
for _ in 0..30 {
assert!(
alive(grandchild),
"grandchild must survive: run_git_service must disarm on the success \
path too, not fire a group SIGTERM after a clean completion"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
// A git that hangs (never finishes, never disconnects) must be bounded: it
// returns GitServiceTimeout (the handler maps that to 504) and its process
// group is torn down. Goes RED if the timeout wrap is removed — the OUTER
// bound below then fires instead of run_git_service's own, so the test fails
// loudly rather than hanging CI.
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_times_out_and_tears_down_a_hung_git() {
let tmp = tempfile::TempDir::new().unwrap();
let pidfile = tmp.path().join("pids");
// Fork a grandchild, then hang forever.
let body = format!(
"#!/bin/sh\nsleep 300 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nwait\n",
pidfile.display()
);
let git_bin = write_fake_git(tmp.path(), &body);
// Outer bound well above git_timeout: if run_git_service's own timeout is
// broken it hangs, and this fires instead so we assert-fail, not hang.
// fake_git_run_with_pids retries a transient spawn/schedule miss under
// fork-storm load and returns the pidfile the fake recorded.
let git_timeout = Duration::from_millis(1000);
let (outcome, (_leader, grandchild)) = fake_git_run_with_pids(&pidfile, || {
tokio::time::timeout(
Duration::from_secs(5),
run_git_service(
git_bin.to_str().unwrap(),
"git-upload-pack",
tmp.path(),
Bytes::new(),
git_timeout,
),
)
})
.await;
assert!(
outcome.is_ok(),
"run_git_service must return via its own timeout, not hang"
);
let err = outcome
.unwrap()
.expect_err("a hung git must surface as Err");
assert!(
err.downcast_ref::<GitServiceTimeout>().is_some(),
"the error must be GitServiceTimeout (maps to 504), got: {err:#}"
);
// The hung git's group must be torn down (the armed guard fires on the
// timeout return), not leaked.
let _cleanup = ReapOnPanic(vec![grandchild]);
let mut gone = false;
for _ in 0..500 {
if !alive(grandchild) {
gone = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(
gone,
"a timed-out git's process group must be torn down, not leaked"
);
}
// On timeout, run_git_service must REAP the group before returning, so a
// caller releasing a write lock (receive-pack) can't race a still-live git.
// The fake traps SIGTERM and lingers ~1s (git-cleanup stand-in): with the
// reap the call waits for it to exit, so the leader is dead by the time we
// return; without it, the call returns while the fake is still mid-trap.
// Goes RED if the timeout arm's reap is removed.
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_reaps_the_group_before_returning_on_timeout() {
let tmp = tempfile::TempDir::new().unwrap();
let pidfile = tmp.path().join("pids");
// Leader traps SIGTERM and exits after ~1s. The grandchild (a sub-shell)
// IGNORES SIGTERM entirely (`trap "" TERM`) and sleeps far past the ~4s reap
// cap, so nothing but the untrappable SIGKILL escalation can kill it within
// the cap. That makes the escalation load-bearing: neuter the SIGKILL and
// this test goes RED. (A grandchild that merely trapped-and-slept UNDER the
// cap would self-exit and hide a broken escalation.) "Wait for the whole
// group" (leader AND grandchild) is observable because both must be dead
// before run_git_service returns.
let body = format!(
"#!/bin/sh\ntrap 'sleep 1; exit 0' TERM\nsh -c 'trap \"\" TERM; sleep 300' >/dev/null 2>&1 &\nprintf '%s\\n%s\\n' \"$$\" \"$!\" > \"{}\"\nwait\n",
pidfile.display()
);
let git_bin = write_fake_git(tmp.path(), &body);
// fake_git_run_with_pids retries a transient spawn/schedule miss under
// fork-storm load and returns the pids the fake recorded; the outer 10s
// bound fires only if run_git_service's own timeout is broken (so we
// assert-fail rather than hang), well above the git_timeout plus the
// SIGKILL-escalation reap of the SIGTERM-ignoring grandchild.
let git_timeout = Duration::from_millis(300);
let (outcome, (leader, grandchild)) = fake_git_run_with_pids(&pidfile, || {
tokio::time::timeout(
Duration::from_secs(10),
run_git_service(
git_bin.to_str().unwrap(),
"git-upload-pack",
tmp.path(),
Bytes::new(),
git_timeout,
),
)
})
.await;
assert!(outcome.is_ok(), "must return via its own timeout, not hang");
assert!(outcome.unwrap().is_err(), "a hung git must surface as Err");
// The WHOLE group (leader AND the lingering grandchild) must be gone by the
// time we get here.
let _cleanup = ReapOnPanic(vec![grandchild]);
assert!(
!alive(leader) && !alive(grandchild),
"run_git_service must reap the whole git process group (leader AND \
grandchildren) before returning on a timeout, so a write-lock release \
can't race a still-live git"
);
}
// The timeout bounds git-receive-pack too, not just upload-pack: on the push
// path a hung git also holds the repo's write lock, so an unbounded receive-pack
// pins the repo until the process dies. run_git_service must return
// Err(GitServiceTimeout) for the receive-pack service exactly as it does for
// upload-pack. Goes RED if the internal timeout is removed (the fake never
// exits, so the outer bound trips and the outcome is not Ok).
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_times_out_a_hung_receive_pack() {
let tmp = tempfile::TempDir::new().unwrap();
let git_bin = write_fake_git(tmp.path(), "#!/bin/sh\nsleep 300\n");
let outcome = tokio::time::timeout(
Duration::from_secs(5),
run_git_service(
git_bin.to_str().unwrap(),
"git-receive-pack",
tmp.path(),
Bytes::new(),
Duration::from_millis(200),
),
)
.await;
assert!(
outcome.is_ok(),
"receive-pack must return via its own timeout, not hang"
);
let err = outcome
.unwrap()
.expect_err("a hung receive-pack must surface as Err");
assert!(
err.downcast_ref::<GitServiceTimeout>().is_some(),
"a hung receive-pack must time out as GitServiceTimeout (maps to 504); \
got: {err:#}"
);
}
// A malformed request: git exits non-zero with a recognizable pkt-line error
// on stderr AND does not read stdin, so a body larger than the pipe buffer
// makes the write EPIPE. run_git_service must surface git's stderr (which the
// handler classifies as a 400), not the EPIPE write error (a generic 500).
// Goes RED if the stdin-write check runs before the exit-status check.
#[cfg(unix)]
#[tokio::test]
async fn run_git_service_surfaces_git_stderr_over_a_stdin_epipe() {
let tmp = tempfile::TempDir::new().unwrap();
let git_bin = write_fake_git(
tmp.path(),
"#!/bin/sh\necho 'fatal: bad line length character: 0000' >&2\nexit 128\n",
);
// Larger than a pipe buffer (~64 KiB) so the write blocks then EPIPEs when
// the fake exits without draining stdin, exercising the ordering.
let big = Bytes::from(vec![0u8; 256 * 1024]);
let result = run_git_service(
git_bin.to_str().unwrap(),
"git-upload-pack",
tmp.path(),
big,
Duration::from_secs(60),
)
.await;
let err = result.expect_err("non-zero git must surface as Err");
let msg = format!("{err:#}");
assert!(
msg.contains("bad line length"),
"run_git_service must surface git's stderr (a classifiable 400), not the \
stdin-write EPIPE (a generic 500); got: {msg}"
);
}
}
+9 -29
View File
@@ -271,8 +271,9 @@ pub struct TreeEntry {
/// `/ipfs/<cid>` is computed from these same content bytes via
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
///
/// Get just the object type. Returns `None` if the object doesn't exist.
pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result<Option<String>> {
/// Returns `None` if the object does not exist in this repo.
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
// First check if the object exists and get its type
let type_output = Command::new("git")
.args(["cat-file", "-t", sha256_hex])
.current_dir(repo_path)
@@ -283,17 +284,13 @@ pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result<Option<String>>
return Ok(None);
}
Ok(Some(
String::from_utf8_lossy(&type_output.stdout)
.trim()
.to_string(),
))
}
let obj_type = String::from_utf8_lossy(&type_output.stdout)
.trim()
.to_string();
/// Read an object's content if its type is already known.
pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -> Result<Vec<u8>> {
// Read the raw content bytes
let content_output = Command::new("git")
.args(["cat-file", obj_type, sha256_hex])
.args(["cat-file", &obj_type, sha256_hex])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file <type>")?;
@@ -303,24 +300,7 @@ pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -
bail!("git cat-file failed: {stderr}");
}
Ok(content_output.stdout)
}
/// Read a git object by its SHA-256 hex object ID.
///
/// Returns `(object_type, content_bytes)` where `content_bytes` is the raw
/// object content (without the git framing header). The CID served over
/// `/ipfs/<cid>` is computed from these same content bytes via
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
///
/// Returns `None` if the object does not exist in this repo.
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
let obj_type = match object_type(repo_path, sha256_hex)? {
Some(t) => t,
None => return Ok(None),
};
let content = read_object_content(repo_path, sha256_hex, &obj_type)?;
Ok(Some((obj_type, content)))
Ok(Some((obj_type, content_output.stdout)))
}
/// Get the diff between two branches: changes on source_branch not in target_branch.
+1 -95
View File
@@ -309,33 +309,11 @@ pub fn replicable_blob_set(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
) -> Result<HashSet<String>> {
allowed_blob_set_for_caller(repo_path, rules, is_public, owner_did, None)
}
/// Reachable blob OIDs that visibility ALLOWS `caller` at some path. The
/// caller-aware generalization of `replicable_blob_set` (which is the anonymous
/// `caller = None` case). Used by `GET /ipfs/{cid}` to gate fail-closed against
/// dangling/unreachable blobs (#126): a blob written via `git hash-object -w`
/// but unreferenced is absent from the reachable walk, so it is never in this
/// set and the IPFS serve path drops it — even from the owner, who has no path
/// to authorize the blob at.
///
/// A blob reachable at an allowed path is included even when also denied
/// elsewhere (its content is readable to this caller elsewhere). Trees and
/// commits are NOT included here; the caller decides per object type whether
/// the allow-set applies (it does not for trees/commits — KTD3).
pub fn allowed_blob_set_for_caller(
repo_path: &Path,
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Result<HashSet<String>> {
let pairs = blob_paths(repo_path)?;
let mut allowed = HashSet::new();
for (oid, path) in &pairs {
if visibility_check(rules, is_public, owner_did, caller, path) == Decision::Allow {
if visibility_check(rules, is_public, owner_did, None, path) == Decision::Allow {
allowed.insert(oid.clone());
}
}
@@ -765,78 +743,6 @@ mod tests {
);
}
#[test]
fn allowed_set_excludes_dangling_blob_for_every_caller() {
// #126: a blob written via `git hash-object -w` but never referenced has
// no path to gate on, so it is absent from the reachable allowed-set —
// for anonymous callers, listed readers, AND the owner. The IPFS serve
// path relies on this fail-closed property to drop dangling withheld
// blobs that the deny-set model leaked.
let td = TempDir::new().unwrap();
let work = td.path().join("work");
std::fs::create_dir_all(work.join("public")).unwrap();
std::fs::write(work.join("public/a.txt"), b"public bytes\n").unwrap();
let run = |args: &[&str]| {
assert!(
Command::new("git")
.args(args)
.current_dir(&work)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
};
run(&["init", "-q"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
run(&["add", "."]);
run(&["commit", "-qm", "init"]);
let oid_of = |rev: &str| {
let out = Command::new("git")
.args(["rev-parse", rev])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let public_oid = oid_of("HEAD:public/a.txt");
std::fs::write(work.join("orphan.bin"), b"DANGLING SECRET\n").unwrap();
let dangling_oid = {
let out = Command::new("git")
.args(["hash-object", "-w", "orphan.bin"])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
assert!(
matches!(dangling_oid.len(), 40 | 64),
"precondition: hash-object stored the dangling blob"
);
// Path-scoped rule: /secret/** denied to anon, allowed to a listed reader.
let reader = "did:key:zReader";
let rules = [rule("/secret/**", &[reader])];
// Every gate-relevant caller: anonymous, listed reader, owner. None of
// them can put the dangling blob in the allowed set — it has no path.
for caller in [None, Some(reader), Some(OWNER)] {
let allowed = allowed_blob_set_for_caller(&work, &rules, true, OWNER, caller).unwrap();
assert!(
!allowed.contains(&dangling_oid),
"dangling blob must be absent from allowed-set (caller={caller:?})"
);
// Sanity: the reachable public blob is still in the set for every
// caller (the rule does not deny /public/**).
assert!(
allowed.contains(&public_oid),
"reachable public blob must be in allowed-set (caller={caller:?})"
);
}
}
#[test]
fn recipients_are_owner_plus_allowed_readers_only() {
let (_td, repo, secret_oid, public_oid) = fixture();
+6 -332
View File
@@ -49,49 +49,16 @@ impl QueryRoot {
&self,
ctx: &Context<'_>,
repo: Option<String>,
#[graphql(
default = 20,
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
)]
limit: i64,
#[graphql(default = 20)] limit: i64,
) -> Result<Vec<RefUpdateType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// Gate each row on the same "/" visibility decision the repos resolver
// uses, so anonymous callers get no row for a local repo they can't read
// (#112). The shared collector applies the fail-closed gate *before* the
// limit (paging past dropped private rows) so a small limit still returns
// the latest visible events, and keeps this surface byte-identical to the
// REST feed (#114). The row slug is peer-supplied, so the pure filter
// treats it as untrusted input; remote (no local match) rows pass.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let updates =
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
.await
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
// Resolve the trusted display owner_did per row, identical to the REST
// feed: the stored wire value is untrusted, so it is echoed only when it
// matches the canonical owner of the local repo the slug names (#P1);
// legacy None rows are attributed via an exact unique local match (#P3).
// The batch resolver issues at most one query per distinct local repo
// rather than one per event row (#P2).
let pairs: Vec<(&str, Option<&str>)> = updates
.iter()
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
.collect();
let owner_dids = db
.resolve_ref_update_owner_dids(&pairs)
let updates = db
.list_ref_updates_filtered(repo.as_deref(), limit)
.await
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let resolved: Vec<RefUpdateType> = updates
Ok(updates
.into_iter()
.zip(owner_dids)
.map(|(u, owner_did)| RefUpdateType {
.map(|u| RefUpdateType {
repo: u.repo,
ref_name: u.ref_name,
old_sha: u.old_sha,
@@ -99,10 +66,8 @@ impl QueryRoot {
pusher_did: u.pusher_did,
node_did: u.node_did,
timestamp: u.timestamp,
owner_did,
})
.collect();
Ok(resolved)
.collect())
}
async fn tasks(
@@ -129,294 +94,3 @@ impl QueryRoot {
Ok(t.map(AgentTaskType::from))
}
}
#[cfg(test)]
mod tests {
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
use chrono::Utc;
use sqlx::PgPool;
use std::sync::Arc;
const OWNER: &str = "did:key:z6MkOwner";
async fn db(pool: PgPool) -> Arc<Db> {
let db = Db::for_testing(pool);
db.run_migrations().await.unwrap();
Arc::new(db)
}
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)
}
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
RepoRecord {
id: id.into(),
name: name.into(),
owner_did: owner_did.into(),
description: None,
is_public,
default_branch: "main".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: format!("/srv/{id}"),
forked_from: None,
machine_id: None,
}
}
fn ref_row(id: &str, slug: &str) -> ReceivedRefUpdate {
ReceivedRefUpdate {
id: id.into(),
node_did: "did:key:z6MkNode".into(),
pusher_did: "did:key:z6MkPusher".into(),
repo: slug.into(),
ref_name: "refs/heads/main".into(),
old_sha: "0".repeat(40),
new_sha: "a".repeat(40),
timestamp: Utc::now().to_rfc3339(),
cert_id: None,
received_at: Utc::now().to_rfc3339(),
from_peer: "peer1".into(),
owner_did: None,
}
}
/// Count `refUpdates` rows in a GraphQL response.
fn count(resp: &async_graphql::Response) -> usize {
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::List(rows) = obj.get("refUpdates").expect("refUpdates key")
else {
panic!("refUpdates not a list");
};
rows.len()
}
async fn anon(schema: &super::super::GitlawbSchema, query: &str) -> async_graphql::Response {
schema.execute(async_graphql::Request::new(query)).await
}
async fn authed(
schema: &super::super::GitlawbSchema,
query: &str,
did: &str,
) -> async_graphql::Response {
schema
.execute(
async_graphql::Request::new(query)
.data(crate::auth::AuthenticatedDid(did.to_string())),
)
.await
}
// Scenario 1 — anon must not get a private local repo's row on the
// repo:Some branch. This is the load-bearing RED→GREEN case.
#[sqlx::test]
async fn ref_updates_private_repo_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
.await
.unwrap();
let schema = schema(db);
// The GraphQL `repo` arg is the raw slug DB filter, so it must equal the
// stored slug to select the row at all — this is the exact leak path.
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName newSha pusherDid } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 2 — owner still sees their own private repo's row.
#[sqlx::test]
async fn ref_updates_private_repo_kept_for_owner(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName } }"#;
assert_eq!(count(&authed(&schema, q, OWNER).await), 1);
}
// Scenario 3 — unfiltered (repo:None): anon gets only the public row.
#[sqlx::test]
async fn ref_updates_unfiltered_anon_gets_only_public(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("pub", OWNER, "openrepo", true))
.await
.unwrap();
db.create_repo(&repo("priv", OWNER, "secret", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u_pub", "z6MkOwner/openrepo"))
.await
.unwrap();
db.insert_ref_update(&ref_row("u_priv", "z6MkOwner/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo refName ownerDid } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 1);
// The one row returned must be the public repo's with owner_did echoed.
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
unreachable!()
};
let async_graphql::Value::Object(row) = &rows[0] else {
unreachable!()
};
assert_eq!(
row.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo")
);
assert_eq!(
row.get("ownerDid").unwrap(),
&async_graphql::Value::from(OWNER),
"ownerDid must fall back to the local record's owner for legacy rows"
);
}
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
#[sqlx::test]
async fn ref_updates_full_did_slug_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", "did:key:zABC", "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "did:key:zABC/widget"))
.await
.unwrap();
let schema = schema(db);
// repo:None so the slug is not the DB filter key (which is verbatim);
// the gate must still drop it.
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 5 — truncated-key fail-closed: 8-char-prefix owner form.
#[sqlx::test]
async fn ref_updates_truncated_key_slug_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", "did:key:zABCDEFGH", "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "zABCDEF/widget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 6 — remote slug (no local match) is returned to anon.
#[sqlx::test]
async fn ref_updates_remote_slug_kept_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
// Row whose slug matches no local repo (different owner + name).
db.insert_ref_update(&ref_row("u1", "zZZZOTHER/gadget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 1);
}
// Scenario 7 (#114 P2) — a small limit must page past the newest rows when
// they are private, so the older public rows are still returned. Before the
// gate moved ahead of the limit this returned 0 (the newest `limit` rows were
// all private and got filtered after the SQL LIMIT). RED→GREEN.
#[sqlx::test]
async fn ref_updates_small_limit_pages_past_newest_private(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("pub", OWNER, "openrepo", true))
.await
.unwrap();
db.create_repo(&repo("priv", OWNER, "secret", false))
.await
.unwrap();
// 3 older PUBLIC rows …
for i in 0..3 {
let mut r = ref_row(&format!("pub{i}"), "z6MkOwner/openrepo");
r.timestamp = format!("2026-07-01T10:00:0{i}+00:00");
db.insert_ref_update(&r).await.unwrap();
}
// … then 5 NEWER PRIVATE rows (the newest in the feed).
for i in 0..5 {
let mut r = ref_row(&format!("priv{i}"), "z6MkOwner/secret");
r.timestamp = format!("2026-07-01T10:00:1{i}+00:00");
db.insert_ref_update(&r).await.unwrap();
}
let schema = schema(db);
// limit 3 < the 5 newest (all private): anon must still get 3 public rows.
let q = r#"{ refUpdates(limit: 3) { repo } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 3, "paging must reach the older public rows");
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
unreachable!()
};
for row in rows {
let async_graphql::Value::Object(r) = row else {
unreachable!()
};
assert_eq!(
r.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo"),
"every returned row must be the public repo's"
);
}
}
// Scenario 8 — a quarantined mirror is withheld on the GraphQL surface too.
// Guards that the resolver keeps delegating to the shared collector (where the
// quarantine fold lives); a REST-only test would miss a resolver that stopped.
#[sqlx::test]
async fn ref_updates_quarantined_mirror_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.upsert_mirror_repo("z6MkQuar", "secret", "/tmp/q", None, true)
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 8b — the GraphQL surface also withholds a quarantined repo from an
// authenticated OWNER, not just anon. Without the collector's quarantine drop
// the owner short-circuit in visibility_check keeps the row on this surface
// too, so the REST owner test alone would not guard the resolver.
#[sqlx::test]
async fn ref_updates_quarantined_repo_dropped_for_owner(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("q1", "did:key:z6MkQuar", "secret", false))
.await
.unwrap();
db.set_repo_quarantine("q1", true).await.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&authed(&schema, q, "did:key:z6MkQuar").await), 0);
}
}
@@ -11,15 +11,6 @@ pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
/// 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
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
async fn ref_updates(
&self,
ctx: &Context<'_>,
@@ -39,7 +30,6 @@ impl SubscriptionRoot {
pusher_did: ev.pusher_did,
node_did: ev.node_did,
timestamp: ev.timestamp,
owner_did: Some(ev.owner_did),
}),
_ => None,
}
-1
View File
@@ -57,7 +57,6 @@ pub struct RefUpdateType {
pub pusher_did: String,
pub node_did: String,
pub timestamp: String,
pub owner_did: Option<String>,
}
#[derive(SimpleObject, Clone)]
+7 -24
View File
@@ -237,13 +237,9 @@ impl ProofGuard {
match self.0 {
Some(p) => {
if !db.consume_proof_jti(&p.jti, p.exp).await? {
let (url, level) = url_and_level();
return Err(AppError::IcaptchaProofRequired {
message: "iCaptcha proof already used (replay); solve a fresh challenge"
.to_string(),
url,
level,
});
return Err(AppError::IcaptchaProofRequired(
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
));
}
Ok(Some(p))
}
@@ -269,23 +265,10 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppE
}
fn reject_error(v: &Verifier, reason: &str) -> AppError {
AppError::IcaptchaProofRequired {
message: format!(
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
v.url, v.required_level, PROOF_HEADER
),
url: v.url.clone(),
level: v.required_level,
}
}
/// iCaptcha service url + required level for error responses, read from the
/// initialized verifier (falls back to defaults if somehow uninitialized).
fn url_and_level() -> (String, u32) {
VERIFIER
.get()
.map(|v| (v.url.clone(), v.required_level))
.unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3))
AppError::IcaptchaProofRequired(format!(
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
v.url, v.required_level, PROOF_HEADER
))
}
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
+101 -533
View File
@@ -1,3 +1,4 @@
mod admin;
mod api;
mod arweave;
mod auth;
@@ -24,14 +25,8 @@ mod test_support;
mod visibility;
mod webhooks;
use anyhow::{anyhow, Context, Result};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use anyhow::{Context, Result};
use clap::Parser;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::watch;
@@ -44,20 +39,6 @@ use config::Config;
use db::Db;
use state::AppState;
#[derive(Clone)]
struct DegradedState {
node_did: String,
db_startup: Arc<DbStartupStatus>,
}
/// Two independent counters with no cross-field invariant — atomics, not a
/// lock, so the retry loop and the degraded handlers never contend.
#[derive(Default)]
struct DbStartupStatus {
attempts: AtomicU64,
next_retry_secs: AtomicU64,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
@@ -70,6 +51,15 @@ async fn main() -> Result<()> {
let mut config = Config::parse();
// Admin subcommands run instead of the daemon and exit.
if let Some(cmd) = config.command.clone() {
match cmd {
config::Command::PurgeSpam(args) => {
return admin::purge_spam(&config, &args).await;
}
}
}
// Merge the embedded seed list of public network nodes into the runtime
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
bootstrap::merge_seeds(&mut config);
@@ -96,101 +86,21 @@ async fn main() -> Result<()> {
env!("CARGO_PKG_VERSION")
);
info!("╚══════════════════════════════════════════╝");
info!(did = %node_did, "node identity");
info!(addr = %config.bind_addr(), "listening");
// Process-wide shutdown signal. One sender lives in AppState (cloned
// into every handler); main() keeps a clone and flips it on SIGINT
// or SIGTERM. Tasks that hold a watch::Receiver get notified at
// their next await point.
let (shutdown_tx, _shutdown_rx_for_main) = watch::channel(false);
spawn_shutdown_signal(shutdown_tx.clone());
info!(did = %node_did, "node identity");
info!(addr = %config.bind_addr(), "binding HTTP listener");
// Bind HTTP once, before dependency initialization, and keep this socket
// for the life of the process. The degraded server accepts on a dup of the
// same socket, so the degraded→full handoff never closes the port: while
// the full server initializes, connections queue in the shared backlog
// instead of being refused.
let listener = TcpListener::bind(config.bind_addr())
.await
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
let full_std_listener = listener.into_std()?;
let degraded_listener = TcpListener::from_std(
full_std_listener
.try_clone()
.context("failed to clone HTTP listener for degraded server")?,
)?;
// Metrics must stay observable during a database outage — the degraded
// window is exactly when dashboards need data — so this listener starts
// before the DB connects.
let metrics_handle = if !config.metrics_addr.is_empty() {
match spawn_metrics_server(&config.metrics_addr, shutdown_tx.subscribe()).await {
Ok(handle) => {
info!(addr = %config.metrics_addr, "metrics endpoint listening");
Some(handle)
}
Err(e) => {
warn!(err = %e, addr = %config.metrics_addr, "failed to start metrics endpoint — continuing without");
None
}
}
} else {
info!("metrics endpoint disabled (GITLAWB_METRICS_ADDR not set)");
None
};
let db_startup = Arc::new(DbStartupStatus::default());
let (db_ready_tx, db_ready_rx) = watch::channel(false);
let mut degraded_handle = tokio::spawn(run_degraded_server(
degraded_listener,
node_did.to_string(),
Arc::clone(&db_startup),
db_ready_rx,
shutdown_tx.subscribe(),
));
// Connect to PostgreSQL database. A transient outage or bad secret should
// not crash-loop the process and hammer the database provider; permanent
// misconfiguration surfaces through error-level logs and the /ready check.
let db = tokio::select! {
db = connect_db_with_retry(&config, Arc::clone(&db_startup), shutdown_tx.subscribe()) => {
match db {
Some(db) => db,
None => {
// Shutdown requested while waiting for the database. The
// degraded server only serves one-shot 503s — abort it
// rather than drain, so a slow client can't stall exit.
degraded_handle.abort();
return Ok(());
}
}
}
degraded = &mut degraded_handle => {
if *shutdown_tx.borrow() {
return Ok(());
}
return match degraded {
Ok(Ok(())) => Err(anyhow!("degraded HTTP server stopped before database became ready")),
Ok(Err(err)) => Err(err.context("degraded HTTP server failed")),
Err(err) => Err(anyhow!("degraded HTTP server task failed: {err}")),
};
}
};
// Flip the degraded server into graceful shutdown, but do NOT await the
// drain: one slow in-flight request must not delay the full server, and
// the shared socket means there is no port gap to cover. The drain
// finishes (and logs) in the background.
db_ready_tx.send(true).ok();
tokio::spawn(async move {
match degraded_handle.await {
Ok(Ok(())) => {}
Ok(Err(err)) => warn!(err = %err, "degraded HTTP server exited with error"),
Err(err) => warn!(err = %err, "degraded HTTP server task failed"),
}
});
info!(addr = %config.bind_addr(), "database ready; starting full HTTP server");
// Connect to PostgreSQL database
let db = Arc::new(
Db::connect(&config.database_url)
.await
.context("failed to connect to database")?,
);
// Prune peer rows that point back at this node (stale self-loop entries)
if let Some(public_url) = config.public_url.as_deref() {
@@ -245,8 +155,16 @@ async fn main() -> Result<()> {
None
};
// Shared no-redirect HTTP client. See build_http_client for the SSRF rationale.
let http_client = Arc::new(build_http_client()?);
// Do not follow redirects: this client fans out to peer-supplied URLs
// (sync trigger, profile/repo fetches). A peer answering `302 Location:
// http://127.0.0.1/` would otherwise bypass the announce-time public-URL
// check and turn the redirect into an SSRF.
let http_client = Arc::new(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()?,
);
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
@@ -282,79 +200,7 @@ async fn main() -> Result<()> {
let repo_store =
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
// DID (attacker-varied), so bound its key set to cap memory.
let rate_limiter =
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
// exactly how the recurring spam-repo floods get past both it and the
// iCaptcha gate. Keyed on the resolved client IP so a single-source flood is
// capped regardless of how many identities it mints. Sized well above any
// legitimate per-IP creation rate; GITLAWB_CREATE_RATE_LIMIT overrides, 0
// disables. Bounded key set — the key is a client-influenced IP.
let create_limit = std::env::var("GITLAWB_CREATE_RATE_LIMIT")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(120);
let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
create_limit,
std::time::Duration::from_secs(3600),
200_000,
);
if create_limit == 0 {
tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
}
// Push-path flood brake: max git-receive-pack requests per client IP per
// hour (counts both the info/refs advertisement and the push POST). Sized
// for heavy agent automation while still stopping flood traffic (the June
// 2026 attack pushed several times per second per IP). GITLAWB_PUSH_RATE_LIMIT
// overrides; 0 disables. Bounded key set — the key is a client-influenced IP.
let push_limit = std::env::var("GITLAWB_PUSH_RATE_LIMIT")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(600);
let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
push_limit,
std::time::Duration::from_secs(3600),
200_000,
);
if push_limit == 0 {
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
}
// Which forwarded header the edge is trusted to set. Default None (trust
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
// a node behind Caddy/NGINX sets it to x-forwarded-for.
let push_limiter_trust = rate_limit::TrustedProxy::from_env_value(
&std::env::var("GITLAWB_TRUSTED_PROXY").unwrap_or_default(),
);
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
// Peer-sync flood brakes, keyed on the resolved client IP (per-DID is useless
// here — a did:key farm self-registers). Two buckets so an unsigned notify
// flood can't drain the signed trigger caller's quota (#82). Bounded key sets
// (the key is a client-influenced IP); 0 disables each.
let sync_trigger_rate_limiter = rate_limit::RateLimiter::new_bounded(
config.sync_trigger_rate_limit,
std::time::Duration::from_secs(3600),
200_000,
);
let peer_write_rate_limiter = rate_limit::RateLimiter::new_bounded(
config.peer_write_rate_limit,
std::time::Duration::from_secs(3600),
200_000,
);
if config.sync_trigger_rate_limit == 0 {
tracing::warn!(
"GITLAWB_SYNC_TRIGGER_RATE_LIMIT=0 — /sync/trigger IP rate limiting disabled"
);
}
if config.peer_write_rate_limit == 0 {
tracing::warn!("GITLAWB_PEER_WRITE_RATE_LIMIT=0 — peer-write IP rate limiting disabled");
}
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
icaptcha::init().await;
@@ -372,11 +218,6 @@ async fn main() -> Result<()> {
machine_id,
repo_store,
rate_limiter,
create_ip_rate_limiter,
push_rate_limiter,
push_limiter_trust,
sync_trigger_rate_limiter,
peer_write_rate_limiter,
shutdown_tx: shutdown_tx.clone(),
};
@@ -406,13 +247,37 @@ async fn main() -> Result<()> {
});
}
// Spawn a task that flips the shutdown signal on SIGINT or SIGTERM.
// On Unix, both signals are handled. On Windows, only Ctrl-C is
// supported by tokio::signal::ctrl_c.
{
let tx = shutdown_tx.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal as unix_signal, SignalKind};
let mut sigterm =
unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut sigint =
unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = sigterm.recv() => info!("SIGTERM received, shutting down"),
_ = sigint.recv() => info!("SIGINT received, shutting down"),
}
}
#[cfg(not(unix))]
{
use tokio::signal;
let _ = signal::ctrl_c().await;
info!("Ctrl-C received, shutting down");
}
tx.send(true).ok();
});
}
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
{
let rl = state.rate_limiter.clone();
let create_ip_rl = state.create_ip_rate_limiter.clone();
let push_rl = state.push_rate_limiter.clone();
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
let peer_write_rl = state.peer_write_rate_limiter.clone();
let db = state.db.clone();
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
@@ -420,10 +285,6 @@ async fn main() -> Result<()> {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
rl.cleanup().await;
create_ip_rl.cleanup().await;
push_rl.cleanup().await;
sync_trigger_rl.cleanup().await;
peer_write_rl.cleanup().await;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -443,10 +304,9 @@ async fn main() -> Result<()> {
}
let router = server::build_router(state.clone());
// Re-register the socket bound at startup — same fd, so there was never a
// moment with the port closed between the degraded and full servers.
let listener = TcpListener::from_std(full_std_listener)
.context("failed to re-register HTTP listener with the runtime")?;
let listener = TcpListener::bind(config.bind_addr())
.await
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
info!("✓ node started — did:{}", node_did);
info!(" repos dir: {}", config.repos_dir.display());
@@ -455,6 +315,23 @@ async fn main() -> Result<()> {
&config.database_url.split('@').next_back().unwrap_or("?")
);
// Optional Prometheus metrics listener on a separate port.
let metrics_handle = if !config.metrics_addr.is_empty() {
match spawn_metrics_server(&config.metrics_addr, state.clone()).await {
Ok(handle) => {
info!(addr = %config.metrics_addr, "metrics endpoint listening");
Some(handle)
}
Err(e) => {
warn!(err = %e, addr = %config.metrics_addr, "failed to start metrics endpoint — continuing without");
None
}
}
} else {
info!("metrics endpoint disabled (GITLAWB_METRICS_ADDR not set)");
None
};
// Publish our DID record to the Kademlia DHT shortly after startup
if let Some(p2p) = &state.p2p {
let did_record = p2p::DidRecord {
@@ -532,25 +409,19 @@ async fn main() -> Result<()> {
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
// `into_make_service_with_connect_info` exposes the socket peer address as
// `ConnectInfo<SocketAddr>` so the push limiter can key on the real client
// when no trusted proxy header applies (see `rate_limit::client_key`).
let serve_result = axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
let mut rx = shutdown_signal_for_axum;
// Wait until the watcher flips to true, then return so axum
// can begin draining.
while !*rx.borrow_and_update() {
if rx.changed().await.is_err() {
// Sender dropped — treat as shutdown.
break;
let serve_result = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let mut rx = shutdown_signal_for_axum;
// Wait until the watcher flips to true, then return so axum
// can begin draining.
while !*rx.borrow_and_update() {
if rx.changed().await.is_err() {
// Sender dropped — treat as shutdown.
break;
}
}
}
})
.await;
})
.await;
// Server has stopped accepting new connections and drained in-flight
// requests. Tear the rest of the system down.
@@ -564,224 +435,12 @@ async fn main() -> Result<()> {
Ok(())
}
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal as unix_signal, SignalKind};
let mut sigterm =
unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut sigint = unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = sigterm.recv() => info!("SIGTERM received, shutting down"),
_ = sigint.recv() => info!("SIGINT received, shutting down"),
}
}
#[cfg(not(unix))]
{
use tokio::signal;
let _ = signal::ctrl_c().await;
info!("Ctrl-C received, shutting down");
}
tx.send(true).ok();
});
}
async fn connect_db_with_retry(
config: &Config,
db_startup: Arc<DbStartupStatus>,
mut shutdown_rx: watch::Receiver<bool>,
) -> Option<Arc<Db>> {
let initial_retry_secs = config.db_retry_initial_secs;
let max_retry_secs = config.db_retry_max_secs.max(initial_retry_secs);
let acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
let attempt_timeout = std::time::Duration::from_secs(config.db_connect_timeout_secs);
let mut attempts = 0_u64;
loop {
if *shutdown_rx.borrow() {
return None;
}
attempts = attempts.saturating_add(1);
db_startup.attempts.store(attempts, Ordering::Relaxed);
// Bound the whole attempt, not just the pool connect: migrations
// block on a cross-instance advisory lock, and an unbounded wait
// there would wedge this loop — no retries, no logs, no recovery.
// Timing out and retrying is safe; migrations are idempotent.
let attempt = match tokio::time::timeout(
attempt_timeout,
Db::connect(
&config.database_url,
config.db_max_connections,
acquire_timeout,
),
)
.await
{
Ok(result) => result,
Err(_) => Err(anyhow!(
"connect + migrate attempt exceeded {}s (GITLAWB_DB_CONNECT_TIMEOUT_SECS); \
is another instance holding the migration lock?",
attempt_timeout.as_secs()
)),
};
match attempt {
Ok(db) => {
info!(attempts, "database connection established");
return Some(Arc::new(db));
}
Err(err) => {
// A bad DATABASE_URL or rejected credentials won't heal on
// their own. Still retry (exiting would crash-loop and hammer
// the provider — and take liveness down with it), but log at
// error level and skip straight to the maximum backoff; the
// /ready health check is what surfaces this to deploys.
let permanent = is_likely_permanent_db_error(&err);
let retry_secs = if permanent {
max_retry_secs
} else {
database_retry_delay_secs(initial_retry_secs, max_retry_secs, attempts)
};
db_startup
.next_retry_secs
.store(retry_secs, Ordering::Relaxed);
if permanent {
tracing::error!(
attempts,
retry_secs,
err = %err,
"database rejected our configuration (bad DATABASE_URL or credentials?) — retrying, but operator action is likely required"
);
} else {
warn!(
attempts,
retry_secs,
err = %err,
"database unavailable during startup; retrying"
);
}
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(retry_secs)) => {}
changed = shutdown_rx.changed() => {
if changed.is_err() || *shutdown_rx.borrow() {
return None;
}
}
}
}
}
}
}
/// Errors that indicate misconfiguration rather than a transient outage: a
/// malformed DATABASE_URL, or a server that answered and rejected us —
/// Postgres error class 28xxx (invalid authorization) or 3D000 (database
/// does not exist). Best-effort: an error that anyhow can't downcast back to
/// sqlx just counts as transient.
fn is_likely_permanent_db_error(err: &anyhow::Error) -> bool {
match err.downcast_ref::<sqlx::Error>() {
Some(sqlx::Error::Configuration(_)) => true,
Some(sqlx::Error::Database(db)) => db
.code()
.map(|c| c.starts_with("28") || c.starts_with("3D"))
.unwrap_or(false),
_ => false,
}
}
fn database_retry_delay_secs(initial_secs: u64, max_secs: u64, attempts: u64) -> u64 {
// The exponent bound only keeps the u32 cast safe — max_secs is the real
// (operator-configurable) cap, and saturating math handles overflow.
let exponent = attempts.saturating_sub(1).min(63) as u32;
initial_secs
.saturating_mul(2_u64.saturating_pow(exponent))
.min(max_secs)
}
async fn run_degraded_server(
listener: TcpListener,
node_did: String,
db_startup: Arc<DbStartupStatus>,
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);
info!(?addr, "degraded HTTP server ready");
axum::serve(listener, router)
.with_graceful_shutdown(async move {
// wait_for resolves on predicate-true or sender-drop; either way
// this phase is over.
tokio::select! {
_ = db_ready_rx.wait_for(|ready| *ready) => {}
_ = shutdown_rx.wait_for(|stop| *stop) => {}
}
})
.await?;
Ok(())
}
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
let state = DegradedState {
node_did,
db_startup,
};
// Everything answers 503 with the same body — including /health and
// /ready, so peer pings (which treat any 2xx /health as alive) and
// uptime monitors correctly see a node that cannot serve traffic.
// `/` additionally carries the node identity for probing peers.
Router::new()
.route("/", get(degraded_node_info))
.fallback(degraded_unavailable)
.with_state(state)
}
/// One source of truth for the degraded 503 body, sharing the error
/// vocabulary with error.rs so clients see the same code/message for
/// "database unavailable" regardless of which phase produced it.
fn degraded_body(db_startup: &DbStartupStatus) -> serde_json::Value {
serde_json::json!({
"status": "degraded",
"database": "initializing",
"error": error::DB_UNAVAILABLE_CODE,
"message": error::DB_UNAVAILABLE_MESSAGE,
"db_attempts": db_startup.attempts.load(Ordering::Relaxed),
"db_next_retry_secs": db_startup.next_retry_secs.load(Ordering::Relaxed),
})
}
async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResponse {
let mut body = degraded_body(&state.db_startup);
if let Some(obj) = body.as_object_mut() {
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());
}
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
}
async fn degraded_unavailable(State(state): State<DegradedState>) -> impl IntoResponse {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(degraded_body(&state.db_startup)),
)
}
/// Spawn a small axum router that exposes only `GET /metrics` on its own
/// listener. Returns the JoinHandle so `main()` can abort it on shutdown.
/// This is deliberately separate from the main router so the metrics port
/// can be firewalled differently from the API port — bind to localhost
/// or a private interface only.
async fn spawn_metrics_server(
addr: &str,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<tokio::task::JoinHandle<()>> {
async fn spawn_metrics_server(addr: &str, state: AppState) -> Result<tokio::task::JoinHandle<()>> {
use axum::{response::IntoResponse, routing::get, Router};
async fn metrics_handler() -> impl IntoResponse {
@@ -805,6 +464,7 @@ async fn spawn_metrics_server(
}
}
let mut shutdown_rx = state.subscribe_shutdown();
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("failed to bind metrics listener to {addr}"))?;
@@ -865,12 +525,7 @@ async fn gossip_task(
}
}
// Reuse the shared no-redirect client for every gossip outbound call (the
// bootstrap announce POST and the periodic peer /health ping). Peer URLs are
// attacker-influenceable, so a 3xx to a private address must not be followed.
// Do NOT fall back to reqwest::Client::new(): its default follows redirects
// and would reintroduce the SSRF closed here (#93).
let client = state.http_client.clone();
let client = reqwest::Client::new();
let my_did = state.node_did.to_string();
let my_url = state.config.public_url.clone().unwrap_or_default();
@@ -945,7 +600,13 @@ async fn gossip_task(
Err(_) => continue,
};
for peer in peers {
let ok = ping_peer_health(&client, &peer.http_url).await;
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
let ok = client
.get(&url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
}
}
@@ -959,38 +620,6 @@ async fn gossip_task(
}
}
/// Build the shared node HTTP client used for every outbound fan-out (sync
/// trigger, profile/repo fetches, gossip announce + peer pings).
///
/// No redirects: peer URLs are attacker-influenceable, so a `3xx` to a private
/// address must not be followed (SSRF guard, #78/#93). Do NOT replace with
/// `reqwest::Client::new()` — its default follows redirects. Kept as a named
/// builder so tests bind the redirect guarantee to the real client the node
/// runs, not a hand-rolled equivalent.
fn build_http_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
}
/// Ping a peer's `/health` endpoint and report whether it answered 2xx.
///
/// Takes the client by reference so callers supply the shared, no-redirect
/// `state.http_client`. Peer URLs are attacker-influenceable, so a `3xx` to a
/// private address must not be followed. Do NOT call this with a bare
/// `reqwest::Client::new()`: its default follows redirects and would
/// reintroduce the SSRF this guards against (#93).
async fn ping_peer_health(client: &reqwest::Client, http_url: &str) -> bool {
let url = format!("{}/health", http_url.trim_end_matches('/'));
client
.get(&url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
let key_path = config.resolved_key_path();
@@ -1023,64 +652,3 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
Ok(kp)
}
}
#[cfg(test)]
mod gossip_ssrf_tests {
use super::ping_peer_health;
// Build the client exactly as production does (super::build_http_client) so
// these tests bind the redirect guarantee to the real shared client the
// node runs. A regression that makes build_http_client follow redirects
// fails ping_peer_health_does_not_follow_redirect.
fn production_http_client() -> reqwest::Client {
super::build_http_client().expect("failed to build production http client")
}
// A peer answering `/health` with a 302 toward an internal address must not
// be followed: the redirect target must never be requested (#93).
#[tokio::test]
async fn ping_peer_health_does_not_follow_redirect() {
let mut server = mockito::Server::new_async().await;
let internal = server
.mock("GET", "/internal-metadata")
.with_status(200)
.expect(0)
.create_async()
.await;
let _health = server
.mock("GET", "/health")
.with_status(302)
.with_header("location", &format!("{}/internal-metadata", server.url()))
.create_async()
.await;
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
assert!(!ok, "a 302 must not count as a healthy peer");
// expect(0) is enforced only at assert time; this fails if the redirect
// was followed to the internal target.
internal.assert_async().await;
}
#[tokio::test]
async fn ping_peer_health_reports_success_on_200() {
let mut server = mockito::Server::new_async().await;
let _health = server
.mock("GET", "/health")
.with_status(200)
.create_async()
.await;
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
assert!(ok, "a 200 /health must count as a healthy peer");
}
// A transport error (nothing listening) must map to unhealthy, never a
// spurious healthy — the .unwrap_or(false) arm.
#[tokio::test]
async fn ping_peer_health_reports_unhealthy_on_connection_error() {
let ok = ping_peer_health(&production_http_client(), "http://127.0.0.1:1").await;
assert!(!ok, "a connection error must count as an unhealthy peer");
}
}
+1 -5
View File
@@ -235,11 +235,7 @@ pub fn record_auth_failure(route: &str, reason: &str) {
}
}
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped,
/// rejected, deferred}. `rejected` is terminal and caused by a guard (a
/// malformed slug, or a path resolving outside repos_dir); `deferred` leaves
/// the row pending for a later pass, so a queue stalled on an operator
/// condition is visible rather than looking idle.
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped}.
pub fn record_sync_processed(status: &str) {
if let Some(c) = SYNC_PROCESSED.get() {
c.with_label_values(&[status]).inc();
-70
View File
@@ -39,11 +39,6 @@ pub struct RefUpdateEvent {
pub pusher_did: String,
/// Repository identifier (owner/name)
pub repo: String,
/// Full owner DID — added in #144 for display and storage; not yet
/// wired into the feed gate matcher. Optional for backward compat with
/// older peers that don't include it.
#[serde(default)]
pub owner_did: Option<String>,
/// Git ref that changed (e.g., "refs/heads/main")
pub ref_name: String,
/// SHA before the push (all-zeros for new ref)
@@ -312,7 +307,6 @@ pub async fn start(
node_did: event.node_did.clone(),
pusher_did: event.pusher_did.clone(),
repo: event.repo.clone(),
owner_did: event.owner_did.clone(),
ref_name: event.ref_name.clone(),
old_sha: event.old_sha.clone(),
new_sha: event.new_sha.clone(),
@@ -438,67 +432,3 @@ pub async fn start(
Ok(handle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_update_event_round_trip_with_owner_did() {
let event = RefUpdateEvent {
node_did: "did:key:zNode".into(),
pusher_did: "did:key:zPusher".into(),
repo: "zOwner/myrepo".into(),
owner_did: Some("did:key:zOwner".into()),
ref_name: "refs/heads/main".into(),
old_sha: "0000000000000000000000000000000000000000".into(),
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
timestamp: "2026-07-02T12:00:00Z".into(),
cert_id: None,
cid: None,
};
let json = serde_json::to_value(&event).unwrap();
// owner_did must be present in the serialized output
assert_eq!(json["owner_did"], "did:key:zOwner");
assert_eq!(json["repo"], "zOwner/myrepo");
let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into()));
}
#[test]
fn ref_update_event_backward_compat_no_owner_did() {
let old_json = serde_json::json!({
"node_did": "did:key:zNode",
"pusher_did": "did:key:zPusher",
"repo": "zOwner/myrepo",
"ref_name": "refs/heads/main",
"old_sha": "0000000000000000000000000000000000000000",
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"timestamp": "2026-07-02T12:00:00Z",
"cert_id": null,
"cid": null
});
let deserialized: RefUpdateEvent = serde_json::from_value(old_json).unwrap();
assert_eq!(deserialized.owner_did, None);
assert_eq!(deserialized.repo, "zOwner/myrepo");
}
#[test]
fn ref_update_event_backward_compat_null_owner_did() {
let with_null = serde_json::json!({
"node_did": "did:key:zNode",
"pusher_did": "did:key:zPusher",
"repo": "zOwner/myrepo",
"owner_did": null,
"ref_name": "refs/heads/main",
"old_sha": "0000000000000000000000000000000000000000",
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"timestamp": "2026-07-02T12:00:00Z",
"cert_id": null,
"cid": null
});
let deserialized: RefUpdateEvent = serde_json::from_value(with_null).unwrap();
assert_eq!(deserialized.owner_did, None);
}
}
+19 -490
View File
@@ -1,21 +1,15 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::{ConnectInfo, Request};
use axum::http::{HeaderMap, StatusCode};
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use tokio::sync::Mutex;
use crate::auth::AuthenticatedDid;
/// Default ceiling on the number of distinct keys a limiter tracks. Bounds the
/// limiter's own memory so a caller that can vary the key (many DIDs, or a
/// spoofable IP header) cannot grow the map without limit.
const DEFAULT_MAX_KEYS: usize = 100_000;
#[derive(Clone)]
struct Window {
timestamps: Vec<Instant>,
@@ -26,98 +20,38 @@ pub struct RateLimiter {
state: Arc<Mutex<HashMap<String, Window>>>,
max_requests: usize,
window: Duration,
/// Hard cap on tracked keys. When full, expired keys are evicted (at most
/// once per [`sweep_interval`](Self::sweep_interval), see below); if still
/// full, a request under a *new* key is rejected rather than inserted — so
/// the map can never exceed this bound and a rejected request never
/// allocates a new entry.
max_keys: usize,
/// Timestamp of the last inline capacity sweep. The eviction scan is
/// O(max_keys), so under a distinct-key flood (when the map sits at the
/// cap) running it on every miss would serialize all traffic behind a full
/// scan. Gating it to at most once per interval amortizes that cost; the
/// background [`cleanup`](Self::cleanup) loop still reclaims on its own.
last_sweep: Arc<Mutex<Instant>>,
}
impl RateLimiter {
// Retained for tests and callers that don't need a tight key bound;
// production limiters use `new_bounded`.
#[allow(dead_code)]
pub fn new(max_requests: usize, window: Duration) -> Self {
Self::new_bounded(max_requests, window, DEFAULT_MAX_KEYS)
}
/// Like [`new`] but with an explicit cap on the number of distinct keys.
/// Production limiters keyed on client-influenced values (per-DID, per-IP)
/// set this so the limiter's own state cannot be turned into a
/// memory-exhaustion vector.
pub fn new_bounded(max_requests: usize, window: Duration, max_keys: usize) -> Self {
Self {
state: Arc::new(Mutex::new(HashMap::new())),
max_requests,
window,
max_keys: max_keys.max(1),
last_sweep: Arc::new(Mutex::new(Instant::now())),
}
}
/// How often the inline capacity sweep may run: at most once per second, or
/// once per window when the window is shorter. Bounds post-flood staleness
/// (a full map self-heals within one interval) without paying the O(max_keys)
/// scan on every capacity miss.
fn sweep_interval(&self) -> Duration {
self.window.min(Duration::from_secs(1))
}
pub(crate) async fn check(&self, key: &str) -> bool {
// max_requests == 0 means the limiter is disabled, not "block all".
if self.max_requests == 0 {
return true;
}
async fn check(&self, key: &str) -> bool {
let now = Instant::now();
let mut state = self.state.lock().await;
// Fast path: an already-tracked key never allocates and never grows the
// map, so it is unaffected by the key cap.
if let Some(window) = state.get_mut(key) {
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
}
window.timestamps.push(now);
return true;
// Look up before inserting so the common case (key already tracked)
// doesn't allocate a String per request.
if !state.contains_key(key) {
state.insert(
key.to_string(),
Window {
timestamps: Vec::new(),
},
);
}
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
// cannot grow the map, and a rejected request never allocates an entry.
if state.len() >= self.max_keys {
// Reclaim expired keys, but at most once per sweep interval — the
// scan is O(max_keys) and the map sits at the cap precisely during a
// distinct-key flood, so sweeping per miss would serialize traffic
// behind a full scan. Between sweeps a new key is simply rejected.
let mut last_sweep = self.last_sweep.lock().await;
if now.duration_since(*last_sweep) >= self.sweep_interval() {
state.retain(|_, w| {
w.timestamps
.retain(|t| now.duration_since(*t) < self.window);
!w.timestamps.is_empty()
});
*last_sweep = now;
}
drop(last_sweep);
if state.len() >= self.max_keys {
return false;
}
let window = state.get_mut(key).expect("window just ensured");
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
}
state.insert(
key.to_string(),
Window {
timestamps: vec![now],
},
);
window.timestamps.push(now);
true
}
@@ -154,136 +88,6 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
next.run(request).await
}
/// Which forwarded header (if any) the operator's edge is trusted to set. Only
/// a proxy the operator controls may be believed; a raw client can put any
/// value in `Fly-Client-IP` / `X-Forwarded-For`, so trusting them unconditionally
/// lets a flooder rotate the header and never fill a bucket. Configured via
/// `GITLAWB_TRUSTED_PROXY`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TrustedProxy {
/// No trusted proxy: ignore forwarded headers, key on the socket peer IP.
/// Safe default for direct/self-hosted nodes.
None,
/// Behind Fly's edge, which sets (and overwrites any client-supplied)
/// `Fly-Client-IP`.
Fly,
/// Behind a single reverse proxy (e.g. Caddy on the AWS image) that appends
/// the real client as the rightmost `X-Forwarded-For` hop.
XForwardedFor,
}
impl TrustedProxy {
/// Parse `GITLAWB_TRUSTED_PROXY`. Unknown/empty → `None` (trust nothing).
pub fn from_env_value(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"fly" | "fly-client-ip" => TrustedProxy::Fly,
"xff" | "x-forwarded-for" | "caddy" => TrustedProxy::XForwardedFor,
_ => TrustedProxy::None,
}
}
}
fn trimmed_nonempty(v: &str) -> Option<String> {
let t = v.trim();
(!t.is_empty()).then(|| t.to_string())
}
/// Resolve the rate-limit key for a request. In a trusted-proxy mode the
/// operator's edge header is preferred; when that header is missing or empty we
/// fall back to the socket peer address rather than skipping the limiter (a
/// malformed header must never disable the brake). With no trusted proxy the
/// socket peer is always used and forwarded headers are ignored entirely.
/// Returns `None` only when neither a trusted header nor a peer address is
/// available (e.g. a synthetic test request with no `ConnectInfo`).
pub fn client_key(
headers: &HeaderMap,
peer: Option<SocketAddr>,
trust: TrustedProxy,
) -> Option<String> {
let from_header = match trust {
TrustedProxy::None => None,
TrustedProxy::Fly => headers
.get("fly-client-ip")
.and_then(|v| v.to_str().ok())
.and_then(trimmed_nonempty),
// Rightmost hop = the value appended by the immediately-upstream trusted
// proxy. The leftmost hop is client-controlled and must not be trusted.
TrustedProxy::XForwardedFor => headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit(',').next())
.and_then(trimmed_nonempty),
};
from_header.or_else(|| peer.map(|p| p.ip().to_string()))
}
/// Per-client-IP limiter for the git push path, carrying the trusted-proxy
/// policy. A newtype so it can live in request extensions (keyed by type)
/// alongside the per-DID [`RateLimiter`]. Per-DID limits are useless against a
/// push flood from a DID farm (one throwaway DID per repo), so the push path
/// throttles on the resolved client IP instead.
#[derive(Clone)]
pub struct IpRateLimiter {
pub limiter: RateLimiter,
pub trust: TrustedProxy,
}
/// Infallible extractor for the socket peer address from `ConnectInfo`. Yields
/// `None` when the server was started without connect-info (e.g. `oneshot` in
/// tests), so a handler never 500s on its absence — the limiter simply falls
/// back per [`client_key`].
pub struct PeerAddr(pub Option<SocketAddr>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(PeerAddr(
parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0),
))
}
}
/// 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).
pub fn too_many_requests() -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response()
}
/// 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.
pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
let limiter = request.extensions().get::<IpRateLimiter>().cloned();
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0);
if let Some(limiter) = limiter {
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
if !limiter.limiter.check(&key).await {
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
return too_many_requests();
}
}
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -330,279 +134,4 @@ mod tests {
let state = limiter.state.lock().await;
assert!(state.is_empty());
}
#[tokio::test]
async fn zero_limit_disables() {
let limiter = RateLimiter::new(0, Duration::from_secs(60));
for _ in 0..1000 {
assert!(limiter.check("k").await);
}
}
// ── key-cap / memory-bound (P2) ──────────────────────────────────────
#[tokio::test]
async fn caps_tracked_keys_and_rejects_new_ones_when_full() {
// Cap of 2 distinct keys; generous request budget so rejection is due to
// the key cap, not the per-key limit.
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 2);
assert!(limiter.check("a").await);
assert!(limiter.check("b").await);
// Third distinct key would grow the map past the cap → rejected.
assert!(!limiter.check("c").await);
// The map never exceeded the cap, and the rejected key was NOT inserted.
let state = limiter.state.lock().await;
assert_eq!(state.len(), 2);
assert!(!state.contains_key("c"));
}
#[tokio::test]
async fn known_key_unaffected_by_cap() {
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 1);
assert!(limiter.check("a").await); // fills the single slot
assert!(limiter.check("a").await); // same key still served
assert!(!limiter.check("b").await); // new key rejected — cap full
}
#[tokio::test]
async fn expired_keys_evicted_to_admit_new_when_full() {
let limiter = RateLimiter::new_bounded(100, Duration::from_millis(40), 1);
assert!(limiter.check("old").await);
tokio::time::sleep(Duration::from_millis(55)).await;
// "old" is now expired; a new key triggers inline eviction and is admitted.
assert!(limiter.check("new").await);
let state = limiter.state.lock().await;
assert!(state.contains_key("new"));
assert!(!state.contains_key("old"));
}
#[tokio::test]
async fn capacity_sweep_is_amortized_within_interval() {
// The O(max_keys) eviction scan must not run on every capacity miss.
// With a 1s sweep interval, the resident (unexpired) key is never
// evicted by a burst of misses, so repeated new keys are all rejected
// without disturbing existing state.
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(1), 1);
assert!(limiter.check("resident").await);
for i in 0..100 {
assert!(
!limiter.check(&format!("flood-{i}")).await,
"new key must be rejected while the single slot is held"
);
}
let state = limiter.state.lock().await;
assert_eq!(state.len(), 1, "no flood key was ever inserted");
assert!(state.contains_key("resident"));
}
// ── client_key / trusted-proxy resolution (P1 + P2) ─────────────────
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
v.parse().unwrap(),
);
}
h
}
fn peer(s: &str) -> Option<SocketAddr> {
Some(s.parse().unwrap())
}
#[test]
fn none_mode_ignores_headers_and_uses_peer() {
// Even a well-formed Fly header is ignored without a trusted proxy.
let h = headers(&[("fly-client-ip", "203.0.113.7")]);
assert_eq!(
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::None).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn fly_mode_trusts_fly_header() {
let h = headers(&[
("fly-client-ip", "203.0.113.7"),
("x-forwarded-for", "1.2.3.4"),
]);
assert_eq!(
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::Fly).as_deref(),
Some("203.0.113.7")
);
}
#[test]
fn fly_mode_empty_header_falls_back_to_peer_not_skip() {
// Empty Fly-Client-IP must NOT collapse traffic onto Some("") nor skip
// the limiter — it falls back to the real peer.
let h = headers(&[("fly-client-ip", "")]);
assert_eq!(
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::Fly).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn xff_mode_uses_rightmost_hop_not_client_controlled_first() {
// Client prepends spoofed hops; only the rightmost (proxy-appended) is trusted.
let h = headers(&[("x-forwarded-for", "9.9.9.9, 8.8.8.8, 198.51.100.9")]);
assert_eq!(
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn xff_mode_empty_leading_hop_does_not_disable_brake() {
// "X-Forwarded-For: ,1.2.3.4" — rightmost hop is used; never None-skips.
let h = headers(&[("x-forwarded-for", ",1.2.3.4")]);
assert_eq!(
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
Some("1.2.3.4")
);
}
#[test]
fn malformed_xff_falls_back_to_peer() {
let h = headers(&[("x-forwarded-for", " , ")]);
assert_eq!(
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::XForwardedFor).as_deref(),
Some("198.51.100.9")
);
}
#[test]
fn trusted_proxy_parsing() {
assert_eq!(TrustedProxy::from_env_value("fly"), TrustedProxy::Fly);
assert_eq!(
TrustedProxy::from_env_value("X-Forwarded-For"),
TrustedProxy::XForwardedFor
);
assert_eq!(
TrustedProxy::from_env_value("caddy"),
TrustedProxy::XForwardedFor
);
assert_eq!(TrustedProxy::from_env_value(""), TrustedProxy::None);
assert_eq!(TrustedProxy::from_env_value("garbage"), TrustedProxy::None);
}
// ── middleware 429 path ─────────────────────────────────────────────
/// A minimal router with the push limiter layered over an OK handler,
/// driven via `oneshot`. `ConnectInfo` is attached to each request directly
/// (as the production make-service does) so the middleware resolves a peer.
fn ip_limited_router(limiter: IpRateLimiter) -> axum::Router {
axum::Router::new()
.route(
"/o/r/git-receive-pack",
axum::routing::post(|| async { StatusCode::OK }),
)
.layer(axum::middleware::from_fn(rate_limit_by_ip))
.layer(axum::Extension(limiter))
}
async fn post_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/o/r/git-receive-pack")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn middleware_returns_429_over_limit() {
let router = ip_limited_router(IpRateLimiter {
limiter: RateLimiter::new(2, Duration::from_secs(60)),
trust: TrustedProxy::None,
});
let peer: SocketAddr = "203.0.113.7:5000".parse().unwrap();
// Two requests inside the budget, the third over it (shared Arc state).
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
assert_eq!(
post_from(&router, peer).await,
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn middleware_isolates_distinct_peers() {
let router = ip_limited_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::None,
});
let a: SocketAddr = "203.0.113.1:1".parse().unwrap();
let b: SocketAddr = "203.0.113.2:1".parse().unwrap();
assert_eq!(post_from(&router, a).await, StatusCode::OK);
assert_eq!(post_from(&router, b).await, StatusCode::OK); // independent bucket
assert_eq!(post_from(&router, a).await, StatusCode::TOO_MANY_REQUESTS);
}
// ── creation-route layering: IP brake runs BEFORE auth ──────────────
/// A minimal stand-in for the creation route group: an inner "auth" layer
/// that rejects every request with 401 (as signature verification would for
/// an unsigned caller), wrapped by the per-IP creation limiter as the
/// outermost layer — exactly the ordering `server::build_router` applies to
/// `/api/v1/repos` et al. Lets us assert the security-relevant property
/// (flood traffic is braked before auth burns CPU) without a database, which
/// the full `#[sqlx::test]` route test in `api::repos` cannot cover in the
/// plain unit-test pass.
fn ip_limited_over_auth_router(limiter: IpRateLimiter) -> axum::Router {
async fn always_unauthorized(_req: Request, _next: Next) -> Response {
StatusCode::UNAUTHORIZED.into_response()
}
axum::Router::new()
.route(
"/api/v1/repos",
axum::routing::post(|| async { StatusCode::CREATED }),
)
.layer(axum::middleware::from_fn(always_unauthorized))
.layer(axum::middleware::from_fn(rate_limit_by_ip))
.layer(axum::Extension(limiter))
}
async fn post_repos_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri("/api/v1/repos")
.body(axum::body::Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
router.clone().oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn creation_ip_brake_rejects_before_auth() {
// A DID farm mints a fresh signature per repo, so the per-DID limiter
// never trips; the per-IP brake is what stops the flood. It must run
// outermost, ahead of auth, so a flood is rejected with 429 before
// signature verification — otherwise every request would reach (and pay
// for) auth and merely 401.
let router = ip_limited_over_auth_router(IpRateLimiter {
limiter: RateLimiter::new(1, Duration::from_secs(60)),
trust: TrustedProxy::None,
});
let peer: SocketAddr = "203.0.113.42:7000".parse().unwrap();
// First request passes the IP brake and reaches the (failing) auth layer.
assert_eq!(
post_repos_from(&router, peer).await,
StatusCode::UNAUTHORIZED
);
// Budget now exhausted: the IP brake short-circuits with 429 *before*
// auth — proving the limiter is the outermost layer.
assert_eq!(
post_repos_from(&router, peer).await,
StatusCode::TOO_MANY_REQUESTS,
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
);
}
}
+6 -142
View File
@@ -14,7 +14,7 @@ use tracing::Level;
use crate::api::{
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles,
protect, pulls, register, replicas, repos, resolve, stars, status, tasks, visibility, webhooks,
protect, pulls, register, replicas, repos, resolve, stars, tasks, visibility, webhooks,
};
use crate::auth;
use crate::rate_limit;
@@ -81,19 +81,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/tasks", get(tasks::list_tasks))
.route("/api/v1/tasks/{id}", get(tasks::get_task));
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
// single identity; the per-IP limiter (outer) caps a DID farm that mints a
// fresh throwaway did:key per repo to slip past both the per-DID limit and
// the iCaptcha gate — the mechanism behind the recurring spam-repo floods.
// The per-IP layer wraps the auth layer (outermost = runs first) so flood
// traffic is rejected before signature verification burns CPU, matching the
// push path.
// ── Rate-limited creation routes — require HTTP Signature + per-DID throttle
let limiter = state.rate_limiter.clone();
let create_ip_limiter = rate_limit::IpRateLimiter {
limiter: state.create_ip_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let creation_routes = add_auth_layers(
Router::new()
.route("/api/v1/repos", post(repos::create_repo))
@@ -107,9 +96,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(limiter)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(create_ip_limiter));
);
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
let write_routes = add_auth_layers(
@@ -183,77 +170,11 @@ pub fn build_router(state: AppState) -> Router {
state.clone(),
);
// ── Status claim writes — signature, plus the per-DID throttle AND the
// per-IP flood brake `creation_routes` carries. Not in `write_routes`, which
// has neither: every call here appends a row, so an unthrottled writer grows
// the claim log on demand and the three per-repo caps would be the only bound.
let status_write_routes = add_auth_layers(
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/statuses/{sha}",
post(status::create_status),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(state.rate_limiter.clone())),
state.clone(),
)
// The route's advertised body bound, applied at the transport. It sits
// OUTSIDE the auth layers because `require_signature` collects and hashes
// the whole body before any handler runs: the handler's own check measures
// an allocation that has already happened, so without this layer the 8 KiB
// figure bounded what gets stored and not what gets buffered, and
// concurrent requests multiplied exactly the cost it was written to
// prevent. It sits INSIDE the rate limiters so an oversized request is
// still counted by the per-IP brake before it is refused.
//
// A declared Content-Length over the limit is answered 413 without reading
// a byte; a body that declares nothing (or understates) is truncated at the
// limit and fails where it is read, which `require_signature` reports as an
// unreadable body.
.layer(RequestBodyLimitLayer::new(status::MAX_REQUEST_BODY_BYTES))
// The one route that persists the signed request body, so the one route
// whose signature material carries it. Applied OUTSIDE `add_auth_layers`
// (outermost = runs first) because `require_signature` reads this marker to
// decide whether to carry the body; a layer added inside the auth pair runs
// after the middleware and is never seen. `create_status` refuses an absent
// body rather than storing an empty column, so getting this order wrong
// fails loudly instead of quietly writing unverifiable claims.
.layer(axum::Extension(auth::PersistsSignedBody))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
limiter: state.create_ip_rate_limiter.clone(),
trust: state.push_limiter_trust,
}));
// ── Status claim reads — open, behind `optional_signature` so the handler
// sees the caller when one signs and still answers a public repo for an
// anonymous reader. Without the layer the handler's optional auth extension
// is None for EVERY caller, including the owner, and every private repo
// reads as not-found.
let status_read_routes = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/commits/{sha}/status",
get(status::commit_status),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/status",
get(status::pull_request_status),
)
.layer(middleware::from_fn(auth::optional_signature));
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
// routes only — all other API routes keep axum's default 2 MB cap.
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
// helper signs requests with RFC 9421 signatures using the agent's keypair.
let pack_limit = state.config.max_pack_bytes;
// Per-IP throttle wraps the auth layer (outermost = runs first): flood
// traffic is rejected before signature verification burns CPU. Per-DID
// limiting is deliberately NOT used here — a DID farm (one throwaway
// identity per repo, as in the June 2026 push flood) never trips it.
let push_limiter = rate_limit::IpRateLimiter {
limiter: state.push_rate_limiter.clone(),
trust: state.push_limiter_trust,
};
let git_write_routes = add_auth_layers(
Router::new()
.route(
@@ -263,9 +184,7 @@ pub fn build_router(state: AppState) -> Router {
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(push_limiter));
);
// ── IPFS content-addressed retrieval and pin listing ──────────────────
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
@@ -319,8 +238,7 @@ pub fn build_router(state: AppState) -> Router {
.route(
"/api/v1/agents/{did}/bounties",
get(bounties::agent_bounty_stats),
)
.layer(middleware::from_fn(auth::optional_signature));
);
// ── Profile routes (write — require HTTP Signature) ─────────────────
let profile_write_routes = add_auth_layers(
@@ -349,41 +267,15 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/peers", get(peers::list_peers))
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer));
// /sync/trigger drives an O(peers) outbound fan-out + per-repo enqueue, so it
// ALWAYS requires a signature (both config modes) and carries a tight per-IP
// brake. A signature alone does not cap cost — a did:key farm self-registers
// (INV-10) — so the IP brake is a separate, load-bearing half. The brake is
// outermost (runs before signature verification burns CPU) and is keyed on the
// client IP before any DID is read, so DID rotation cannot bypass it.
let sync_trigger_routes = add_auth_layers(
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
state.clone(),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
limiter: state.sync_trigger_rate_limiter.clone(),
trust: state.push_limiter_trust,
}));
// announce + notify keep their rolling-upgrade signature behavior (unsigned
// accepted until all peers upgrade), but both reach peer-write side effects —
// notify hits the same enqueue_sync sink as trigger — so they carry a per-IP
// brake too, on a SEPARATE bucket from trigger's, so an unsigned notify flood
// cannot drain the signed trigger caller's quota.
let mut peer_write_routes = Router::new()
.route("/api/v1/peers/announce", post(peers::announce))
.route("/api/v1/sync/trigger", post(peers::trigger_sync))
.route("/api/v1/sync/notify", post(peers::notify_sync));
peer_write_routes = if state.config.require_signed_peer_writes {
add_auth_layers(peer_write_routes, state.clone())
} else {
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
};
let peer_write_routes = peer_write_routes
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
limiter: state.peer_write_rate_limiter.clone(),
trust: state.push_limiter_trust,
}));
// ── Read routes — open for public repos ───────────────────────────────
let read_routes = Router::new()
@@ -431,10 +323,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/repos/{owner}/{repo}/events",
get(events::list_repo_events),
)
.route(
"/api/v1/repos/{owner}/{repo}/push-events",
get(events::list_repo_push_events),
)
.route("/api/v1/agents", get(agents::list_agents))
.route("/api/v1/agents/{did}", get(agents::show_agent))
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
@@ -517,7 +405,6 @@ pub fn build_router(state: AppState) -> Router {
let meta_routes = Router::new()
.route("/", get(node_info))
.route("/health", get(health))
.route("/ready", get(ready))
.route("/api/v1/p2p/info", get(p2p_info))
.route("/api/v1/stats", get(stats))
.route("/api/v1/contracts", get(contracts_info));
@@ -531,15 +418,12 @@ pub fn build_router(state: AppState) -> Router {
.merge(profile_write_routes)
.merge(creation_routes)
.merge(write_routes)
.merge(status_write_routes)
.merge(status_read_routes)
.merge(git_write_routes)
.merge(git_read_routes)
.merge(issue_write_routes)
.merge(read_routes)
.merge(peer_read_routes)
.merge(peer_write_routes)
.merge(sync_trigger_routes)
.merge(ipfs_routes)
.merge(arweave_routes)
.merge(meta_routes)
@@ -562,26 +446,6 @@ async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok" }))
}
/// Readiness = "this node can serve real traffic", and every real endpoint
/// depends on the database, so probe it rather than reporting a constant.
/// The probe gets its own short bound so a wedged pool can't hang the health
/// check for the full acquire timeout.
async fn ready(State(state): State<AppState>) -> axum::response::Response {
let probe = tokio::time::timeout(std::time::Duration::from_secs(2), state.db.ping()).await;
match probe {
Ok(Ok(())) => Json(json!({ "status": "ready" })).into_response(),
_ => (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"status": "degraded",
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})),
)
.into_response(),
}
}
async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
let p2p_peer_id = state.p2p.as_ref().map(|h| h.local_peer_id.to_string());
Json(json!({
-30
View File
@@ -17,7 +17,6 @@ pub struct RefUpdateBroadcast {
pub pusher_did: String,
pub node_did: String,
pub timestamp: String,
pub owner_did: String,
}
#[derive(Clone, Debug)]
@@ -52,35 +51,6 @@ pub struct AppState {
pub repo_store: RepoStore,
/// Per-DID rate limiter for creation endpoints (repos, issues, PRs)
pub rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for the same creation endpoints. The per-DID
/// limiter above cannot brake a creation flood from a DID farm — one
/// throwaway `did:key` per repo means each DID makes a single create call
/// and never trips its own bucket. A valid iCaptcha proof does not cap this
/// either: the enforced level draws only machine-solvable deterministic
/// challenges (and the caller can pin the easy type), so a bot mints a fresh
/// DID, solves a proof, and creates a repo unthrottled. Braking on the
/// resolved client IP is what actually stops a single-source flood (same
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
pub create_ip_rate_limiter: RateLimiter,
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
/// push path throttles on the resolved client IP instead.
pub push_rate_limiter: RateLimiter,
/// Which forwarded header (if any) the edge is trusted to set, for
/// resolving the push limiter's client-IP key. See `GITLAWB_TRUSTED_PROXY`.
/// Node-wide; also keys the two peer-sync limiters below.
pub push_limiter_trust: crate::rate_limit::TrustedProxy,
/// Per-client-IP limiter for `POST /api/v1/sync/trigger` (tight). The route
/// requires a signature, but a signature does not cap cost (a did:key farm
/// self-registers), and its per-call cost is an O(peers) fan-out, so the IP
/// brake is a separate, load-bearing half. Its own bucket so an unsigned
/// `/sync/notify` flood cannot drain the signed trigger caller's quota.
pub sync_trigger_rate_limiter: RateLimiter,
/// Per-client-IP limiter for the peer-write routes (`/peers/announce`,
/// `/sync/notify`) (generous). `/sync/notify` reaches the same `enqueue_sync`
/// 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,
/// Process-wide graceful-shutdown signal. Sending `true` causes every
/// task that holds a `watch::Receiver` to exit at its next await point.
/// Used by:
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5 -414
View File
@@ -4,8 +4,7 @@
//! based on the repo's visibility rules with a fallback to the legacy
//! `is_public` flag. It performs no I/O so it is exhaustively unit tested.
use crate::db::{RepoRecord, VisibilityRule};
use std::collections::HashMap;
use crate::db::VisibilityRule;
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, PartialEq, Eq)]
@@ -25,12 +24,11 @@ fn nfc(s: &str) -> String {
s.nfc().collect()
}
/// True if `caller` is the repo owner. Uses [`crate::api::did_matches`] so the
/// owner check is representation-agnostic within `did:key` (full `did:key:z...`
/// matches bare `z...` stored by mirror rows) while still denying cross-method
/// collisions (`did:gitlawb:z...` vs bare `z...`).
/// True if `caller` is the repo owner (matches full did:key or its short form),
/// mirroring the owner-match idiom in `api/protect.rs`.
fn is_owner(owner_did: &str, caller: &str) -> bool {
crate::api::did_matches(owner_did, caller)
let owner_short = owner_did.split(':').next_back().unwrap_or(owner_did);
caller == owner_did || caller == owner_short
}
/// The match prefix for a glob: "/" stays "/", "/secret/**" becomes "/secret".
@@ -129,105 +127,6 @@ pub fn listable_at_root(
visibility_check(rules, is_public, owner_did, caller, "/") == Decision::Allow
}
/// Whether a single `received_ref_updates` row (identified by its peer-supplied
/// `row_repo` slug) should be shown to `caller` (None = anonymous) on the
/// cross-repo ref-updates feeds (#112 GraphQL, #114 REST).
///
/// Pure and I/O-free: both call sites load the deduped local repo set and its
/// visibility rules once per request and pass them in, so the gate logic lives
/// here and visibility.rs keeps its "no I/O" property.
///
/// The slug is written verbatim from the inbound gossip/notify message, so it is
/// untrusted input, not a canonical key. The decision is fail-closed by
/// construction: the only KEEP paths are (a) a slug with no `/` (cannot name a
/// local `owner/name` pair, so remote by definition), (b) all matched local
/// records are readable, and (c) no local record matches (remote/gossip-only).
/// Any local match the caller cannot read at root DROPs the row. There is no
/// catch-all keep on unexpected state.
///
/// Slug/record owner keys are matched prefix-tolerantly (one is a prefix of the
/// other), covering the exact short-key, the full `did:key:` form, and the
/// URL-truncated 8-char form. Prefix over-match can only over-drop a genuinely
/// remote row (fail-safe), never over-serve a private local one.
///
/// The live call sites are the #112 (GraphQL) and #114 (REST) feed handlers,
/// added in the following units; exercised by the unit tests below meanwhile.
pub fn ref_update_row_visible(
deduped: &[RepoRecord],
rules_by_repo: &HashMap<String, Vec<VisibilityRule>>,
caller: Option<&str>,
row_repo: &str,
) -> bool {
// A slug with no '/' cannot name a local owner/name pair — remote by
// definition (same branch as "matches nothing local") → KEEP.
if row_repo.rsplit_once('/').is_none() {
return true;
}
// Match each local record with the shared slug predicate (one matcher, so
// this gate and the collector's quarantine drop cannot disagree about which
// rows a repo owns), then fail closed on any matched record the caller
// cannot read at root.
for record in deduped {
if !ref_update_row_names_repo(record, row_repo) {
continue;
}
let rules = rules_by_repo
.get(&record.id)
.map(Vec::as_slice)
.unwrap_or(&[]);
if !listable_at_root(rules, record.is_public, &record.owner_did, caller) {
return false;
}
}
// Reached only if every matched local record is readable, or nothing local
// matched (remote/gossip-only). Both are the KEEP paths; there is no
// default-keep on unexpected state — an unreadable match already returned.
true
}
/// Whether `row_repo`'s peer-supplied slug names the local `record`. The single
/// match predicate shared by the feed gate ([`ref_update_row_visible`]) and the
/// quarantine hard-drop in the ref-update collector, so the two cannot diverge
/// about which rows a repo owns — a second, drifting matcher is exactly the #134
/// slug-collision class.
///
/// A record matches when the slug's `name` half is equal and one owner key is a
/// prefix of the other (prefix-tolerant: exact short key, full `did:key:` form,
/// and the URL-truncated 8-char form). Both the slug owner and the record owner
/// are reduced to their last ':'-delimited segment before comparing. The node's
/// own emitter builds the wire slug via `normalize_owner_key` (bare short key for
/// a single-segment `did:key:`, the full DID for every other method; see
/// api/repos.rs), but the stored slug is UNTRUSTED: a peer can broadcast a row
/// under any owner form, including a method-stripped `user/name`. Reducing both
/// sides to the trailing segment matches a repo's own rows whichever form keyed
/// them, and still catches an attacker-planted short slug.
///
/// This intentionally diverges from `did_matches` / `DEDUP_CTE`, which strip only
/// bare `did:key:` and keep other DID methods whole: those compare trusted,
/// canonical DIDs, while this slug is untrusted. Applying the keep-whole rule here
/// would fail open: a hostile `user/name` slug would not prefix-match a private
/// `did:web:host:user` record and would leak. The price is a fail-SAFE over-match
/// when a remote owner shares both a trailing segment and a repo name with a local
/// private repo (negligible for full did:key ids; only did:web / truncated forms
/// collide): it can hide a genuinely remote row, never serve a private one.
pub fn ref_update_row_names_repo(record: &RepoRecord, row_repo: &str) -> bool {
let Some((owner_part, name)) = row_repo.rsplit_once('/') else {
return false;
};
if record.name != name {
return false;
}
let row_key = owner_part.split(':').next_back().unwrap_or(owner_part);
let record_key = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
record_key.starts_with(row_key) || row_key.starts_with(record_key)
}
/// The subtree path globs that `caller` (None = anonymous) may NOT read, given
/// the repo's rules. Whole-repo ("/") rules are excluded: a denied whole-repo
/// read is handled by the 404 gate before a clone ever starts. Each remaining
@@ -400,60 +299,6 @@ mod tests {
);
}
#[test]
fn non_key_owner_bare_short_does_not_match() {
// A non-key DID owner (did:gitlawb:z6MkFoo) probed with the bare last
// segment (z6MkFoo) must NOT match — the old is_owner would return true
// via split(':').next_back(), which this PR tightens.
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, false, "did:gitlawb:z6MkFoo", Some("z6MkFoo"), "/"),
Decision::Deny,
"bare short must not match a non-key DID owner"
);
// The full non-key DID still matches itself.
assert_eq!(
visibility_check(
&rules,
false,
"did:gitlawb:z6MkFoo",
Some("did:gitlawb:z6MkFoo"),
"/"
),
Decision::Allow,
"full non-key DID still matches itself"
);
}
// #153 forward guard: is_owner must match across bare-key / full-did:key
// representations should someone revert to single-side normalize_owner_key.
#[test]
fn bare_owner_full_caller_allows_owner() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, false, "z6MkOwner", Some("did:key:z6MkOwner"), "/"),
Decision::Allow,
"bare owner + full did:key caller must match (guard against single-side revert)"
);
}
// #153 regression: cross-method DID must still be denied even when the
// trailing segment collides with a bare owner key.
#[test]
fn cross_method_did_denied_with_bare_owner() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, false, "z6MkFoo", Some("did:gitlawb:z6MkFoo"), "/"),
Decision::Deny,
"cross-method DID must not match a bare did:key owner"
);
assert_eq!(
visibility_check(&rules, false, "z6MkFoo", Some("did:web:z6MkFoo"), "/"),
Decision::Deny,
"did:web must not match a bare did:key owner"
);
}
#[test]
fn root_rule_allows_listed_reader() {
let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
@@ -558,260 +403,6 @@ mod tests {
));
}
// ── ref_update_row_visible (feed gate) ──────────────────────────────────
fn rec(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
RepoRecord {
id: id.into(),
name: name.into(),
owner_did: owner_did.into(),
description: None,
is_public,
default_branch: "main".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: format!("/srv/{id}"),
forked_from: None,
machine_id: None,
}
}
fn rules_for(entries: &[(&str, &[VisibilityRule])]) -> HashMap<String, Vec<VisibilityRule>> {
entries
.iter()
.map(|(id, rs)| (id.to_string(), rs.to_vec()))
.collect()
}
#[test]
fn feed_public_local_repo_kept_for_anon() {
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", true)];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
}
#[test]
fn feed_private_local_repo_dropped_for_anon_kept_for_owner() {
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
// Anonymous → drop.
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
// Owner (full DID) → keep.
assert!(ref_update_row_visible(
&deduped,
&rules,
Some("did:key:z6MkOwner"),
"z6MkOwner/widget"
));
}
#[test]
fn feed_root_rule_reader_reincluded() {
// Private repo (is_public=false) with a root rule granting a named
// reader. Delegates to listable_at_root: anon and non-reader denied,
// named reader allowed.
let deduped = [rec("r1", OWNER, "widget", false)];
let root = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
let rules = rules_for(&[("r1", &root)]);
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
assert!(!ref_update_row_visible(
&deduped,
&rules,
Some("did:key:z6MkStranger"),
"z6MkOwner/widget"
));
assert!(ref_update_row_visible(
&deduped,
&rules,
Some("did:key:z6MkFriend"),
"z6MkOwner/widget"
));
}
#[test]
fn feed_alias_full_did_slug_dropped_for_anon() {
// Owner stored full-DID; slug also carries the full-DID form. Still
// matches (row_key normalizes to the short key) → drop. Round 1's
// string-match would have leaked this.
let deduped = [rec("r1", "did:key:zABC", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"did:key:zABC/widget"
));
}
#[test]
fn feed_truncated_key_slug_dropped_for_anon() {
// Slug carries an 8-char URL-truncated prefix of the owner key; still
// matches via prefix tolerance → drop. Round 2's get_repo path leaked.
let deduped = [rec("r1", "did:key:zABCDEFGH", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"zABCDEF/widget"
));
}
#[test]
fn feed_mirror_coexistence_private_canonical_dropped_for_anon() {
// Pure-level mirror-coexistence: the deduped set contains only the
// private canonical record for (owner,name). A matching slug drops for
// anon. (DB-level dedup survivor property is pinned separately.)
let deduped = [rec("uuid-canonical", "did:key:z6Mkwbud", "nipmod", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"z6Mkwbud/nipmod"
));
}
#[test]
fn feed_empty_owner_slug_matches_and_drops() {
// Slug "/name": empty owner_part → row_key "" → starts_with("") matches
// every same-named record. Fail-safe pin for a private repo.
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(&deduped, &rules, None, "/widget"));
}
#[test]
fn feed_one_char_owner_slug_matches_and_drops() {
// 1-char owner prefix that the private repo's key starts with → match.
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(&deduped, &rules, None, "z/widget"));
}
#[test]
fn feed_remote_slug_no_match_kept() {
// Different owner key, no prefix relation → no local match → keep.
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"zZZZOTHER/widget"
));
}
#[test]
fn feed_private_didweb_short_slug_dropped_for_anon() {
// A private did:web repo must drop its row for anon when the slug arrives
// in the short `{last-segment}/{name}` form. Post-#141 the emitter no longer
// produces this form for did:web (normalize_owner_key keeps the full DID),
// so this is the untrusted/crafted variant a peer can broadcast; the full-DID
// variant the emitter now sends is covered by the next test. A did:key-aware
// (keep-whole) rule would fail to match `alice` against `did:web:host:alice`
// and leak.
let deduped = [rec("r1", "did:web:host:alice", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"alice/widget"
));
}
#[test]
fn feed_multi_segment_did_slug_dropped_for_anon() {
// A private repo owned by a multi-segment DID must also fail closed under
// the full-DID slug form. Post-#141 this IS the form the emitter broadcasts
// (normalize_owner_key keeps non-did:key methods whole), and a peer can craft
// it too. The gate drops it: both sides reduce to the last ':' segment
// ("user"), so they match. Fail-safe regardless of which form keyed the row.
let deduped = [rec("r1", "did:web:host:user", "widget", false)];
let rules = HashMap::new();
assert!(!ref_update_row_visible(
&deduped,
&rules,
None,
"did:web:host:user/widget"
));
}
#[test]
fn feed_multi_segment_did_slug_kept_for_public() {
// Symmetric keep-side: a PUBLIC multi-segment-DID repo's row must still be
// returned to anon after the last-':'-segment normalization — guards
// against a regression that over-drops legitimate did:web rows.
let deduped = [rec("r1", "did:web:host:user", "widget", true)];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"did:web:host:user/widget"
));
}
#[test]
fn feed_malformed_slug_no_slash_kept_no_panic() {
let deduped = [rec("r1", "did:key:z6MkOwner", "widget", false)];
let rules = HashMap::new();
assert!(ref_update_row_visible(&deduped, &rules, None, "noslug"));
}
#[test]
fn feed_empty_deduped_set_keeps_any_slug() {
let deduped: [RepoRecord; 0] = [];
let rules = HashMap::new();
assert!(ref_update_row_visible(
&deduped,
&rules,
None,
"z6MkOwner/widget"
));
assert!(ref_update_row_visible(&deduped, &rules, None, "anything"));
}
// The shared slug matcher underpinning both the feed gate and the collector's
// quarantine drop: it must recognize a repo's own row across every owner-DID
// form (bare short key, full did:key, URL-truncated prefix) and must not
// over-match a different name or an unrelated owner. The quarantine drop calls
// this directly, so its match contract is load-bearing for withholding a
// quarantined mirror from a caller who matches the mirror's owner_did.
#[test]
fn names_repo_matches_owner_forms_and_rejects_mismatches() {
// Bare short owner key (the form upsert_mirror_repo stores).
let bare = rec("m", "z6MkQuar", "secret", false);
assert!(ref_update_row_names_repo(&bare, "z6MkQuar/secret"));
// Full did:key owner; the short-segment slug still matches via last-segment.
let full = rec("c", "did:key:z6MkQuar", "secret", false);
assert!(ref_update_row_names_repo(&full, "z6MkQuar/secret"));
assert!(ref_update_row_names_repo(&full, "did:key:z6MkQuar/secret"));
// URL-truncated 8-char prefix slug → prefix-tolerant match.
let long = rec("l", "did:key:z6MkQuarLONGKEY", "secret", false);
assert!(ref_update_row_names_repo(&long, "z6MkQuar/secret"));
// Different name, different owner, and no-slash → no match.
assert!(!ref_update_row_names_repo(&bare, "z6MkQuar/other"));
assert!(!ref_update_row_names_repo(&bare, "z6MkOther/secret"));
assert!(!ref_update_row_names_repo(&bare, "noslash"));
}
// #101: a deny rule must withhold a path that denotes the same characters in
// a different Unicode normalization form. Without NFC normalization at the
// matcher seam, an NFC-authored rule byte-compares unequal to an NFD-stored
+1 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "gl"
description = "The gitlawb CLI — identity management, node control, MCP server"
version = "0.7.1" # x-release-please-version
version = "0.4.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -12,8 +12,6 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
icaptcha-client = { path = "../icaptcha-client" }
base64 = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+10 -28
View File
@@ -8,10 +8,6 @@ use std::path::PathBuf;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
fn signed_client(node: &str, dir: Option<&std::path::Path>) -> NodeClient {
NodeClient::new(node, load_keypair_from_dir(dir).ok())
}
#[derive(Args)]
pub struct BountyArgs {
#[command(subcommand)]
@@ -53,8 +49,6 @@ pub enum BountyCmd {
status: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Show details of a specific bounty
Show {
@@ -62,8 +56,6 @@ pub enum BountyCmd {
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Claim an open bounty
Claim {
@@ -129,13 +121,8 @@ pub async fn run(args: BountyArgs) -> Result<()> {
node,
dir,
} => cmd_create(repo, title, amount, issue, tx_hash, deadline, node, dir).await,
BountyCmd::List {
repo,
status,
node,
dir,
} => cmd_list(repo, status, node, dir).await,
BountyCmd::Show { id, node, dir } => cmd_show(id, node, dir).await,
BountyCmd::List { repo, status, node } => cmd_list(repo, status, node).await,
BountyCmd::Show { id, node } => cmd_show(id, node).await,
BountyCmd::Claim {
id,
wallet,
@@ -205,13 +192,8 @@ async fn cmd_create(
Ok(())
}
async fn cmd_list(
repo: Option<String>,
status: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let client = signed_client(&node, dir.as_deref());
async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let url = if let Some(ref repo) = repo {
let (owner, name) = repo
@@ -232,7 +214,7 @@ async fn cmd_list(
};
let resp = client
.get_authed(&url)
.get(&url)
.await
.context("failed to connect to node")?;
let body: Value = resp.json().await.unwrap_or_default();
@@ -255,9 +237,9 @@ async fn cmd_list(
Ok(())
}
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?;
async fn cmd_show(id: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let resp = client.get(&format!("/api/v1/bounties/{id}")).await?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
@@ -480,7 +462,7 @@ mod tests {
.create_async()
.await;
cmd_list(None, None, server.url(), None).await.unwrap();
cmd_list(None, None, server.url()).await.unwrap();
}
#[tokio::test]
@@ -524,7 +506,7 @@ mod tests {
.create_async()
.await;
let err = cmd_show("nonexistent".to_string(), server.url(), None)
let err = cmd_show("nonexistent".to_string(), server.url())
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
+42 -248
View File
@@ -5,15 +5,10 @@
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
fn signed_client(node: &str, dir: Option<&std::path::Path>) -> NodeClient {
NodeClient::new(node, load_keypair_from_dir(dir).ok())
}
#[derive(Args)]
pub struct CertArgs {
#[command(subcommand)]
@@ -28,8 +23,6 @@ pub enum CertCmd {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Show a specific ref certificate and verify its signature
Show {
@@ -39,51 +32,28 @@ pub enum CertCmd {
id: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
/// Exit non-zero unless the Ed25519 signature verifies AND the
/// issuing node matches the queried node (or --expect-node)
#[arg(long)]
verify: bool,
/// Expected issuing node DID for --verify. A valid signature alone
/// only proves the cert is internally consistent — signed by whatever
/// key it names — so --verify also anchors the issuer to a DID you
/// trust: this value when given, else the queried node's DID.
#[arg(long, requires = "verify")]
expect_node: Option<String>,
},
}
pub async fn run(args: CertArgs) -> Result<()> {
match args.cmd {
CertCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
CertCmd::Show {
repo,
id,
node,
dir,
verify,
expect_node,
} => cmd_show(repo, id, node, dir, verify, expect_node).await,
CertCmd::List { repo, node } => cmd_list(repo, node).await,
CertCmd::Show { repo, id, node } => cmd_show(repo, id, node).await,
}
}
/// Resolve "repo" into (owner, name) using the caller's DID when no slash is given.
async fn resolve_repo(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
async fn resolve_repo(repo: &str, node: &str) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
Ok((owner.to_string(), name.to_string()))
} else {
let short = if let Ok(kp) = load_keypair_from_dir(dir) {
let short = if let Ok(kp) = load_keypair_from_dir(None) {
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = signed_client(node, dir);
let client = NodeClient::new(node, None);
let info: Value = client
.get_authed("/")
.get("/")
.await?
.json()
.await
@@ -95,13 +65,13 @@ async fn resolve_repo(
}
}
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
async fn cmd_list(repo: String, node: String) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node).await?;
let client = signed_client(&node, dir.as_deref());
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/certs");
let resp: Value = client
.get_authed(&path)
.get(&path)
.await?
.json()
.await
@@ -126,23 +96,16 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
Ok(())
}
async fn cmd_show(
repo: String,
id: String,
node: String,
dir: Option<PathBuf>,
require_valid: bool,
expect_node: Option<String>,
) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node).await?;
let client = signed_client(&node, dir.as_deref());
let client = NodeClient::new(&node, None);
let id = resolve_cert_id(&client, &owner, &name, &id).await?;
// Fetch the certificate
let path = format!("/api/v1/repos/{owner}/{name}/certs/{id}");
let resp = client
.get_authed(&path)
.get(&path)
.await?
.error_for_status()
.context("certificate not found")?;
@@ -167,132 +130,47 @@ async fn cmd_show(
println!(" Signature: {signature}");
println!();
// Verify the Ed25519 signature: rebuild the exact canonical payload the
// node signed (see gitlawb-node/src/cert.rs::issue_ref_certificate) and
// check it against the public key embedded in the certificate's node DID.
// This proves the cert is internally authentic — signed by the key it
// names; the node-DID comparison below covers *which* node that is.
let repo_id = cert["repo_id"].as_str().unwrap_or("");
let verdict = verify_signature(
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
);
// Reconstruct the signing payload and verify
// Fetch the node's current public key to verify
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let current_node_did = info["did"].as_str().unwrap_or("");
println!("Signature verification:");
match &verdict {
Ok(()) => {
println!(
" VALID — Ed25519 signature verified against the key the certificate names ({node_did})"
);
}
Err(reason) => {
println!(" INVALID — {reason}");
}
println!(" Signing payload would be:");
println!(" {{\"repo_id\": ..., \"ref\": \"{ref_name}\", \"old\": \"{old_sha}\",");
println!(" \"new\": \"{new_sha}\", \"pusher\": \"{pusher}\",");
println!(" \"node\": \"{node_did}\", \"ts\": \"{issued_at}\"}}");
println!();
if current_node_did == node_did {
println!(" Node DID matches current node. Signature is an Ed25519/base64url value.");
println!(" To verify offline, use the node's Ed25519 public key derived from:");
println!(" did:key → {node_did}");
} else {
println!(" WARNING: Certificate node DID ({node_did}) does not match");
println!(" current node DID ({current_node_did}).");
println!(" This certificate was issued by a different node.");
}
// Contextual only — the verdict above stands on its own, so a node-info
// hiccup here must not turn a successfully displayed certificate into an
// error exit.
let current_node_did = match client.get("/").await {
Ok(resp) => resp
.json::<Value>()
.await
.ok()
.and_then(|info| info["did"].as_str().map(str::to_string)),
Err(_) => None,
};
match current_node_did.as_deref() {
Some(current) if current == node_did => {
println!(" Issuing node DID matches the node being queried.");
}
Some(current) => {
println!(" WARNING: Certificate node DID ({node_did}) does not match");
println!(" current node DID ({current}).");
println!(" This certificate was issued by a different node.");
}
None => {
println!(" NOTE: could not fetch current node info — skipping node-DID comparison.");
}
}
if require_valid {
if let Err(reason) = verdict {
anyhow::bail!("certificate signature did not verify: {reason}");
}
// A valid signature proves internal consistency only: the payload was
// signed by whatever key the certificate itself names. A hostile
// source can mint a keypair, put its DID in node_did, and self-sign.
// --verify therefore also anchors the issuer to a trusted DID:
// --expect-node when given, else the DID of the node being queried.
let expected = expect_node.as_deref().or(current_node_did.as_deref());
match expected {
Some(expected) if expected == node_did => {}
Some(expected) => anyhow::bail!(
"certificate is signed by {node_did}, but the expected issuer is {expected} — \
a valid signature alone proves internal consistency, not a trusted issuer"
),
None => anyhow::bail!(
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
),
}
}
println!();
println!(" Signature (base64url): {signature}");
Ok(())
}
/// Rebuild the node's canonical signing payload (field order must match
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
/// certificate's Ed25519 signature against the key embedded in `node_did`.
#[allow(clippy::too_many_arguments)]
fn verify_signature(
repo_id: &str,
ref_name: &str,
old_sha: &str,
new_sha: &str,
pusher: &str,
node_did: &str,
issued_at: &str,
signature_b64: &str,
) -> std::result::Result<(), String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use std::str::FromStr;
let payload = serde_json::json!({
"repo_id": repo_id,
"ref": ref_name,
"old": old_sha,
"new": new_sha,
"pusher": pusher,
"node": node_did,
"ts": issued_at,
});
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?;
let did =
gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?;
let verifying_key = did
.to_verifying_key()
.map_err(|e| format!("cannot derive a public key from {node_did}: {e}"))?;
let sig_vec = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|e| format!("signature is not valid base64url: {e}"))?;
let sig_bytes: [u8; 64] = sig_vec
.try_into()
.map_err(|_| "signature is not 64 bytes".to_string())?;
gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes)
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
}
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
if id.len() >= 36 {
return Ok(id.to_string());
}
let path = format!("/api/v1/repos/{owner}/{name}/certs?prefix={id}");
let path = format!("/api/v1/repos/{owner}/{name}/certs");
let resp: Value = client
.get_authed(&path)
.get(&path)
.await?
.error_for_status()
.context("failed to list certificates")?
@@ -304,6 +182,7 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
let matches: Vec<String> = certs
.iter()
.filter_map(|cert| cert["id"].as_str())
.filter(|cert_id| cert_id.starts_with(id))
.map(ToString::to_string)
.collect();
@@ -313,88 +192,3 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
_ => anyhow::bail!("certificate prefix {id} matches multiple certificates"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Pins gl's payload reconstruction to the frozen canonical byte form the
/// node signs (default serde_json maps = alphabetically ordered keys). If
/// serialization drifts — a field added, or a preserve_order feature
/// landing anywhere in the workspace (feature unification flips every
/// crate at once) — this literal stops matching and the test fails,
/// instead of every real certificate silently rendering INVALID.
#[test]
fn payload_serialization_matches_frozen_canonical_form() {
let payload = serde_json::json!({
"repo_id": "repo-1",
"ref": "refs/heads/main",
"old": "oldsha",
"new": "newsha",
"pusher": "did:key:z6MkPusher",
"node": "did:key:z6MkNode",
"ts": "2026-07-22T00:00:00+00:00",
});
let frozen = concat!(
r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#,
r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#,
r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#,
);
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
}
/// Signing exactly as the node does must round-trip through
/// verify_signature; any field tampering must fail it.
#[test]
fn verify_signature_round_trip_and_tamper() {
let kp = gitlawb_core::identity::Keypair::generate();
let node_did = kp.did().as_str().to_string();
let payload = serde_json::json!({
"repo_id": "repo-1",
"ref": "refs/heads/main",
"old": "0".repeat(40),
"new": "a".repeat(40),
"pusher": "did:key:z6MkPusher",
"node": node_did,
"ts": "2026-07-22T00:00:00+00:00",
});
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
let ok = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
&sig,
);
assert!(ok.is_ok(), "expected valid signature, got: {ok:?}");
let tampered = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"b".repeat(40), // new_sha changed after signing
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
&sig,
);
assert!(tampered.is_err(), "tampered payload must not verify");
let garbage = verify_signature(
"repo-1",
"refs/heads/main",
&"0".repeat(40),
&"a".repeat(40),
"did:key:z6MkPusher",
&node_did,
"2026-07-22T00:00:00+00:00",
"not-base64url!!!",
);
assert!(garbage.is_err(), "malformed signature must not verify");
}
}
+11 -276
View File
@@ -16,7 +16,6 @@ use std::path::PathBuf;
use crate::http::NodeClient;
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
const GITHUB_API_BASE: &str = "https://api.github.com";
#[derive(Args)]
pub struct DoctorArgs {
@@ -142,20 +141,18 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
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(
Ok(v) if !v.is_empty() && !v.contains("127.0.0.1") && !v.contains("localhost") => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => {
checks.push(Check::fail(
"GITLAWB_NODE",
format!(
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
"set to local address ({v}) — git push/clone will fail against remote nodes"
),
"export GITLAWB_NODE=https://node.gitlawb.com",
));
}
Ok(v) if !v.is_empty() => {
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
}
_ => {
checks.push(Check::fail(
"GITLAWB_NODE",
@@ -177,19 +174,6 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
"node",
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.
let gl_ver = env!("CARGO_PKG_VERSION");
if version != "?" && is_newer(version, gl_ver) {
checks.push(Check::warn(
"gl version",
format!(
"node is v{version} but gl is v{gl_ver} — your CLI may be missing \
features (signing / iCaptcha) this node requires"
),
"upgrade gl: curl -sSf https://gitlawb.com/install.sh | sh",
));
}
}
Ok(resp) => {
checks.push(Check::fail(
@@ -207,31 +191,6 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
}
// ── 4b. iCaptcha capability ───────────────────────────────────────────
// Gated writes (repo create / register / fork) auto-solve a challenge at the
// iCaptcha service; check it's reachable so the failure mode is obvious.
let icaptcha_url = std::env::var("GITLAWB_ICAPTCHA_URL")
.unwrap_or_else(|_| icaptcha_client::DEFAULT_URL.to_string());
match NodeClient::new(&icaptcha_url, None).get("/v1/pubkey").await {
Ok(resp) if resp.status().is_success() => {
checks.push(Check::pass("iCaptcha", format!("{icaptcha_url} reachable")));
}
Ok(resp) => {
checks.push(Check::warn(
"iCaptcha",
format!("{icaptcha_url} returned HTTP {}", resp.status()),
"gated writes (repo create / register) may fail until iCaptcha is reachable",
));
}
Err(e) => {
checks.push(Check::warn(
"iCaptcha",
format!("{icaptcha_url} unreachable: {e}"),
"set GITLAWB_ICAPTCHA_URL or check connectivity — repo create / register solve a challenge there",
));
}
}
// ── 5. git-remote-gitlawb helper ──────────────────────────────────────
// Use PATH lookup only — invoking the binary directly triggers git internals
// that error with "fatal: not a git repository" outside of a git repo.
@@ -245,15 +204,6 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
));
}
// ── 5b. shell alias shadowing ─────────────────────────────────────────
// oh-my-zsh's default `git` plugin aliases gl='git pull', which silently
// shadows this binary in every interactive zsh — the classic symptom is
// `gl` printing "fatal: not a git repository". Aliases beat PATH, so no
// install method can fix it; the user's rc file has to unalias.
if let Some(check) = check_shell_alias_shadowing(dirs::home_dir()) {
checks.push(check);
}
// ── 6. git ────────────────────────────────────────────────────────────
match std::process::Command::new("git")
.arg("--version")
@@ -275,7 +225,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── 7. Version up to date ─────────────────────────────────────────────
let current = env!("CARGO_PKG_VERSION");
checks.push(check_version(current, GITHUB_API_BASE).await);
checks.push(check_version(current).await);
// ── Render ────────────────────────────────────────────────────────────
for check in &checks {
@@ -320,83 +270,6 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
}
/// 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
/// relative to oh-my-zsh loading is not modeled (an unalias placed before the
/// plugin loads would be ineffective); acceptable slack for a warning check.
fn rc_unaliases_gl(rc: &str) -> bool {
rc.lines().any(|line| {
let line = line.trim_start();
if line.starts_with('#') {
return false;
}
line.split([';', '&', '|']).any(|cmd| {
// A trailing comment must not count: `unalias foo # gl`
let cmd = cmd.split('#').next().unwrap_or("");
let mut tokens = cmd.split_whitespace();
tokens.next() == Some("unalias") && tokens.any(|t| t == "gl")
})
})
}
/// True when the URL's host is exactly a loopback address. Substring checks
/// misclassify hosts like `localhost.example` or URLs with "localhost" in the
/// path, so parse and compare the actual host.
fn is_loopback_url(value: &str) -> bool {
reqwest::Url::parse(value)
.ok()
.and_then(|u| {
u.host_str()
.map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]" || h == "::1")
})
.unwrap_or(false)
}
/// Detect interactive-shell setups where an alias shadows `gl` (aliases beat
/// PATH, so no install method can fix this). Best-effort heuristic over
/// ~/.zshrc: an explicit `alias gl=`, or oh-my-zsh's `git` plugin (which
/// ships gl='git pull'). Returns None when nothing suspicious is found or the
/// rc file already contains an `unalias gl`.
fn check_shell_alias_shadowing(home: Option<PathBuf>) -> Option<Check> {
let home = home?;
let rc = std::fs::read_to_string(home.join(".zshrc")).ok()?;
if rc_unaliases_gl(&rc) {
return None;
}
let explicit_alias = rc.lines().any(|l| l.trim_start().starts_with("alias gl="));
// Single-line `plugins=(git ...)` is the overwhelmingly common form; a
// multi-line plugins array slips past this heuristic, which is acceptable
// for a warning-level check.
let omz_git_plugin = home.join(".oh-my-zsh/plugins/git").exists()
&& rc.lines().any(|l| {
let l = l.trim_start();
l.starts_with("plugins=")
&& l.trim_start_matches("plugins=(")
.trim_end_matches(')')
.split_whitespace()
.any(|p| p == "git")
});
if explicit_alias || omz_git_plugin {
let source = if explicit_alias {
"~/.zshrc defines `alias gl=`"
} else {
"oh-my-zsh's git plugin aliases gl='git pull'"
};
Some(Check::warn(
"shell alias",
format!(
"{source} — interactive shells run that instead of this binary \
(symptom: `gl` prints \"fatal: not a git repository\")"
),
"echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc (must come after oh-my-zsh loads)",
))
} else {
None
}
}
fn which_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
@@ -408,9 +281,8 @@ fn which_in_path(name: &str) -> bool {
.unwrap_or(false)
}
/// Fetch the latest release tag from Gitlawb/node (the actual release repo — see install.sh's
/// `GITLAWB_RELEASE_REPO` default) and compare to current version.
async fn check_version(current: &'static str, github_api_base: &str) -> Check {
/// Fetch the latest release tag from gitlawb/releases and compare to current version.
async fn check_version(current: &'static str) -> Check {
let client = match reqwest::Client::builder()
.user_agent(format!("gl/{current}"))
.timeout(std::time::Duration::from_secs(5))
@@ -421,9 +293,7 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
};
let resp = match client
.get(format!(
"{github_api_base}/repos/Gitlawb/node/releases/latest"
))
.get("https://api.github.com/repos/gitlawb/releases/releases/latest")
.send()
.await
{
@@ -431,13 +301,6 @@ async fn check_version(current: &'static str, github_api_base: &str) -> Check {
Err(_) => return Check::pass("version", format!("v{current} (offline — could not check)")),
};
if !resp.status().is_success() {
return Check::pass(
"version",
format!("v{current} (GitHub API returned HTTP {})", resp.status()),
);
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Check::pass("version", format!("v{current} (invalid response)")),
@@ -481,88 +344,6 @@ fn is_newer(latest: &str, current: &str) -> bool {
mod tests {
use super::*;
fn fake_home(zshrc: Option<&str>, with_omz_git: bool) -> tempfile::TempDir {
let home = tempfile::TempDir::new().unwrap();
if let Some(rc) = zshrc {
std::fs::write(home.path().join(".zshrc"), rc).unwrap();
}
if with_omz_git {
std::fs::create_dir_all(home.path().join(".oh-my-zsh/plugins/git")).unwrap();
}
home
}
#[test]
fn alias_shadowing_flags_omz_git_plugin() {
let home = fake_home(Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\n"), true);
let check = check_shell_alias_shadowing(Some(home.path().to_path_buf()));
assert!(check.is_some(), "omz git plugin without unalias must warn");
}
#[test]
fn alias_shadowing_flags_explicit_alias() {
let home = fake_home(Some("alias gl='git pull'\n"), false);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some());
}
#[test]
fn alias_shadowing_silent_when_unaliased() {
let home = fake_home(
Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\nunalias gl 2>/dev/null\n"),
true,
);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
}
#[test]
fn alias_shadowing_ignores_fake_unaliases() {
// None of these actually free `gl` — the warning must survive them.
for rc in [
"plugins=(git)\n# unalias gl\n", // commented out
"plugins=(git)\nunalias global\n", // longer word
"plugins=(git)\nunalias foo # gl\n", // gl only in a comment
"plugins=(git)\necho unalias-gl-later\n", // not an unalias command
] {
let home = fake_home(Some(rc), true);
assert!(
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some(),
"must still warn for rc: {rc:?}"
);
}
// Real unalias forms that do free it — all must silence the warning.
for rc in [
"plugins=(git)\nunalias gl\n",
"plugins=(git)\nunalias gl 2>/dev/null\n",
"plugins=(git)\ntrue; unalias gl\n",
"plugins=(git)\nunalias glog gl gst\n",
] {
let home = fake_home(Some(rc), true);
assert!(
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none(),
"must be silent for rc: {rc:?}"
);
}
}
#[test]
fn loopback_url_detection_is_host_exact() {
assert!(is_loopback_url("http://127.0.0.1:7545"));
assert!(is_loopback_url("http://localhost:7545"));
assert!(is_loopback_url("http://[::1]:7545"));
assert!(!is_loopback_url("https://localhost.example"));
assert!(!is_loopback_url("https://node.gitlawb.com/localhost"));
assert!(!is_loopback_url("not a url"));
}
#[test]
fn alias_shadowing_silent_without_signals() {
let home = fake_home(Some("plugins=(z)\nexport EDITOR=vim\n"), false);
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
let no_rc = fake_home(None, false);
assert!(check_shell_alias_shadowing(Some(no_rc.path().to_path_buf())).is_none());
assert!(check_shell_alias_shadowing(None).is_none());
}
#[test]
fn test_is_newer_minor_bump() {
assert!(is_newer("0.2.0", "0.1.0"));
@@ -598,50 +379,4 @@ mod tests {
fn test_which_in_path_missing_binary() {
assert!(!which_in_path("this-binary-does-not-exist-gl-test-12345"));
}
#[tokio::test]
async fn test_check_version_queries_gitlawb_node_releases() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name":"v9.9.9"}"#)
.create_async()
.await;
let check = check_version("0.1.0", &server.url()).await;
assert!(matches!(check.state, CheckState::Warn));
assert!(check.detail.contains("v9.9.9"));
}
#[tokio::test]
async fn test_check_version_up_to_date() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name":"v0.1.0"}"#)
.create_async()
.await;
let check = check_version("0.1.0", &server.url()).await;
assert!(matches!(check.state, CheckState::Ok));
assert!(check.detail.contains("up to date"));
}
#[tokio::test]
async fn test_check_version_http_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/repos/Gitlawb/node/releases/latest")
.with_status(403)
.create_async()
.await;
let check = check_version("0.1.0", &server.url()).await;
assert!(matches!(check.state, CheckState::Ok));
assert!(check.detail.contains("GitHub API returned HTTP 403"));
}
}
+35 -511
View File
@@ -1,19 +1,8 @@
//! Signed HTTP client for gitlawb API calls (async).
//!
//! Writes are signed with RFC 9421 HTTP Signatures. When the node gates a write
//! behind iCaptcha (HTTP 403 `icaptcha_proof_required`, advertised via the
//! `x-icaptcha-url` / `x-icaptcha-level` headers), the client transparently
//! solves the challenge and retries the same signed request with the
//! `x-icaptcha-proof` header — see `crates/icaptcha-client`.
use anyhow::{Context, Result};
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use icaptcha_client::IcaptchaCfg;
/// Max times we'll fetch a fresh proof and retry a 403-iCaptcha response
/// (absorbs proof expiry / first-seen replay).
const MAX_ICAPTCHA_RETRIES: usize = 2;
pub struct NodeClient {
inner: reqwest::Client,
@@ -45,15 +34,6 @@ impl NodeClient {
.with_context(|| format!("GET {url}"))
}
/// GET that signs when a keypair is available; falls back to unsigned for public repos.
pub async fn get_authed(&self, path: &str) -> Result<reqwest::Response> {
if self.keypair.is_some() {
self.get_signed(path).await
} else {
self.get(path).await
}
}
/// GET with RFC 9421 HTTP Signature auth, for owner-only read endpoints.
/// Signs over the empty body (same shape the node verifies for signed reads).
pub async fn get_signed(&self, path: &str) -> Result<reqwest::Response> {
@@ -72,519 +52,63 @@ impl NodeClient {
req.send().await.with_context(|| format!("GET {url}"))
}
/// GET that signs when an identity keypair is present and falls back to an
/// anonymous GET otherwise — for read-visibility endpoints, where a public
/// repo is readable anonymously but a private repo requires the owner/reader
/// to be authenticated. Mirrors the conditional signing of post/put/delete.
pub async fn get_maybe_signed(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self.inner.get(&url);
if let Some(kp) = &self.keypair {
let signed = sign_request(kp, "GET", path, b"");
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
}
req.send().await.with_context(|| format!("GET {url}"))
}
/// POST with JSON body + RFC 9421 signing + transparent iCaptcha solve/retry.
/// POST with JSON body + RFC 9421 HTTP Signature auth.
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("POST", path, body).await
}
/// PUT with RFC 9421 signing + transparent iCaptcha solve/retry.
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("PUT", path, body).await
}
/// DELETE with RFC 9421 signing + transparent iCaptcha solve/retry.
pub async fn delete(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("DELETE", path, body).await
}
/// Sign + send a write. On a 403 iCaptcha challenge (detected via the
/// `x-icaptcha-*` headers) solve it and retry the same signed request with
/// the proof header, up to [`MAX_ICAPTCHA_RETRIES`]. Emits an actionable
/// hint on a 401 "not an agent" (the old-CLI / unregistered failure mode).
async fn send_signed(
&self,
method: &str,
path: &str,
body: &[u8],
) -> Result<reqwest::Response> {
let mut proof: Option<String> = None;
let mut attempts = 0;
loop {
let resp = self.send_once(method, path, body, proof.as_deref()).await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED
&& resp
.headers()
.get("x-gitlawb-error")
.and_then(|v| v.to_str().ok())
== Some("human_detected")
{
eprintln!(
"note: this node requires signed requests (RFC 9421). If writes keep \
failing, your `gl` may be too old — upgrade it — or you're not registered: \
run `gl register`."
);
}
if status == reqwest::StatusCode::FORBIDDEN && attempts < MAX_ICAPTCHA_RETRIES {
if let Some(cfg) = self.icaptcha_cfg(resp.headers())? {
attempts += 1;
proof = Some(obtain_proof(cfg).await?);
continue;
}
}
return Ok(resp);
}
}
/// Build, sign, and send one request, optionally attaching a proof header.
async fn send_once(
&self,
method: &str,
path: &str,
body: &[u8],
proof: Option<&str>,
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self
.inner
.request(method.parse().expect("valid HTTP method"), &url)
.post(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
if let Some(kp) = &self.keypair {
let signed = sign_request(kp, method, path, body);
let signed = sign_request(kp, "POST", path, body);
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
}
if let Some(p) = proof {
req = req.header(icaptcha_client::PROOF_HEADER, p);
}
req.send().await.with_context(|| format!("{method} {url}"))
req.send().await.with_context(|| format!("POST {url}"))
}
/// If `headers` describe an iCaptcha 403, build the solve config (binding the
/// proof's `sub` to our DID). Returns `None` for a non-iCaptcha 403.
fn icaptcha_cfg(&self, headers: &reqwest::header::HeaderMap) -> Result<Option<IcaptchaCfg>> {
let url = headers.get("x-icaptcha-url").and_then(|v| v.to_str().ok());
let level = headers
.get("x-icaptcha-level")
.and_then(|v| v.to_str().ok());
if url.is_none() && level.is_none() {
return Ok(None); // not an iCaptcha challenge
/// PUT with RFC 9421 HTTP Signature auth (idempotent write).
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self
.inner
.put(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
if let Some(kp) = &self.keypair {
let signed = sign_request(kp, "PUT", path, body);
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
}
let kp = self
.keypair
.as_ref()
.context("iCaptcha challenge requires an identity keypair (run `gl identity new`)")?;
Ok(Some(IcaptchaCfg::new(
kp.did().to_string(),
url.map(str::to_string),
level.and_then(|l| l.parse().ok()),
)))
}
}
/// Run the (blocking) iCaptcha solve loop off the async runtime.
async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
tokio::task::spawn_blocking(move || icaptcha_client::obtain_proof(&cfg, None))
.await
.context("iCaptcha solver task panicked")?
}
#[cfg(test)]
mod tests {
use super::*;
use gitlawb_core::identity::Keypair;
use mockito::Server;
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes the two integration tests that touch the process-global
/// `GITLAWB_ICAPTCHA_URL` / `GITLAWB_ICAPTCHA_INSECURE` env vars so they
/// never race.
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
fn test_keypair() -> Keypair {
Keypair::generate()
}
fn headers_from_pairs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
let mut h = reqwest::header::HeaderMap::new();
for (k, v) in pairs {
h.insert(
k.parse::<reqwest::header::HeaderName>().unwrap(),
v.parse::<reqwest::header::HeaderValue>().unwrap(),
);
}
h
}
// ── icaptcha_cfg ────────────────────────────────────────────────────
#[test]
fn icaptcha_cfg_returns_some_when_both_headers_present() {
let kp = test_keypair();
let client = NodeClient::new("http://localhost", Some(kp.clone()));
let headers = headers_from_pairs(&[
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
("x-icaptcha-level", "3"),
]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.did, kp.did().to_string());
assert_eq!(cfg.level, 3);
}
#[test]
fn icaptcha_cfg_defaults_level_when_only_url_present() {
let kp = test_keypair();
let client = NodeClient::new("http://localhost", Some(kp));
let headers = headers_from_pairs(&[("x-icaptcha-url", "https://icaptcha.gitlawb.com")]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
}
#[test]
fn icaptcha_cfg_defaults_url_when_only_level_present() {
let kp = test_keypair();
let client = NodeClient::new("http://localhost", Some(kp));
let headers = headers_from_pairs(&[("x-icaptcha-level", "5")]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.level, 5);
req.send().await.with_context(|| format!("PUT {url}"))
}
#[test]
fn icaptcha_cfg_returns_none_without_icaptcha_headers() {
let client = NodeClient::new("http://localhost", Some(test_keypair()));
let headers = reqwest::header::HeaderMap::new();
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
}
#[test]
fn icaptcha_cfg_returns_none_with_unrelated_headers() {
let client = NodeClient::new("http://localhost", Some(test_keypair()));
let headers = headers_from_pairs(&[("content-type", "application/json")]);
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
}
#[test]
fn icaptcha_cfg_errors_when_no_keypair() {
let client = NodeClient::new("http://localhost", None);
let headers = headers_from_pairs(&[("x-icaptcha-level", "3")]);
let err = client.icaptcha_cfg(&headers).unwrap_err();
assert!(err.to_string().contains("identity keypair"));
}
#[test]
fn icaptcha_cfg_ignores_unparseable_level() {
let client = NodeClient::new("http://localhost", Some(test_keypair()));
let headers = headers_from_pairs(&[
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
("x-icaptcha-level", "not-a-number"),
]);
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
}
// ── send_once ───────────────────────────────────────────────────────
#[tokio::test]
async fn send_once_attaches_proof_header_when_provided() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("x-icaptcha-proof", "test.proof.token")
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.send_once("POST", "/api/test", b"{}", Some("test.proof.token"))
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
#[tokio::test]
async fn send_once_omits_proof_header_when_not_provided() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("x-icaptcha-proof", mockito::Matcher::Missing)
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.send_once("POST", "/api/test", b"{}", None)
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
#[tokio::test]
async fn send_once_signs_request_when_keypair_present() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("Signature", mockito::Matcher::Any)
.match_header("Signature-Input", mockito::Matcher::Any)
.match_header("Content-Digest", mockito::Matcher::Any)
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_once("POST", "/api/test", b"{}", None)
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
/// DELETE with RFC 9421 HTTP Signature auth.
pub async fn delete(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self
.inner
.delete(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
#[tokio::test]
async fn send_once_does_not_sign_when_no_keypair() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/test")
.match_header("Signature", mockito::Matcher::Missing)
.match_header("Signature-Input", mockito::Matcher::Missing)
.match_header("Content-Digest", mockito::Matcher::Missing)
.with_status(200)
.with_body("ok")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.send_once("POST", "/api/test", b"{}", None)
.await
.unwrap();
assert_eq!(resp.status(), 200);
m.assert();
}
// ── send_signed ─────────────────────────────────────────────────────
#[tokio::test]
async fn send_signed_returns_non_icaptcha_403_without_retry() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"forbidden"}"#)
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 403);
m.assert();
}
#[tokio::test]
async fn send_signed_returns_first_response_on_success() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"created"}"#)
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 201);
m.assert();
}
#[tokio::test]
async fn send_signed_handles_405_not_icaptcha() {
let mut server = Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.with_status(405)
.with_body(r#"{"error":"method not allowed"}"#)
.create_async()
.await;
let client = NodeClient::new(server.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 405);
m.assert();
}
// ── send_signed iCaptcha retry (full integration) ────────────────────
/// Set GITLAWB_ICAPTCHA_URL and GITLAWB_ICAPTCHA_INSECURE so the iCaptcha
/// client trusts a local mockito HTTP server, restoring any prior values on
/// drop so a test run launched with those variables keeps working.
/// Holds [`ICAPTCHA_ENV_LOCK`] for its lifetime so concurrent tests don't
/// race on the process-global env vars.
struct IcaptchaEnv {
_lock: MutexGuard<'static, ()>,
prev_url: Option<OsString>,
prev_insecure: Option<OsString>,
}
impl IcaptchaEnv {
fn new(url: &str) -> Self {
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev_url = std::env::var_os("GITLAWB_ICAPTCHA_URL");
let prev_insecure = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::set_var("GITLAWB_ICAPTCHA_URL", url);
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
IcaptchaEnv {
_lock: lock,
prev_url,
prev_insecure,
}
if let Some(kp) = &self.keypair {
let signed = sign_request(kp, "DELETE", path, body);
req = req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
}
}
impl Drop for IcaptchaEnv {
fn drop(&mut self) {
match self.prev_url.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
}
match self.prev_insecure.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
}
}
}
/// Set up a mock iCaptcha server that responds to challenge + answer.
/// `hits` sets the expected call count for both endpoints so the test can
/// verify the solve loop was entered the correct number of times.
struct MockIcaptcha {
challenge: mockito::Mock,
answer: mockito::Mock,
_guard: IcaptchaEnv,
url: String,
}
impl MockIcaptcha {
async fn new(server: &mut mockito::ServerGuard, hits: usize) -> Self {
let url = server.url();
let guard = IcaptchaEnv::new(&url);
let challenge = server
.mock("POST", "/v1/challenge")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"challengeId":"c1","type":"arithmetic","difficulty":1,"prompt":"What is 1 + 1?","token":"tk1"}"#,
)
.expect(hits)
.create_async()
.await;
let answer = server
.mock("POST", "/v1/answer")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"passed","proof":"mock.proof"}"#)
.expect(hits)
.create_async()
.await;
Self {
challenge,
answer,
_guard: guard,
url,
}
}
}
#[tokio::test]
async fn send_signed_solves_icaptcha_and_retries_to_success() {
let mut node = Server::new_async().await;
let mut icaptcha = Server::new_async().await;
let ic = MockIcaptcha::new(&mut icaptcha, 1).await;
let n1 = node
.mock("POST", "/api/register")
.with_status(403)
.with_header("content-type", "application/json")
.with_header("x-icaptcha-url", &ic.url)
.with_header("x-icaptcha-level", "3")
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
.expect(1)
.create_async()
.await;
let n2 = node
.mock("POST", "/api/register")
.match_header("x-icaptcha-proof", "mock.proof")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"created"}"#)
.expect(1)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 201);
n1.assert();
n2.assert();
ic.challenge.assert();
ic.answer.assert();
}
#[tokio::test]
async fn send_signed_returns_403_after_icaptcha_retries_exhausted() {
let mut node = Server::new_async().await;
let mut icaptcha = Server::new_async().await;
// MAX_ICAPTCHA_RETRIES = 2, so with every call returning 403 with
// iCaptcha headers the solve loop runs twice (2 challenge + 2 answer).
let ic = MockIcaptcha::new(&mut icaptcha, 2).await;
// The original + 2 retries = 3 node calls before the loop gives up.
let n = node
.mock("POST", "/api/register")
.with_status(403)
.with_header("content-type", "application/json")
.with_header("x-icaptcha-url", &ic.url)
.with_header("x-icaptcha-level", "3")
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
.expect(3)
.create_async()
.await;
let client = NodeClient::new(node.url(), Some(test_keypair()));
let resp = client
.send_signed("POST", "/api/register", b"{}")
.await
.unwrap();
assert_eq!(resp.status(), 403);
n.assert();
ic.challenge.assert();
ic.answer.assert();
req.send().await.with_context(|| format!("DELETE {url}"))
}
}
+4 -52
View File
@@ -38,23 +38,13 @@ pub async fn run(args: InitArgs) -> Result<()> {
let git_dir = cwd.join(".git");
if !git_dir.exists() {
println!("Initializing git repository...");
// -b main: the push flow targets `main`; without it a fresh repo uses
// the user's init.defaultBranch (often `master`). Older git (<2.28)
// lacks the flag, so fall back to a plain init.
let status = std::process::Command::new("git")
.args(["init", "-b", "main"])
.args(["init"])
.current_dir(&cwd)
.status()
.context("failed to run git init")?;
if !status.success() {
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&cwd)
.status()
.context("failed to run git init")?;
if !status.success() {
anyhow::bail!("git init failed");
}
anyhow::bail!("git init failed");
}
} else {
println!("Git repository detected.");
@@ -175,47 +165,9 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
}
// The hint must match the repo's actual state: with no commits yet, a bare
// `git push` fails with "src refspec ... does not match any" — exactly the
// trap a zero-to-push command must not set.
let has_commits = std::process::Command::new("git")
.args(["rev-parse", "--verify", "HEAD"])
.current_dir(&cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
// symbolic-ref succeeds on a branch (including an unborn one) and fails
// on a detached HEAD — where guessing "main" could push the wrong ref.
let branch = std::process::Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&cwd)
.stderr(std::process::Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
println!();
match (branch, has_commits) {
(Some(branch), true) => {
println!("Ready! Push with:");
println!(" git push gitlawb {branch}");
}
(Some(branch), false) => {
println!("Ready! Nothing is committed yet — commit, then push:");
println!(" git add -A && git commit -m \"initial commit\"");
println!(" git push gitlawb {branch}");
}
(None, _) => {
println!("Ready! HEAD is detached — create or switch to a branch, then push:");
println!(" git switch -c main");
println!(" git push gitlawb main");
}
}
println!("Ready! Push with:");
println!(" git push gitlawb main");
Ok(())
}
+9 -137
View File
@@ -3,8 +3,6 @@
//! Communicates with the gitlawb node to list pinned CIDs and retrieve git
//! objects by their content-addressed CID.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::Value;
@@ -23,9 +21,6 @@ pub enum IpfsCmd {
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
/// Retrieve and display a git object from the node by its CIDv1
Get {
@@ -38,24 +33,19 @@ pub enum IpfsCmd {
pub async fn run(args: IpfsArgs) -> Result<()> {
match args.cmd {
IpfsCmd::List { node, dir } => cmd_list(node, dir).await,
IpfsCmd::List { node } => cmd_list(node).await,
IpfsCmd::Get { cid, node } => cmd_get(cid, node).await,
}
}
async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
// #134 gates /api/v1/ipfs/pins behind auth: sign the request with the
// caller's identity. On no identity, propagate load_keypair_from_dir's
// error (it already names `gl identity new`) rather than a bare 401.
let keypair = crate::identity::load_keypair_from_dir(dir.as_deref())?;
let client = NodeClient::new(&node, Some(keypair));
let resp = client.get_signed("/api/v1/ipfs/pins").await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("node returned {status} for pins listing: {body}");
}
let resp: Value = resp.json().await.context("failed to parse pins response")?;
async fn cmd_list(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let resp: Value = client
.get("/api/v1/ipfs/pins")
.await?
.json()
.await
.context("failed to parse pins response")?;
let pins = resp["pins"].as_array().cloned().unwrap_or_default();
let count = resp["count"].as_u64().unwrap_or(pins.len() as u64);
@@ -118,121 +108,3 @@ async fn cmd_get(cid: String, node: String) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Seed a keypair into a temp dir the way `load_keypair_from_dir` expects,
/// then return the dir handle (keeps it alive for the test's duration).
fn seed_keystore() -> 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
}
#[tokio::test]
async fn test_cmd_list_signs_request_and_renders_pins() {
let mut server = mockito::Server::new_async().await;
let keystore = seed_keystore();
// Happy path: signed GET to /api/v1/ipfs/pins carrying the RFC 9421
// signature headers, node returns a populated pins body.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.match_header("content-digest", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"pins":[{"cid":"bafyone","sha256_hex":"abc123","pinned_at":"2026-07-02T12:00:00.123456Z"}],"count":1}"#,
)
.create_async()
.await;
cmd_list(server.url(), Some(keystore.path().to_path_buf()))
.await
.unwrap();
m.assert_async().await;
}
#[tokio::test]
async fn test_cmd_list_empty_pins() {
let mut server = mockito::Server::new_async().await;
let keystore = seed_keystore();
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"pins":[],"count":0}"#)
.create_async()
.await;
cmd_list(server.url(), Some(keystore.path().to_path_buf()))
.await
.unwrap();
m.assert_async().await;
}
#[tokio::test]
async fn test_cmd_list_no_identity_errors_without_request() {
let mut server = mockito::Server::new_async().await;
// Empty keystore dir: no identity.pem present.
let empty = tempfile::TempDir::new().unwrap();
// The endpoint must never be hit when there is no identity.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.expect(0)
.create_async()
.await;
let err = cmd_list(server.url(), Some(empty.path().to_path_buf()))
.await
.expect_err("no identity should be an error");
assert!(
err.to_string().contains("gl identity new")
|| err.to_string().contains("no identity found"),
"error should name `gl identity new`, got: {err}"
);
m.assert_async().await;
}
#[tokio::test]
async fn test_cmd_list_non_success_status_is_error_not_empty() {
let mut server = mockito::Server::new_async().await;
let keystore = seed_keystore();
// A signed request the node rejects (401) must surface as an error,
// not be silently parsed into an empty pin list.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"unauthorized"}"#)
.create_async()
.await;
let err = cmd_list(server.url(), Some(keystore.path().to_path_buf()))
.await
.expect_err("non-2xx status should be an error");
assert!(
err.to_string().contains("401"),
"error should mention the status, got: {err}"
);
m.assert_async().await;
}
}
+6 -10
View File
@@ -10,10 +10,6 @@ use uuid::Uuid;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
fn signed_client(node: &str, dir: Option<&std::path::Path>) -> NodeClient {
NodeClient::new(node, load_keypair_from_dir(dir).ok())
}
#[derive(Args)]
pub struct IssueArgs {
#[command(subcommand)]
@@ -219,10 +215,10 @@ async fn cmd_create(
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = signed_client(&node, dir.as_deref());
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let resp: Value = client
.get_authed(&path)
.get(&path)
.await?
.json()
.await
@@ -258,10 +254,10 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = signed_client(&node, dir.as_deref());
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/issues/{id}");
let resp = client
.get_authed(&path)
.get(&path)
.await
.context("failed to connect to node")?;
let status = resp.status();
@@ -351,10 +347,10 @@ async fn cmd_issue_comments(
dir: Option<PathBuf>,
) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = signed_client(&node, dir.as_deref());
let client = NodeClient::new(&node, None);
let resp: Value = client
.get_authed(&format!(
.get(&format!(
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
))
.await?
+9 -210
View File
@@ -932,33 +932,13 @@ async fn call_tool(
"webhook_list" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
// Owner-gated route: an explicit `owner` arg wins, otherwise default to
// the signing keypair's short DID (NOT the node DID). The request is
// signed by this keypair, so the path owner must match it or the node
// returns 403/404.
let owner = if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
o.to_string()
} else {
let kp = keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
};
// Owner-gated route: must be signed (get_signed), not a plain get().
let resp = client
.get_signed(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.json()
.await?;
// Check the HTTP status before deserializing: a 401/403/404 JSON error
// body (missing identity, wrong owner, private/deleted repo) must fail
// the tool call, not be returned as a successful result.
let status = resp.status();
let body: Value = resp.json().await?;
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("webhook_list failed ({status}): {msg}");
}
Ok(serde_json::to_string_pretty(&body)?)
Ok(serde_json::to_string_pretty(&resp)?)
}
"webhook_delete" => {
@@ -990,14 +970,14 @@ async fn call_tool(
}
u
};
let resp: Value = client.get_authed(&url).await?.json().await?;
let resp: Value = client.get(&url).await?.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"bounty_show" => {
let id = args["id"].as_str().context("missing 'id'")?;
let resp: Value = client
.get_authed(&format!("/api/v1/bounties/{id}"))
.get(&format!("/api/v1/bounties/{id}"))
.await?
.json()
.await?;
@@ -1185,7 +1165,7 @@ async fn call_tool(
(owner, repo.to_string())
};
let resp: Value = client
.get_authed(&format!("/api/v1/repos/{owner}/{name}/issues"))
.get(&format!("/api/v1/repos/{owner}/{name}/issues"))
.await?
.json()
.await?;
@@ -1845,187 +1825,6 @@ mod tests {
assert_eq!(parsed["body"], "looks good");
}
#[tokio::test]
async fn test_webhook_list_via_mcp_signs_the_request() {
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();
// /hooks is owner-gated, so the MCP webhook_list tool must send a SIGNED
// request (get_signed). Requiring the header catches a regression to get().
// Passing `owner` in args makes resolve_owner skip the node-root lookup.
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[],"count":0}"#)
.create_async()
.await;
let result = call_tool(
"webhook_list",
json!({"owner": "alice", "repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
assert!(result.contains("webhooks"), "got: {result}");
}
#[tokio::test]
async fn test_webhook_list_default_owner_is_keypair_not_node_did() {
// When `owner` is omitted, the owner segment must come from the signing
// keypair's short DID, NOT the node root DID. The "/" mock returns a
// different DID; the /hooks mock only matches the keypair's short DID,
// so a regression to the node DID would leave the request unmatched.
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();
let _root = server
.mock("GET", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"did":"did:key:z6MkNodeRootDid"}"#)
.create_async()
.await;
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"/api/v1/repos/{short}/myrepo/hooks$")),
)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[],"count":0}"#)
.expect(1)
.create_async()
.await;
let result = call_tool(
"webhook_list",
json!({"repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
assert!(result.contains("webhooks"), "got: {result}");
_m.assert_async().await;
}
#[tokio::test]
async fn test_webhook_list_explicit_owner_overrides_keypair() {
// An explicit `owner` arg must win over the keypair default and the node
// DID. The path must use the supplied owner; "/" must not even be hit.
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();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/repos/someoneelse/myrepo/hooks$".to_string()),
)
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[],"count":0}"#)
.expect(1)
.create_async()
.await;
let result = call_tool(
"webhook_list",
json!({"repo": "myrepo", "owner": "someoneelse"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
assert!(result.contains("webhooks"), "got: {result}");
_m.assert_async().await;
}
#[tokio::test]
async fn test_webhook_list_non_success_status_errors() {
// A 403 (or any non-2xx) JSON error body for a missing identity, wrong
// owner, or private/deleted repo must fail the tool call, NOT be returned
// as a successful result. Mirrors the `gl webhook list` status check.
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();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"only the repo owner can perform this action"}"#)
.create_async()
.await;
let err = call_tool(
"webhook_list",
json!({"owner": "alice", "repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.expect_err("non-2xx must error, not return the error body as success");
let msg = err.to_string();
assert!(msg.contains("webhook_list failed (403"), "got: {msg}");
assert!(
msg.contains("only the repo owner"),
"must surface the node message, got: {msg}"
);
}
#[tokio::test]
async fn test_webhook_list_without_keypair_errors() {
// No identity loaded and no explicit owner → the tool must error rather
// than fall back to the node DID and issue an unsigned, mis-scoped GET.
let server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap(); // empty: no identity.pem
let err = call_tool(
"webhook_list",
json!({"repo": "myrepo"}),
&server.url(),
Some(dir.path()),
)
.await
.expect_err("must error without an identity");
assert!(err.to_string().contains("no identity found"), "got: {err}");
}
#[test]
fn test_tool_count_is_42() {
let tools = tool_definitions();
+12 -379
View File
@@ -2,12 +2,10 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use gitlawb_core::identity::Keypair;
use serde_json::Value;
use std::path::PathBuf;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
use crate::node_stake;
#[derive(Args)]
@@ -22,9 +20,6 @@ pub enum NodeCmd {
Status {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
/// Check trust score for a DID
Trust {
@@ -126,7 +121,7 @@ pub enum NodeCmd {
pub async fn run(args: NodeArgs) -> Result<()> {
match args.cmd {
NodeCmd::Status { node, dir } => cmd_status(node, dir).await,
NodeCmd::Status { node } => cmd_status(node).await,
NodeCmd::Trust { did, node } => cmd_trust(did, node).await,
NodeCmd::Resolve { did, node } => cmd_resolve(did, node).await,
NodeCmd::Register {
@@ -182,101 +177,7 @@ async fn try_get_json(client: &NodeClient, path: &str) -> Option<Value> {
resp.json::<Value>().await.ok()
}
/// Outcome of fetching the IPFS pins panel for `gl node status`.
///
/// #134 gates `/api/v1/ipfs/pins` behind auth, so this panel signs its request
/// when an identity is available and otherwise reports that the caller must
/// sign in. A pins failure never aborts the dashboard.
#[derive(Debug)]
enum PinsPanel {
/// Signed read succeeded and returned pins (carries the resolved count).
Pins(u64),
/// Signed read succeeded but the node has no pins recorded.
Empty,
/// Signed read was rejected (401/other) or errored.
Unavailable,
/// No identity available; no request was issued.
NeedsIdentity,
/// An explicit `--dir` was given but held no usable identity; carries the dir
/// so the panel can name it instead of the misleading "sign in" prompt.
IdentityError(PathBuf),
}
/// Identity resolved for the signed pins read, decided before the request so the
/// dashboard can distinguish "no identity at all" from "an explicit `--dir` that
/// could not be loaded".
enum PinsAuth {
/// A usable keypair; the read will be signed.
Keyed(Keypair),
/// No identity requested and none in the default keystore.
Anonymous,
/// An explicit `--dir` was given but held no usable identity (carries the dir).
DirUnusable(PathBuf),
}
/// Resolve the pins-panel identity from an optional `--dir`. Mirrors `gl ipfs
/// list`'s identity selection: an explicit `--dir` that fails to load is reported
/// as `DirUnusable` (so the panel names the bad path) rather than collapsing to
/// the misleading "sign in to view"; a missing default keystore (no `--dir`)
/// degrades quietly to `Anonymous`.
fn resolve_pins_auth(dir: Option<&std::path::Path>) -> PinsAuth {
match load_keypair_from_dir(dir) {
Ok(kp) => PinsAuth::Keyed(kp),
Err(_) => match dir {
Some(d) => PinsAuth::DirUnusable(d.to_path_buf()),
None => PinsAuth::Anonymous,
},
}
}
/// Fetch the pins panel state. With a keypair, signs the `/api/v1/ipfs/pins`
/// read and maps the outcome; with no identity it returns `NeedsIdentity`, and
/// with an unusable explicit `--dir` it returns `IdentityError` — both without
/// issuing a request. Injectable (node URL + resolved auth) so tests drive it
/// with a mock server and never touch the default keystore.
async fn fetch_pins(node: &str, auth: PinsAuth) -> PinsPanel {
let kp = match auth {
PinsAuth::Keyed(kp) => kp,
PinsAuth::Anonymous => return PinsPanel::NeedsIdentity,
PinsAuth::DirUnusable(dir) => return PinsPanel::IdentityError(dir),
};
let client = NodeClient::new(node, Some(kp));
let resp = match client.get_signed("/api/v1/ipfs/pins").await {
Ok(r) => r,
Err(_) => return PinsPanel::Unavailable,
};
if !resp.status().is_success() {
return PinsPanel::Unavailable;
}
let Ok(body) = resp.json::<Value>().await else {
return PinsPanel::Unavailable;
};
let count = body["count"]
.as_u64()
.unwrap_or_else(|| body["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0));
if count == 0 {
PinsPanel::Empty
} else {
PinsPanel::Pins(count)
}
}
/// Render the one-line pins-panel status for the `gl node status` dashboard.
fn pins_status_line(panel: &PinsPanel) -> String {
match panel {
PinsPanel::Pins(count) => format!(" Pinned CIDs: {count}"),
PinsPanel::Empty => " Pinned CIDs: 0".to_string(),
PinsPanel::Unavailable => " IPFS pins: unavailable".to_string(),
PinsPanel::NeedsIdentity => {
" IPFS pins: sign in to view (run `gl identity new`)".to_string()
}
PinsPanel::IdentityError(dir) => {
format!(" IPFS pins: no usable identity in {}", dir.display())
}
}
}
async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_status(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
// ── Fetch node info (required — bail if unreachable) ──────────────────
@@ -293,21 +194,13 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
let version = info["version"].as_str().unwrap_or("unknown");
let network = info["network"].as_str().unwrap_or("unknown");
// The pins panel signs its read (#134 gates /api/v1/ipfs/pins behind auth).
// `--dir` selects the same identity directory as `gl ipfs list` (#146); an
// explicit --dir that can't be loaded is surfaced in the panel rather than
// masquerading as "no identity", while a missing default keystore degrades
// quietly. A pins failure never aborts the dashboard.
let pins_auth = resolve_pins_auth(dir.as_deref());
// ── Fetch remaining endpoints in parallel ─────────────────────────────
// Peers/repos/p2p/events stay anonymous; only pins is signed.
let (peers_val, repos_val, p2p_val, events_val, pins_panel) = tokio::join!(
let (peers_val, repos_val, p2p_val, events_val, pins_val) = tokio::join!(
try_get_json(&client, "/api/v1/peers"),
try_get_json(&client, "/api/v1/repos"),
try_get_json(&client, "/api/v1/p2p/info"),
try_get_json(&client, "/api/v1/events/ref-updates?limit=5"),
fetch_pins(&node, pins_auth),
try_get_json(&client, "/api/v1/ipfs/pins"),
);
// ── Render dashboard ──────────────────────────────────────────────────
@@ -414,7 +307,14 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
// Pins
println!("Pins");
println!("{}", pins_status_line(&pins_panel));
if let Some(ref pins) = pins_val {
let count = pins["count"]
.as_u64()
.unwrap_or_else(|| pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0));
println!(" Pinned CIDs: {count}");
} else {
println!(" IPFS not configured");
}
println!();
Ok(())
@@ -509,270 +409,3 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use gitlawb_core::identity::Keypair;
#[tokio::test]
async fn test_fetch_pins_keyed_happy_signs_and_returns_pins() {
let mut server = mockito::Server::new_async().await;
let kp = Keypair::generate();
// A keyed fetch must sign the request (RFC 9421 headers) and, on a
// populated 200 body, land in the Pins state carrying the pins.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.match_header("content-digest", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"pins":[{"cid":"bafyone","sha256_hex":"abc123","pinned_at":"2026-07-02T12:00:00Z"}],"count":1}"#,
)
.create_async()
.await;
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
match panel {
PinsPanel::Pins(count) => assert_eq!(count, 1),
other => panic!("expected Pins, got {other:?}"),
}
m.assert_async().await;
}
#[tokio::test]
async fn test_fetch_pins_keyed_empty_returns_empty() {
let mut server = mockito::Server::new_async().await;
let kp = Keypair::generate();
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"pins":[],"count":0}"#)
.create_async()
.await;
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
assert!(
matches!(panel, PinsPanel::Empty),
"expected Empty, got {panel:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn test_fetch_pins_keyed_rejected_returns_unavailable() {
let mut server = mockito::Server::new_async().await;
let kp = Keypair::generate();
// Node rejects the signed read (401): the panel must degrade to
// Unavailable without panicking, so cmd_status still completes.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"unauthorized"}"#)
.create_async()
.await;
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
assert!(
matches!(panel, PinsPanel::Unavailable),
"expected Unavailable, got {panel:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn test_fetch_pins_unkeyed_needs_identity_without_request() {
let mut server = mockito::Server::new_async().await;
// With no keypair the endpoint must never be hit.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.expect(0)
.create_async()
.await;
let panel = fetch_pins(&server.url(), PinsAuth::Anonymous).await;
assert!(
matches!(panel, PinsPanel::NeedsIdentity),
"expected NeedsIdentity, got {panel:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn test_fetch_pins_malformed_body_returns_unavailable() {
let mut server = mockito::Server::new_async().await;
let kp = Keypair::generate();
// 2xx but the body is not valid JSON: must degrade to Unavailable,
// never panic.
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.match_header("signature", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("not json{{{")
.create_async()
.await;
let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await;
assert!(
matches!(panel, PinsPanel::Unavailable),
"malformed body -> Unavailable, got {panel:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn test_fetch_pins_transport_error_returns_unavailable() {
// Bind then drop to obtain a definitely-closed port -> connection
// refused -> get_signed Err -> Unavailable (no panic).
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let kp = Keypair::generate();
let panel = fetch_pins(&format!("http://127.0.0.1:{port}"), PinsAuth::Keyed(kp)).await;
assert!(
matches!(panel, PinsPanel::Unavailable),
"transport error -> Unavailable, got {panel:?}"
);
}
#[test]
fn test_pins_status_line_renders_each_state() {
assert_eq!(pins_status_line(&PinsPanel::Pins(3)), " Pinned CIDs: 3");
assert_eq!(pins_status_line(&PinsPanel::Empty), " Pinned CIDs: 0");
assert_eq!(
pins_status_line(&PinsPanel::Unavailable),
" IPFS pins: unavailable"
);
assert_eq!(
pins_status_line(&PinsPanel::NeedsIdentity),
" IPFS pins: sign in to view (run `gl identity new`)"
);
assert_eq!(
pins_status_line(&PinsPanel::IdentityError(PathBuf::from("/tmp/id"))),
" IPFS pins: no usable identity in /tmp/id"
);
}
// Adversarial follow-up to jatmn #146 P2: an explicit `--dir` that holds no
// usable identity must resolve to DirUnusable (which names the path), NOT the
// Anonymous "sign in to view / run `gl identity new`" case — otherwise a user
// who did supply an identity dir is misdirected to create one they may have.
#[test]
fn resolve_pins_auth_reports_explicit_bad_dir_as_unusable() {
let bad = PathBuf::from("/nonexistent/gl-id-xyz");
match resolve_pins_auth(Some(&bad)) {
PinsAuth::DirUnusable(d) => assert_eq!(d, bad),
_ => panic!("explicit unusable --dir must be DirUnusable, not Anonymous"),
}
}
// Mirror of the above for the absent-`--dir` case: `None` must never resolve to
// DirUnusable — it degrades to Anonymous (no default keystore) or Keyed (one
// present). Env-independent: only the None->DirUnusable mapping is guarded, so it
// holds whether or not the runner has a default `~/.gitlawb` identity.
#[test]
fn resolve_pins_auth_none_is_never_dir_unusable() {
assert!(
!matches!(resolve_pins_auth(None), PinsAuth::DirUnusable(_)),
"absent --dir must degrade to Anonymous (or Keyed), never DirUnusable"
);
}
// The success wiring for #146: an explicit --dir with a valid identity must
// load that key (dir -> load_keypair_from_dir -> Keyed), so the pins read is
// signed with the selected identity rather than the default keystore.
#[test]
fn resolve_pins_auth_loads_keyed_identity_from_explicit_dir() {
let dir = tempfile::TempDir::new().unwrap();
let kp = Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let PinsAuth::Keyed(loaded) = resolve_pins_auth(Some(dir.path())) else {
panic!("an explicit --dir with a valid identity must resolve to Keyed");
};
// A bare `Keyed(_)` match would also pass if load fell through to the default
// keystore; assert the loaded identity is the one written to --dir so the pins
// read signs with the selected key, never the default.
assert_eq!(
loaded.did(),
kp.did(),
"must sign with the identity from --dir, not the default keystore"
);
}
#[tokio::test]
async fn test_fetch_pins_dir_unusable_reports_identity_error_without_request() {
let mut server = mockito::Server::new_async().await;
// An unusable explicit --dir must render the identity error and never hit
// the endpoint (no identity to sign with).
let m = server
.mock("GET", "/api/v1/ipfs/pins")
.expect(0)
.create_async()
.await;
let bad = PathBuf::from("/nonexistent/gl-id-xyz");
let panel = fetch_pins(&server.url(), PinsAuth::DirUnusable(bad.clone())).await;
match panel {
PinsPanel::IdentityError(d) => assert_eq!(d, bad),
other => panic!("expected IdentityError, got {other:?}"),
}
m.assert_async().await;
}
// jatmn #146 P2: `gl node status` must accept the same `--dir` identity
// selector as `gl ipfs list`, so a user who selected an identity via --dir can
// authenticate the status pins panel instead of seeing "sign in to view".
#[test]
fn status_accepts_dir_identity_selector() {
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
args: NodeArgs,
}
let cli = TestCli::try_parse_from([
"gl",
"status",
"--node",
"http://example",
"--dir",
"/tmp/id",
])
.expect("`node status` must accept --dir");
let NodeCmd::Status { dir, .. } = cli.args.cmd else {
panic!("expected the Status subcommand");
};
assert_eq!(dir, Some(PathBuf::from("/tmp/id")));
// Absent --dir must stay None so the default keystore path is unchanged.
let cli = TestCli::try_parse_from(["gl", "status"]).expect("status parses without --dir");
let NodeCmd::Status { dir, .. } = cli.args.cmd else {
panic!("expected the Status subcommand");
};
assert_eq!(dir, None);
}
}
+2 -9
View File
@@ -154,12 +154,10 @@ async fn cmd_remove(
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_owner_repo(&repo, &node, dir.as_deref()).await?;
// Read-visibility-gated on the node: public repos list anonymously, private
// repos need the owner's signature. Sign when an identity is available.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let client = NodeClient::new(&node, None);
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.get(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.await
.context("failed to connect to node")?;
@@ -309,9 +307,6 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
// An identity is supplied, so get_maybe_signed must sign the request.
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":[],"count":0}"#)
@@ -343,8 +338,6 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
+63 -471
View File
@@ -146,8 +146,6 @@ pub enum RepoCmd {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
}
@@ -205,21 +203,25 @@ pub async fn run(args: RepoArgs) -> Result<()> {
RepoCmd::ReplicaUnregister { repo, node, dir } => {
cmd_replica_unregister(repo, node, dir).await
}
RepoCmd::Replicas { repo, node, dir } => cmd_replicas(repo, node, dir).await,
RepoCmd::Replicas { repo, node } => cmd_replicas(repo, node).await,
}
}
/// Derive the short DID key segment from a keypair, or fall back to the node's DID.
/// Resolve the owner short-DID for a bare repo name from the LOCAL identity.
/// Never falls back to the node's own DID (that produced bogus "owned by the
/// node" results for repos that don't exist) — if there's no local identity the
/// caller must pass an explicit `owner/name`.
async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result<String> {
let kp = load_keypair_from_dir(dir).context(
"no local identity to resolve the repo owner — pass `owner/name`, or run `gl identity new`",
)?;
let did = kp.did().to_string();
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
async fn resolve_owner_did(node: &str, dir: Option<&std::path::Path>) -> Result<String> {
if let Ok(kp) = load_keypair_from_dir(dir) {
let did = kp.did().to_string();
return Ok(did.split(':').next_back().unwrap_or(&did).to_string());
}
let client = NodeClient::new(node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
Ok(did.split(':').next_back().unwrap_or(did).to_string())
}
async fn cmd_create(
@@ -304,37 +306,31 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
}
async fn cmd_clone(name: String, node: String, dir: Option<PathBuf>) -> Result<()> {
// Owner is taken from an explicit `owner/name`, else the LOCAL identity —
// never the node's own DID. A missing repo then surfaces as the helper's
// clear 404 rather than a clone under an invented owner.
let (did, repo_name) = if let Some((owner, rest)) = name.split_once('/') {
(owner.to_string(), rest.to_string())
let did = if let Ok(kp) = load_keypair_from_dir(dir.as_deref()) {
kp.did().to_string()
} else {
// Bare name: derive the SHORT owner key via the same helper the other
// commands use, so the URL is `gitlawb://z6Mk.../name` — not the full
// `did:key:z6Mk...` form, whose colons in the authority break the helper.
(resolve_owner_did(&node, dir.as_deref()).await?, name)
let client = NodeClient::new(&node, None);
let info: Value = client.get("/").await?.json().await?;
info["did"]
.as_str()
.context("node missing DID")?
.to_string()
};
let url = format!("gitlawb://{did}/{repo_name}");
let url = format!("gitlawb://{did}/{name}");
println!(" cloning {url}");
let status = std::process::Command::new("git")
.arg("clone")
.arg(&url)
// Point the remote helper at the same node the user selected.
.env("GITLAWB_NODE", &node)
.status()
.context("failed to run git clone — is git installed?")?;
if !status.success() {
anyhow::bail!("git clone failed — does the repo exist on this node?");
anyhow::bail!("git clone failed");
}
Ok(())
}
async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
// Sign when an identity is available so the read-visibility-gated replica
// sub-fetch below resolves for a private repo's owner (public repos still
// work anonymously).
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let client = NodeClient::new(&node, None);
let (owner, name) = if repo.contains('/') {
let (o, n) = repo.split_once('/').unwrap();
@@ -344,27 +340,12 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
(short, repo)
};
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
let r: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}"))
.await?
.json()
.await
.context("failed to connect to node")?;
// 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().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}");
}
let r: Value = resp.json().await.context("parse repo info")?;
.context("repo not found")?;
let owner_did = r["owner_did"].as_str().unwrap_or(&owner);
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
@@ -387,7 +368,7 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
// Replica count — failure to fetch is non-fatal (older nodes don't expose this).
if let Ok(resp) = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
{
if resp.status().is_success() {
@@ -467,17 +448,15 @@ async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>
Ok(())
}
async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_replicas(repo: String, node: String) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
// Read-visibility-gated: public repos list anonymously, private repos need
// the owner's signature. Sign when an identity is available.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let client = NodeClient::new(&node, None);
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
.context("failed to connect to node")?;
let status = resp.status();
@@ -595,7 +574,7 @@ async fn cmd_fork(
}
/// Returns (owner, repo_name) — if repo contains '/', splits on it; otherwise uses caller's DID.
pub(crate) async fn resolve_owner_repo_pair(
async fn resolve_owner_repo_pair(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
@@ -668,23 +647,16 @@ async fn cmd_label_remove(
async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
// Read-visibility-gated like the sibling read surfaces (replicas, protected
// branches): sign when an identity is available so a private-repo owner can
// read their own labels, while public repos stay anonymously listable.
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let client = NodeClient::new(&node, None);
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await?
.json()
.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}");
}
.context("invalid JSON")?;
let labels = body["labels"].as_array().cloned().unwrap_or_default();
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
if labels.is_empty() {
println!("No labels on {owner}/{name}");
} else {
@@ -696,39 +668,17 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
Ok(())
}
/// Parse the protected-branch list from the node's `GET /branches/protected`
/// response. The endpoint returns `{"protected_branches": [...], "count": N}`
/// (see `crates/gitlawb-node/src/api/protect.rs`); each entry may be a plain
/// branch-name string or an object carrying a `name` field.
fn parse_protected_branches(val: &Value) -> Vec<String> {
val["protected_branches"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|b| {
b.as_str()
.map(String::from)
.or_else(|| b["name"].as_str().map(String::from))
})
.collect()
})
.unwrap_or_default()
}
async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: bool) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let my_did = keypair.did().to_string();
let my_short = my_did.split(':').next_back().unwrap_or(&my_did).to_string();
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
// Sign with the loaded identity so the read-visibility-gated protected-branch
// fetch below works on the owner's own private repos.
let client = NodeClient::new(&node, Some(keypair));
let client = NodeClient::new(&node, None);
// Fetch repo info (get_repo is read-visibility-gated; sign so the owner can
// inspect their own private repo).
// Fetch repo info
let resp = client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
.get(&format!("/api/v1/repos/{owner}/{name}"))
.await
.context("failed to connect to node")?;
if !resp.status().is_success() {
@@ -743,14 +693,25 @@ async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: b
.to_string();
let is_owner = my_did == owner_did || my_short == owner_short;
// Fetch protected branches (read-visibility-gated; signed via the client).
// Fetch protected branches
let protected: Vec<String> = match client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.get(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
.await
{
Ok(resp) if resp.status().is_success() => {
let val: Value = resp.json().await.unwrap_or_default();
parse_protected_branches(&val)
val.as_array()
.or_else(|| val["branches"].as_array())
.map(|arr| {
arr.iter()
.filter_map(|b| {
b.as_str()
.map(String::from)
.or_else(|| b["name"].as_str().map(String::from))
})
.collect()
})
.unwrap_or_default()
}
_ => Vec::new(),
};
@@ -788,20 +749,6 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_protected_branches_reads_node_shape() {
use serde_json::json;
assert_eq!(
parse_protected_branches(&json!({"protected_branches":["main","release"],"count":2})),
vec!["main".to_string(), "release".to_string()]
);
assert!(parse_protected_branches(&json!({"count":0})).is_empty());
assert_eq!(
parse_protected_branches(&json!({"protected_branches":[{"name":"main"}]})),
vec!["main".to_string()]
);
}
fn write_identity(dir: &TempDir) {
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
@@ -1139,10 +1086,6 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
)
// Identity is loaded, so get_maybe_signed must sign — requiring the
// header guards against a regression back to bare get().
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"labels":[]}"#)
@@ -1169,10 +1112,6 @@ mod tests {
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
)
// Identity is loaded, so get_maybe_signed must sign — requiring the
// header guards against a regression back to bare get().
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"labels":["language:rust","topic:defi"]}"#)
@@ -1188,97 +1127,6 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn test_cmd_label_list_anonymous_when_no_identity() {
// Empty dir → no identity → get_maybe_signed must fall back to an
// anonymous GET (the public-repo path). Assert NO signature header.
let dir = TempDir::new().unwrap();
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/labels$".to_string()))
.match_header("signature", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"labels":[]}"#)
.create_async()
.await;
cmd_label_list(
"owner/pubrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_label_list_404_errors() {
// A non-reader of a private repo gets 404 from the now-gated endpoint.
// The client must surface that as an error, not print "No labels" and
// exit 0 (the error body has no `labels` key).
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/[^/]+/myrepo/labels$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.create_async()
.await;
let err = cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("list labels failed (404")
&& err.to_string().contains("repo not found"),
"got: {err}"
);
}
#[tokio::test]
async fn test_cmd_label_list_non_json_error_surfaces_status() {
// A non-2xx response with a non-JSON body (e.g. a proxy 502) must still
// surface the HTTP status, not collapse to a bare JSON-parse error.
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/[^/]+/myrepo/labels$".to_string()),
)
.with_status(502)
.with_header("content-type", "text/html")
.with_body("<html>502 Bad Gateway</html>")
.create_async()
.await;
let err = cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("list labels failed (502"),
"got: {err}"
);
}
#[tokio::test]
async fn test_cmd_owner_is_owner() {
let dir = TempDir::new().unwrap();
@@ -1289,15 +1137,11 @@ mod tests {
let short = did.split(':').next_back().unwrap().to_string();
let mut server = mockito::Server::new_async().await;
// Both fetches are read-visibility-gated; with an identity loaded they
// must be signed. Requiring the header guards a regression to get().
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"^/api/v1/repos/{short}/myrepo$")),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
@@ -1312,11 +1156,9 @@ mod tests {
r"^/api/v1/repos/{short}/myrepo/branches/protected$"
)),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main"],"count":1}"#)
.with_body(r#"["main"]"#)
.create_async()
.await;
@@ -1357,7 +1199,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
.with_body(r#"["main","release"]"#)
.create_async()
.await;
@@ -1398,7 +1240,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main"],"count":1}"#)
.with_body(r#"["main"]"#)
.create_async()
.await;
@@ -1439,254 +1281,4 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
#[tokio::test]
async fn test_cmd_replicas_signs_when_identity_present() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// Read-visibility-gated: with an identity the request must be signed.
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repo":"owner/myrepo","replica_count":0,"replicas":[]}"#)
.create_async()
.await;
cmd_replicas(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_replicas_anonymous_when_no_identity() {
// Empty dir → no identity → get_maybe_signed must fall back to an
// anonymous GET (the public-repo path). Assert NO signature header.
let dir = TempDir::new().unwrap();
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
.match_header("signature", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repo":"owner/pubrepo","replica_count":0,"replicas":[]}"#)
.create_async()
.await;
cmd_replicas(
"owner/pubrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_info_signs_repo_and_replica_fetches() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
// Both the primary repo fetch and the replica sub-fetch are
// read-visibility-gated; with an identity loaded both must be signed.
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"id":"r1","name":"myrepo","owner_did":"did:key:zOwner","is_public":false,"default_branch":"main"}"#,
)
.create_async()
.await;
let _replicas = server
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"replica_count":0,"replicas":[]}"#)
.create_async()
.await;
cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_info_404_surfaces_error_not_fake_repo() {
// A non-reader of a private repo gets a JSON 404 from the now-gated
// GET /api/v1/repos/{owner}/{name}. The command must surface that as an
// error, not parse the error body and print a fabricated repo summary
// (owner / "?" / public=true / main) before exiting 0.
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repo not found"}"#)
.create_async()
.await;
let err = cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
// The merged cmd_info maps a 404 to a generic "not found" (it does not
// echo the server's message body on 404), which still surfaces the error
// rather than fabricating a repo card, and avoids leaking any
// server-provided detail on a private-repo denial.
assert!(
err.to_string()
.contains("repository 'owner/myrepo' not found"),
"got: {err}"
);
}
#[tokio::test]
async fn test_cmd_info_non_json_error_surfaces_status() {
// A non-2xx response with a non-JSON body (e.g. a proxy 502) must still
// surface the HTTP status, not collapse to a bare JSON-parse error.
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _repo = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.with_status(502)
.with_header("content-type", "text/html")
.with_body("<html>502 Bad Gateway</html>")
.create_async()
.await;
let err = cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("repo info failed (502"),
"got: {err}"
);
}
}
// Drives the three deny/negative arms that the merge reconciliation changed but
// that no existing test executed (they were only reasoned): resolve_owner_did
// with no identity, cmd_info on an authenticated-but-denied 403, and
// get_maybe_signed with no keypair. Each asserts the must-not case.
#[cfg(test)]
mod vet_merge_deny_arms {
use super::*;
use tempfile::TempDir;
fn write_identity(dir: &TempDir) {
let kp = gitlawb_core::identity::Keypair::generate();
let pem = kp.to_pem().unwrap();
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
}
// No local identity must hard-error, never fall back to the node's own DID.
// (Load-bearing: #113's pre-merge resolve_owner_did returned Ok via a node
// fetch here; a reintroduced fallback makes this go red.)
#[tokio::test]
async fn resolve_owner_did_no_identity_errors_not_fallback() {
let empty = TempDir::new().unwrap(); // no identity.pem written
let err = resolve_owner_did("http://unused.invalid", Some(empty.path()))
.await
.unwrap_err();
assert!(
err.to_string()
.contains("no local identity to resolve the repo owner"),
"got: {err}"
);
}
// An authenticated caller DENIED with a 403 must surface an error, never
// 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();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
)
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"forbidden"}"#)
.create_async()
.await;
let err = cmd_info(
"owner/myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("repo info failed (403"),
"got: {err}"
);
}
// No keypair must send an unsigned GET; the mock only matches when the
// Signature header is absent, so a wrongly-signed request fails to match.
#[tokio::test]
async fn get_maybe_signed_no_keypair_sends_unsigned() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/v1/repos/owner/pub")
.match_header("signature", mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("{}")
.create_async()
.await;
let client = NodeClient::new(server.url(), None);
let resp = client
.get_maybe_signed("/api/v1/repos/owner/pub")
.await
.unwrap();
assert!(
resp.status().is_success(),
"unsigned GET should match the Missing-signature mock; got {}",
resp.status()
);
}
}
+9 -15
View File
@@ -34,14 +34,12 @@ pub enum StarCmd {
#[arg(long)]
dir: Option<PathBuf>,
},
/// Show star count for a repository
/// Show star count for a repository (no auth required)
Count {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
}
@@ -49,7 +47,7 @@ pub async fn run(args: StarArgs) -> Result<()> {
match args.cmd {
StarCmd::Add { repo, node, dir } => cmd_add(repo, node, dir).await,
StarCmd::Remove { repo, node, dir } => cmd_remove(repo, node, dir).await,
StarCmd::Count { repo, node, dir } => cmd_count(repo, node, dir).await,
StarCmd::Count { repo, node } => cmd_count(repo, node).await,
}
}
@@ -112,15 +110,15 @@ async fn cmd_remove(repo: String, node: String, dir: Option<PathBuf>) -> Result<
Ok(())
}
async fn cmd_count(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
async fn cmd_count(repo: String, node: String) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format for count (e.g. alice/myrepo)")?;
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
let client = NodeClient::new(&node, None);
let resp = client
.get_authed(&format!("/api/v1/repos/{owner}/{name}/star"))
.get(&format!("/api/v1/repos/{owner}/{name}/star"))
.await
.context("failed to connect to node")?;
@@ -312,20 +310,16 @@ mod tests {
.create_async()
.await;
cmd_count("alice/myrepo".to_string(), server.url(), None)
cmd_count("alice/myrepo".to_string(), server.url())
.await
.unwrap();
}
#[tokio::test]
async fn test_cmd_count_requires_slash() {
let err = cmd_count(
"noslash".to_string(),
"http://127.0.0.1:1".to_string(),
None,
)
.await
.unwrap_err();
let err = cmd_count("noslash".to_string(), "http://127.0.0.1:1".to_string())
.await
.unwrap_err();
assert!(err.to_string().contains("owner/repo format"));
}
+13 -52
View File
@@ -59,7 +59,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
println!(" repo {short_did}/{repo}");
}
None => {
println!(" repo (not in a gitlawb repo — no gitlawb:// remote)");
println!(" repo (not in a gitlawb repo — no gitlawb:// origin)");
}
}
@@ -143,68 +143,29 @@ fn trust_bar(score: f64) -> String {
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}
/// Find a `gitlawb://<did>/<repo>` remote in the current repo.
///
/// Checks `origin` first (clones), then `gitlawb` (the name `gl init` adds),
/// then any other remote with a gitlawb:// URL.
/// Parse `gitlawb://<did>/<repo>` from the git origin remote.
fn detect_gitlawb_remote() -> Option<(String, String)> {
let out = std::process::Command::new("git")
.args(["remote"])
.args(["remote", "get-url", "origin"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let names: Vec<String> = String::from_utf8(out.stdout)
.ok()?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
let preferred = ["origin", "gitlawb"];
let ordered = preferred
.iter()
.filter(|p| names.iter().any(|n| n == *p))
.map(|p| p.to_string())
.chain(
names
.iter()
.filter(|n| !preferred.contains(&n.as_str()))
.cloned(),
);
for name in ordered {
// A remote can carry several URLs, and the gitlawb:// one may be a
// push URL behind an https fetch URL — check every fetch and push URL.
for extra in [&["--all"][..], &["--push", "--all"][..]] {
let mut args = vec!["remote", "get-url"];
args.extend_from_slice(extra);
args.push(&name);
let Ok(out) = std::process::Command::new("git")
.args(&args)
.stderr(std::process::Stdio::null())
.output()
else {
continue;
};
if !out.status.success() {
continue;
}
let Ok(urls) = String::from_utf8(out.stdout) else {
continue;
};
if let Some(parsed) = urls.lines().find_map(parse_gitlawb_url) {
return Some(parsed);
}
}
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() {
return None;
}
None
Some((did, repo))
}
/// Parse a gitlawb:// URL string into (did, repo).
/// Parse a gitlawb:// URL string into (did, repo) — extracted for testing.
#[cfg(test)]
fn parse_gitlawb_url(url: &str) -> Option<(String, String)> {
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
+10 -283
View File
@@ -1,4 +1,4 @@
use anyhow::{Context, Result};
use anyhow::Result;
use clap::{Args, Subcommand};
use std::path::PathBuf;
@@ -30,39 +30,15 @@ pub enum SyncCmd {
pub async fn run(args: SyncArgs) -> Result<()> {
match args.cmd {
SyncCmd::Trigger => {
// /api/v1/sync/trigger always requires a signature, so a missing or
// unreadable identity must fail here, locally, rather than sending an
// unsigned request that can only 401 remotely (matches the other
// signed CLI writes).
let keypair = load_keypair_from_dir(args.dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&args.node, Some(keypair));
let resp = client.post("/api/v1/sync/trigger", b"{}").await?;
// The node now requires a signature on this route and rate-limits it,
// so a denial (401/429/…) is expected. Check the status BEFORE parsing:
// otherwise a JSON-ish error body deserializes into a zero-count struct
// and prints a fabricated "✓ sync triggered / 0 peers" success.
let status = resp.status();
if !status.is_success() {
// Bound the read: a hostile or broken node must not force an
// unbounded allocation just to surface a denial (INV-6, read half).
let raw = read_body_capped(resp, 8 * 1024).await;
let msg = serde_json::from_str::<serde_json::Value>(&raw)
.ok()
.and_then(|v| {
v.get("message")
.or_else(|| v.get("error"))
.and_then(|m| m.as_str())
.map(str::to_string)
})
.unwrap_or(raw);
anyhow::bail!(
"sync trigger failed ({status}): {}",
sanitize_node_msg(&msg)
);
}
let resp: serde_json::Value = resp.json().await?;
let (peers, enqueued) = trigger_counts(&resp);
let keypair = load_keypair_from_dir(args.dir.as_deref()).ok();
let client = NodeClient::new(&args.node, keypair);
let resp: serde_json::Value = client
.post("/api/v1/sync/trigger", b"{}")
.await?
.json()
.await?;
let peers = resp["peers_reached"].as_u64().unwrap_or(0);
let enqueued = resp["repos_enqueued"].as_u64().unwrap_or(0);
println!("✓ sync triggered");
println!(" peers reached: {peers}");
println!(" repos enqueued: {enqueued}");
@@ -93,252 +69,3 @@ pub async fn run(args: SyncArgs) -> Result<()> {
}
Ok(())
}
/// Extract `(peers_reached, repos_enqueued)` from a successful sync-trigger
/// response. Split out so the extraction is unit-testable (missing or malformed
/// fields default to 0 rather than panicking).
fn trigger_counts(resp: &serde_json::Value) -> (u64, u64) {
(
resp["peers_reached"].as_u64().unwrap_or(0),
resp["repos_enqueued"].as_u64().unwrap_or(0),
)
}
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
/// hostile or broken node returning a huge error body — the display is capped
/// separately, but the read itself must not be unbounded (INV-6, read half).
async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
let mut buf: Vec<u8> = Vec::new();
while buf.len() < cap {
match resp.chunk().await {
Ok(Some(chunk)) => {
let take = (cap - buf.len()).min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if take < chunk.len() {
break; // hit the cap mid-chunk
}
}
_ => break, // end of body or read error — return what we have
}
}
String::from_utf8_lossy(&buf).into_owned()
}
/// 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 (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).
fn sanitize_node_msg(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
.take(200)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn trigger_args(node: String) -> (SyncArgs, tempfile::TempDir) {
// Seed a real identity so `run` gets past the mandatory-keypair check and
// reaches the status-handling path. The mocks below return a fixed status
// regardless of the signature, so these tests exercise the client's
// status-check-before-parse, not signature verification (that is proved
// server-side). Return the TempDir so the caller keeps it alive.
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();
let args = SyncArgs {
cmd: SyncCmd::Trigger,
node,
dir: Some(dir.path().to_path_buf()),
};
(args, dir)
}
#[tokio::test]
async fn trigger_requires_identity_fails_before_request() {
// Empty identity dir → no keypair. `sync trigger` must fail locally with
// a clear identity error BEFORE issuing any request. The node URL points
// at an unreachable port, so a request attempt would surface a different
// (connection) error; getting the identity error proves we never dialed.
let dir = tempfile::TempDir::new().unwrap();
let args = SyncArgs {
cmd: SyncCmd::Trigger,
node: "http://127.0.0.1:1".to_string(),
dir: Some(dir.path().to_path_buf()),
};
let err = run(args).await.unwrap_err();
assert!(
err.to_string().contains("identity not found"),
"expected a local identity error before any request, got: {err}"
);
}
#[tokio::test]
async fn trigger_surfaces_401_as_error_not_fake_success() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/v1/sync/trigger")
.with_status(401)
.with_header("content-type", "application/json")
// 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"}"#)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
let err = run(args).await.unwrap_err();
assert!(
err.to_string().contains("401"),
"expected 401 surfaced, got: {err}"
);
}
#[tokio::test]
async fn trigger_surfaces_429_as_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/v1/sync/trigger")
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"slow down"}"#)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
let err = run(args).await.unwrap_err();
assert!(
err.to_string().contains("429"),
"expected 429 surfaced, got: {err}"
);
}
#[tokio::test]
async fn trigger_sanitizes_control_chars_in_node_error() {
// A hostile node embeds an ANSI color escape (ESC) and a bell (BEL) in
// the JSON message field. The surfaced error must contain neither raw
// control byte, while keeping the printable text.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/v1/sync/trigger")
.with_status(401)
.with_header("content-type", "application/json")
// Valid JSON whose message carries JSON-escaped ESC (\u001b) and
// 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\"}")
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
let err = run(args).await.unwrap_err();
let s = err.to_string();
assert!(!s.contains('\u{1b}'), "ESC leaked to terminal: {s:?}");
assert!(!s.contains('\u{07}'), "BEL leaked to terminal: {s:?}");
assert!(
s.contains("pwned") && s.contains("bad"),
"message text dropped: {s:?}"
);
}
#[tokio::test]
async fn trigger_ok_prints_counts() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/v1/sync/trigger")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"peers_reached":2,"repos_enqueued":5}"#)
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
run(args).await.unwrap();
}
#[test]
fn sanitize_strips_controls_bidi_and_caps_length() {
// C0 (ESC/BEL) and the Cf bidi override (U+202E) are both removed; the
// printable text survives. (Note: a stripped ESC leaves any following
// "[31m" as inert literal text — that is the point, so the input here
// avoids that residue to keep the expectation unambiguous.)
let out = sanitize_node_msg("a\u{1b}\u{07}b\u{202e}c");
assert!(
!out.chars().any(|c| c.is_control()),
"control char leaked: {out:?}"
);
assert!(
!out.contains('\u{202e}'),
"RLO bidi override leaked: {out:?}"
);
assert_eq!(out, "abc");
// Length is capped at 200 chars regardless of input size.
let long = "x".repeat(250);
assert_eq!(sanitize_node_msg(&long).chars().count(), 200);
}
#[test]
fn sanitize_preserves_legitimate_and_rtl_text() {
// Must not over-strip: a plain word, a genuine RTL SCRIPT letter (Arabic
// U+0627, category Lo — NOT a format char), and ZWJ (U+200D, a legitimate
// Cf char, e.g. emoji sequences) all survive. Guards the shared predicate
// against being widened into a blanket Cf stripper.
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
assert_eq!(out, "ok \u{0627}\u{200D}b");
}
#[tokio::test]
async fn trigger_handles_oversized_error_body_without_unbounded_output() {
// A hostile/broken node returns a 2 MB error body. The command must still
// surface the denial with a bounded message, not hang or dump the body.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/v1/sync/trigger")
.with_status(401)
.with_body("A".repeat(2_000_000))
.create_async()
.await;
let (args, _dir) = trigger_args(server.url());
let err = run(args).await.unwrap_err();
let s = err.to_string();
assert!(s.contains("401"), "denial not surfaced: {s:.80?}");
assert!(
s.len() < 500,
"error message not bounded: {} chars",
s.len()
);
}
#[tokio::test]
async fn read_body_capped_bounds_the_read() {
// The read must stop at the cap — a 2 MB body yields at most `cap` bytes,
// not the whole thing (which resp.text() would return).
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/big")
.with_status(200)
.with_body("A".repeat(2_000_000))
.create_async()
.await;
let resp = reqwest::get(format!("{}/big", server.url())).await.unwrap();
let out = read_body_capped(resp, 8192).await;
assert!(out.len() <= 8192, "read not bounded: {} bytes", out.len());
assert!(!out.is_empty(), "expected some body");
}
#[test]
fn trigger_counts_extracts_both_values() {
let v = serde_json::json!({"peers_reached": 2, "repos_enqueued": 5});
assert_eq!(trigger_counts(&v), (2, 5));
// Missing/malformed fields default to 0, never panic.
assert_eq!(trigger_counts(&serde_json::json!({})), (0, 0));
assert_eq!(
trigger_counts(&serde_json::json!({"peers_reached": "x"})),
(0, 0)
);
}
}
+26 -169
View File
@@ -42,8 +42,6 @@ pub enum WebhookCmd {
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Delete a webhook
Delete {
@@ -68,7 +66,7 @@ pub async fn run(args: WebhookArgs) -> Result<()> {
node,
dir,
} => cmd_create(repo, url, events, secret, node, dir).await,
WebhookCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
WebhookCmd::List { repo, node } => cmd_list(repo, node).await,
WebhookCmd::Delete {
repo,
id,
@@ -78,6 +76,15 @@ pub async fn run(args: WebhookArgs) -> Result<()> {
}
}
async fn resolve_owner(client: &NodeClient) -> Result<String> {
let info: Value = client.get("/").await?.json().await?;
let did = info["did"]
.as_str()
.context("node missing DID")?
.to_string();
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
}
async fn cmd_create(
repo: String,
url: String,
@@ -87,8 +94,8 @@ async fn cmd_create(
dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let owner = resolve_owner(&client).await?;
let event_list: Vec<&str> = events.split(',').map(str::trim).collect();
@@ -99,7 +106,7 @@ async fn cmd_create(
}))?;
let resp = client
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &payload)
.await
.context("failed to connect to node")?;
let status = resp.status();
@@ -136,31 +143,18 @@ async fn cmd_create(
Ok(())
}
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
// The node owner-gates GET /hooks (callback URLs are owner-secret), so the
// list request must be signed — anonymous callers get 401.
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
async fn cmd_list(repo: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let owner = resolve_owner(&client).await?;
// 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.
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")?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.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")
.and_then(Value::as_array)
.cloned()
.context("node response missing webhooks array")?;
let hooks = resp["webhooks"].as_array().cloned().unwrap_or_default();
if hooks.is_empty() {
println!("No webhooks for {repo}");
return Ok(());
@@ -191,13 +185,13 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
async fn cmd_delete(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let owner = resolve_owner(&client).await?;
let payload = serde_json::to_vec(&serde_json::json!({}))?;
let resp = client
.delete(
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"),
&payload,
)
.await
@@ -343,170 +337,33 @@ mod tests {
#[tokio::test]
async fn test_list_webhooks_empty() {
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
// The route is owner-gated, so the list request must be signed.
// Requiring the header here is what catches a regression to plain get().
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[]}"#)
.create_async()
.await;
cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("my-repo".to_string(), server.url()).await.unwrap();
}
#[tokio::test]
async fn test_list_webhooks_with_items() {
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[{"id":"h1","url":"https://a.com","events":["push"],"active":true},{"id":"h2","url":"https://b.com","events":["*"],"active":false}]}"#)
.create_async()
.await;
cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_list_owner_comes_from_keypair_not_node_did() {
// The owner segment must be derived from the caller's keypair (loaded
// from --dir), NOT the node root DID. mock_root returns a different DID;
// if the code regressed to using it, this path mock would not match.
let mut server = mockito::Server::new_async().await;
let (dir, kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"/api/v1/repos/{short}/my-repo/hooks$")),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[]}"#)
.expect(1)
.create_async()
.await;
cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
_m.assert_async().await;
}
#[tokio::test]
async fn test_list_honors_owner_repo_arg() {
// An explicit `owner/repo` argument is split and used verbatim.
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/repos/someoneelse/their-repo/hooks$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"webhooks":[]}"#)
.expect(1)
.create_async()
.await;
cmd_list(
"someoneelse/their-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
_m.assert_async().await;
}
#[tokio::test]
async fn test_list_webhooks_non_success_status_errors() {
// A 403 (or any non-2xx) JSON body has no `webhooks` field; cmd_list must
// surface the failure, not print "No webhooks" and exit 0.
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not the owner"}"#)
.create_async()
.await;
let err = cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(err.to_string().contains("not the owner"));
}
#[tokio::test]
async fn test_list_webhooks_2xx_missing_array_errors() {
// A 2xx body without a `webhooks` array signals a node/API contract
// regression. cmd_list must error rather than print "No webhooks".
let mut server = mockito::Server::new_async().await;
let (dir, _kp) = tmp_identity();
let _root = mock_root(&mut server).await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
.match_header("signature", mockito::Matcher::Any)
.match_header("signature-input", mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"ok":true}"#)
.create_async()
.await;
let err = cmd_list(
"my-repo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
assert!(err.to_string().contains("missing webhooks array"));
cmd_list("my-repo".to_string(), server.url()).await.unwrap();
}
// ── delete ───────────────────────────────────────────────────────
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "icaptcha-client"
version = "0.4.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
mockito = "1"
-723
View File
@@ -1,723 +0,0 @@
//! Client for the iCaptcha proof-of-intelligence service.
//!
//! The node gates spam-prone writes (repo create / fork / register) behind an
//! iCaptcha proof. This crate implements the *sanctioned client flow*: on a
//! `403 icaptcha_proof_required`, request a challenge for the required level,
//! solve the deterministic computational types locally, obtain the signed
//! proof, and hand it back so the caller can retry the original signed request
//! with the `x-icaptcha-proof` header.
//!
//! `requesterId` is always the caller's DID, so the proof's `sub` claim matches
//! the authenticated signer (the node enforces `sub == authenticated DID`).
//!
//! Blocking HTTP (reqwest::blocking) so the git remote helper can use it
//! directly; `gl` (async) calls it via `tokio::task::spawn_blocking`.
use std::io::Read;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use serde_json::json;
pub mod pow;
pub mod solvers;
/// Default iCaptcha service base URL (used when the node doesn't advertise one).
pub const DEFAULT_URL: &str = "https://icaptcha.gitlawb.com";
/// Default required level (the node's default floor).
pub const DEFAULT_LEVEL: u32 = 3;
/// Header the gated write must echo the proof back in.
pub const PROOF_HEADER: &str = "x-icaptcha-proof";
/// Cap on bytes read from an error response, and characters kept in the excerpt.
/// The iCaptcha origin is only as trusted as the node that advertised it (a MITM
/// on the default transport qualifies), so its error bodies are attacker-
/// influenceable bytes that reach the TTY — they must be bounded and stripped of
/// control characters before printing (CWE-150, same treatment as #137).
const MAX_ERR_BYTES: u64 = 4096;
const MAX_ERR_CHARS: usize = 500;
/// Read a bounded error body and return a control-char-stripped, length-capped
/// single-line excerpt safe to print to a terminal.
fn error_excerpt(resp: reqwest::blocking::Response) -> String {
let mut buf = Vec::new();
if resp.take(MAX_ERR_BYTES).read_to_end(&mut buf).is_err() {
return String::new();
}
sanitize_excerpt(&String::from_utf8_lossy(&buf))
}
/// Strip C0/C1 control characters, collapse whitespace runs to single spaces,
/// and cap the length — so a hostile response body cannot inject terminal
/// escapes or flood the TTY.
fn sanitize_excerpt(s: &str) -> String {
let mut out = String::new();
let mut pending_space = false;
let mut kept = 0;
for ch in s.trim().chars() {
if kept >= MAX_ERR_CHARS {
break;
}
if ch.is_control() {
if matches!(ch, '\n' | '\r' | '\t') {
pending_space = true;
}
continue;
}
if gitlawb_core::sanitize::is_bidi_format(ch) {
// Drop Cf bidi/format controls (INV-6, #183) without setting
// pending_space: they are not whitespace, so stripping leaves no
// phantom gap.
continue;
}
if ch.is_whitespace() {
pending_space = true;
continue;
}
if pending_space && !out.is_empty() {
out.push(' ');
kept += 1;
if kept >= MAX_ERR_CHARS {
break;
}
}
out.push(ch);
kept += 1;
pending_space = false;
}
out
}
/// Whether `u` parses as a trusted URL.
///
/// 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 std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
&& parsed.scheme() == "http"
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
{
return true;
}
false
}
/// Lowercased origin string (`scheme://host[:port]`) of a URL.
///
/// Includes the effective port so that `http://localhost:3000` and
/// `http://localhost:9000` are treated as distinct origins — a hostile node
/// cannot redirect the iCaptcha solve to a different loopback port.
fn origin_key(u: &str) -> Option<String> {
let parsed = reqwest::Url::parse(u).ok()?;
let scheme = parsed.scheme();
let host = parsed.host_str()?.to_ascii_lowercase();
let port = parsed
.port_or_known_default()
.map(|p| format!(":{p}"))
.unwrap_or_default();
Some(format!("{scheme}://{host}{port}"))
}
/// Decide which iCaptcha origin to actually talk to, and whether it is trusted
/// enough to receive the operator's API key.
///
/// The node-advertised `x-icaptcha-url` is untrusted: a hostile node could point
/// it at an attacker origin (client-side SSRF) or capture a bearer token on the
/// first hop. So an advertised URL is honored only when it is `https` AND its
/// host is allowlisted — the public default host, or the operator's own
/// `GITLAWB_ICAPTCHA_URL`. Otherwise we fall back to the operator/default origin.
///
/// The API key is attached only when the resolved origin is the operator's
/// 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) {
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);
let allowed = |origin: &Option<String>| -> bool {
origin
.as_ref()
.map(|o| Some(o) == default_origin.as_ref() || Some(o) == operator_origin.as_ref())
.unwrap_or(false)
};
let fallback = || {
operator
.map(String::from)
.unwrap_or_else(|| DEFAULT_URL.to_string())
};
let chosen = match advertised {
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
Some(a) => {
tracing::warn!(
advertised = %a,
"ignoring iCaptcha URL advertised by node (must be https with an allowlisted host); using trusted origin"
);
fallback()
}
None => fallback(),
};
// Key goes only to the operator's own configured origin.
let key_trusted = operator_origin.is_some() && origin_key(&chosen) == operator_origin;
(chosen, key_trusted)
}
/// Computational challenge types this client solves locally. Restricting the
/// request to these avoids dictionary (anagram/logic) and LLM (wordproblem/
/// riddle) types, which can't be auto-solved.
const SOLVABLE_TYPES: [&str; 3] = ["arithmetic", "algebra", "sequence"];
/// Bound on challenge/answer rounds (the service escalates difficulty on a miss;
/// correct solvers shouldn't escalate, but cap it regardless).
const MAX_ROUNDS: usize = 8;
/// Where + at what level to solve. `did` becomes the proof's `sub`.
#[derive(Debug, Clone)]
pub struct IcaptchaCfg {
pub url: String,
pub did: String,
pub level: u32,
/// Optional bearer token for an API-key-protected iCaptcha deployment.
pub api_key: Option<String>,
}
impl IcaptchaCfg {
/// Build config from the caller DID plus an optionally node-advertised
/// url/level (e.g. the `x-icaptcha-url` / `x-icaptcha-level` headers) and
/// optional level.
///
/// The advertised URL is validated and allowlisted (see [`resolve_solver_url`])
/// so a hostile node cannot redirect the solve to an attacker origin. The
/// `GITLAWB_ICAPTCHA_API_KEY` bearer token is attached only when the resolved
/// origin is the operator's own `GITLAWB_ICAPTCHA_URL`, never a
/// node-discovered one.
pub fn new(did: impl Into<String>, url: Option<String>, level: Option<u32>) -> Self {
let operator = std::env::var("GITLAWB_ICAPTCHA_URL")
.ok()
.filter(|s| !s.is_empty());
let (resolved_url, key_trusted) = resolve_solver_url(url.as_deref(), operator.as_deref());
let api_key = if key_trusted {
std::env::var("GITLAWB_ICAPTCHA_API_KEY")
.ok()
.filter(|s| !s.is_empty())
} else {
None
};
Self {
url: resolved_url,
did: did.into(),
level: level.unwrap_or(DEFAULT_LEVEL),
api_key,
}
}
}
/// A challenge handed back by the service (mirrors `icaptcha` `Challenge`).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Challenge {
pub challenge_id: String,
#[serde(rename = "type")]
pub kind: String,
pub difficulty: u32,
pub prompt: String,
pub token: String,
/// Proof-of-work to solve and echo back as `powNonce` on the answer. Present
/// only when the service has PoW enabled; absent/None means no PoW required.
#[serde(default)]
pub pow: Option<pow::PowChallenge>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "status", rename_all = "lowercase")]
enum AnswerResult {
Passed { proof: String },
Continue { challenge: Challenge },
Failed { reason: String },
}
/// Solver callback for types this crate can't solve deterministically.
pub type Solver<'a> = dyn Fn(&Challenge) -> Option<String> + 'a;
/// Run the full challenge → solve → answer loop and return a fresh proof token.
///
/// `solver` is consulted for challenge types the built-in solvers don't handle
/// (anagram/logic/LLM); pass `None` to fall back to an interactive stdin prompt.
pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("build iCaptcha http client")?;
let mut challenge = request_challenge(&client, cfg)?;
for _ in 0..MAX_ROUNDS {
let answer = solvers::solve(&challenge.kind, &challenge.prompt)
.or_else(|| solver.and_then(|s| s(&challenge)))
.or_else(|| interactive_prompt(&challenge))
.ok_or_else(|| {
anyhow!(
"cannot solve iCaptcha challenge type '{}' automatically; \
set GITLAWB_ICAPTCHA_API_KEY/solver or solve interactively",
challenge.kind
)
})?;
// Solve the proof-of-work bound to this challenge, if the service
// requires one. A required-but-unsolvable PoW (unknown algorithm or a
// difficulty above our cap) is a hard error — submitting without it
// would just be rejected.
let pow_nonce = match &challenge.pow {
Some(p) => Some(pow::solve(p).ok_or_else(|| {
anyhow!(
"cannot solve iCaptcha proof-of-work (algorithm '{}', difficulty {})",
p.algorithm,
p.difficulty
)
})?),
None => None,
};
match submit_answer(
&client,
cfg,
&challenge.token,
&answer,
pow_nonce.as_deref(),
)? {
AnswerResult::Passed { proof } => return Ok(proof),
AnswerResult::Continue { challenge: next } => challenge = next,
AnswerResult::Failed { reason } => {
bail!("iCaptcha challenge failed: {}", sanitize_excerpt(&reason))
}
}
}
bail!("iCaptcha not solved within {MAX_ROUNDS} rounds")
}
fn request_challenge(client: &reqwest::blocking::Client, cfg: &IcaptchaCfg) -> Result<Challenge> {
let url = format!("{}/v1/challenge", cfg.url.trim_end_matches('/'));
let body = json!({
"requesterId": cfg.did,
"requiredLevel": cfg.level,
"types": SOLVABLE_TYPES,
});
let mut req = client.post(&url).json(&body);
if let Some(key) = &cfg.api_key {
req = req.bearer_auth(key);
}
let resp = req.send().with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = error_excerpt(resp);
bail!("iCaptcha challenge request failed ({status}): {text}");
}
resp.json::<Challenge>().context("parse iCaptcha challenge")
}
fn submit_answer(
client: &reqwest::blocking::Client,
cfg: &IcaptchaCfg,
token: &str,
answer: &str,
pow_nonce: Option<&str>,
) -> Result<AnswerResult> {
let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/'));
let mut body = json!({ "token": token, "answer": answer });
if let Some(nonce) = pow_nonce {
body["powNonce"] = json!(nonce);
}
let mut req = client.post(&url).json(&body);
if let Some(key) = &cfg.api_key {
req = req.bearer_auth(key);
}
let resp = req.send().with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = error_excerpt(resp);
bail!("iCaptcha answer request failed ({status}): {text}");
}
resp.json::<AnswerResult>()
.context("parse iCaptcha answer result")
}
/// Last-resort fallback: show the prompt and read an answer from the terminal.
/// 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, Write};
let mut err = stderr();
let _ = writeln!(
err,
"iCaptcha challenge ({}, level {}): {}\nAnswer: ",
challenge.kind, challenge.difficulty, challenge.prompt
);
let _ = err.flush();
let mut line = String::new();
match stdin().read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => {
let a = line.trim().to_string();
if a.is_empty() {
None
} else {
Some(a)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes tests that touch the process-global
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
/// prior value on drop.
struct InsecureEnv {
_lock: MutexGuard<'static, ()>,
prev: Option<OsString>,
}
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", "1");
InsecureEnv { _lock: lock, prev }
}
}
impl Drop for InsecureEnv {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
}
}
}
// ── P1: hostile error bodies must not reach the terminal raw ──────────
#[test]
fn sanitize_strips_control_chars_and_escapes() {
// ESC-based terminal escape + NUL + bell must all be removed.
let hostile = "\x1b[2J\x1b[31mowned\x07\0 done";
let clean = sanitize_excerpt(hostile);
assert!(!clean.contains('\x1b'), "ESC survived: {clean:?}");
assert!(
!clean.chars().any(|c| c.is_control()),
"control survived: {clean:?}"
);
assert!(clean.contains("owned") && clean.contains("done"));
}
#[test]
fn sanitize_strips_bidi_format_controls() {
// The Cc-only test above is vacuous for Cf: it passes whether or not bidi
// controls survive, which is why this gap shipped (#183). Here every Cf
// bidi class is stripped — override (RLO), embedding + pop (LRE/PDF),
// isolate (LRI/PDI), marks (LRM/ALM) — asserted per code point, and the
// visible words JOIN with no phantom space (a bidi char is dropped like a
// control byte, but unlike a newline/tab it is not whitespace). The leading
// bidi char (index 0) also exercises the empty-out path.
let out = sanitize_excerpt("\u{202e}err\u{202a}\u{202c}\u{2066}\u{2069}\u{200e}\u{061c}ok");
for c in [
'\u{202e}', '\u{202a}', '\u{202c}', '\u{2066}', '\u{2069}', '\u{200e}', '\u{061c}',
] {
assert!(!out.contains(c), "bidi U+{:04X} leaked: {out:?}", c as u32);
}
assert_eq!(out, "errok");
}
#[test]
fn sanitize_preserves_legitimate_and_rtl_text() {
// Must not over-strip: plain text, a genuine RTL SCRIPT letter (Arabic
// U+0627, category Lo), and ZWJ (U+200D, a legitimate Cf char) survive.
assert_eq!(
sanitize_excerpt("ok \u{0627}\u{200D}b"),
"ok \u{0627}\u{200D}b"
);
}
#[test]
fn sanitize_collapses_whitespace_and_caps_length() {
let collapsed = sanitize_excerpt("a\n\n\t b");
assert_eq!(collapsed, "a b");
let long = "x".repeat(MAX_ERR_CHARS + 500);
assert_eq!(sanitize_excerpt(&long).chars().count(), MAX_ERR_CHARS);
}
#[test]
fn sanitize_bidi_chars_do_not_consume_the_length_cap() {
// A dropped bidi char must not count toward MAX_ERR_CHARS: it is dropped
// before `kept` increments, so interleaving bidi controls cannot truncate
// the visible output early. MAX_ERR_CHARS + 100 visible 'x', each trailed by
// a bidi override, must still yield exactly MAX_ERR_CHARS visible 'x' with
// no phantom spaces. (Would regress to fewer 'x' if bidi consumed budget, or
// to spaces if the drop branch wrongly set pending_space.)
let body = "x\u{202e}".repeat(MAX_ERR_CHARS + 100);
assert_eq!(sanitize_excerpt(&body), "x".repeat(MAX_ERR_CHARS));
}
// ── P2: never talk to (or hand a key to) an untrusted node-chosen URL ──
#[test]
fn rejects_non_https_advertised_url() {
// A plaintext node-advertised URL is ignored; we fall back to default.
let (url, key_trusted) = resolve_solver_url(Some("http://evil.example/x"), None);
assert_eq!(url, DEFAULT_URL);
assert!(!key_trusted);
}
#[test]
fn rejects_https_url_with_non_allowlisted_host() {
let (url, key_trusted) = resolve_solver_url(Some("https://evil.example"), None);
assert_eq!(url, DEFAULT_URL, "attacker host must not be honored");
assert!(!key_trusted);
}
#[test]
fn honors_advertised_default_host_but_withholds_key_without_operator() {
// Default public host is allowlisted for talking, but with no operator
// origin configured the API key has no trusted destination.
let (url, key_trusted) = resolve_solver_url(Some("https://icaptcha.gitlawb.com/v2"), None);
assert_eq!(url, "https://icaptcha.gitlawb.com/v2");
assert!(!key_trusted);
}
#[test]
fn key_trusted_only_for_operator_configured_origin() {
let op = "https://icap.mynode.example";
// Node advertises the operator's own origin → talk there, key allowed.
let (url, key_trusted) =
resolve_solver_url(Some("https://icap.mynode.example/v1"), Some(op));
assert_eq!(url, "https://icap.mynode.example/v1");
assert!(key_trusted);
// Node advertises an attacker origin while an operator is configured →
// ignore the advert, fall back to the operator origin, key stays with it.
let (url, key_trusted) = resolve_solver_url(Some("https://evil.example"), Some(op));
assert_eq!(url, op);
assert!(key_trusted);
}
#[test]
fn no_advert_uses_operator_or_default() {
let op = "https://icap.mynode.example";
assert_eq!(resolve_solver_url(None, Some(op)), (op.to_string(), true));
assert_eq!(
resolve_solver_url(None, None),
(DEFAULT_URL.to_string(), false)
);
}
#[test]
fn non_https_operator_is_not_trusted() {
// A misconfigured plaintext operator URL is not a valid key destination.
let (url, key_trusted) = resolve_solver_url(None, Some("http://icap.mynode.example"));
assert_eq!(url, DEFAULT_URL);
assert!(!key_trusted);
}
#[test]
fn rejects_insecure_advertised_url_on_different_port() {
// 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(Some("http://localhost:9000"), Some(op));
assert_eq!(
url, op,
"must fall back to operator origin, not use different port"
);
assert!(key_trusted, "key stays trusted with operator origin");
}
#[test]
fn new_withholds_key_from_untrusted_origin_but_attaches_for_operator() {
let guard = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev_key = std::env::var_os("GITLAWB_ICAPTCHA_API_KEY");
let prev_op = std::env::var_os("GITLAWB_ICAPTCHA_URL");
std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", "secret-bearer");
// Arm 1: No operator configured, node advertises an attacker origin.
// The key has no trusted destination; the advert is rejected.
std::env::remove_var("GITLAWB_ICAPTCHA_URL");
let untrusted =
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
// Arm 1b: No operator configured, no URL advertised. The fallback goes
// to the default public origin, and with no operator to trust, the key
// must not ride.
let no_operator_no_advert = IcaptchaCfg::new("did:key:zTEST", None, None);
// Arm 2: Operator origin configured and advertised — key attached.
std::env::set_var("GITLAWB_ICAPTCHA_URL", "https://icap.mynode.example");
let operator_match = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icap.mynode.example/v1".into()),
None,
);
// Arm 3: Operator configured, default host advertised. The default host
// is allowlisted and selected, but its origin differs from the
// operator's, so key must not ride.
let default_advert = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icaptcha.gitlawb.com/v2".into()),
None,
);
// Arm 4: Operator configured, no URL advertised (the node sent only
// x-icaptcha-level, not x-icaptcha-url). resolve_solver_url falls back
// to the operator origin and marks the key trusted. This is the common
// API-key deployment path — a regression that additionally required
// url.is_some() for key attachment would leave all current tests green
// but make these fallback challenge/answer requests omit Authorization
// and fail against the protected operator service.
let operator_fallback = IcaptchaCfg::new("did:key:zTEST", None, Some(3));
// Arm 5: Operator configured, node advertises an attacker origin.
// The advert is rejected and the solver falls back to the operator's
// own origin, which keeps the key trusted — this is the exfiltration
// scenario the test name describes (withhold key from *untrusted*
// origin, not from the operator's fallback).
let operator_hostile_advert =
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
// Arm 6: Operator configured, same host but different port.
// origin_key includes the port, so :8443 must not match the
// operator origin at the default :443 — the advert is rejected
// and the solver falls back to the operator origin.
let operator_diff_port = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icap.mynode.example:8443/evil".into()),
None,
);
// Restore env and release the lock BEFORE asserting, so a failing
// assertion can't poison the shared lock or cascade into a sibling.
match prev_key {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_API_KEY"),
}
match prev_op {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
}
drop(guard);
let mut failures: Vec<String> = Vec::new();
if untrusted.api_key.is_some() {
failures.push(format!(
"arm 1 (no operator, attacker advert): expected api_key=None, got {:?}",
untrusted.api_key
));
}
if untrusted.url != DEFAULT_URL {
failures.push(format!(
"arm 1 (no operator, attacker advert): expected url={DEFAULT_URL}, got {}",
untrusted.url
));
}
if no_operator_no_advert.api_key.is_some() {
failures.push(format!(
"arm 1b (no operator, no advert): expected api_key=None, got {:?}",
no_operator_no_advert.api_key
));
}
if no_operator_no_advert.url != DEFAULT_URL {
failures.push(format!(
"arm 1b (no operator, no advert): expected url={DEFAULT_URL}, got {}",
no_operator_no_advert.url
));
}
if operator_match.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 2 (operator match): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_match.api_key
));
}
if operator_match.url != "https://icap.mynode.example/v1" {
failures.push(format!(
"arm 2 (operator match): expected url=https://icap.mynode.example/v1, got {}",
operator_match.url
));
}
if default_advert.api_key.is_some() {
failures.push(format!(
"arm 3 (operator + default advert): expected api_key=None, got {:?}",
default_advert.api_key
));
}
if default_advert.url != "https://icaptcha.gitlawb.com/v2" {
failures.push(format!(
"arm 3 (operator + default advert): expected url=https://icaptcha.gitlawb.com/v2, got {}",
default_advert.url
));
}
if operator_fallback.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 4 (operator fallback, no advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_fallback.api_key
));
}
if operator_fallback.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 4 (operator fallback, no advert): expected url=https://icap.mynode.example, got {}",
operator_fallback.url
));
}
if operator_hostile_advert.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 5 (operator + hostile advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_hostile_advert.api_key
));
}
if operator_hostile_advert.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 5 (operator + hostile advert): expected url=https://icap.mynode.example, got {}",
operator_hostile_advert.url
));
}
if operator_diff_port.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 6 (operator + different port advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_diff_port.api_key
));
}
if operator_diff_port.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 6 (operator + different port advert): expected url=https://icap.mynode.example, got {}",
operator_diff_port.url
));
}
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
}
}
-130
View File
@@ -1,130 +0,0 @@
//! Proof-of-work solver for the iCaptcha answer step.
//!
//! iCaptcha requires a small proof-of-work with each answer so that minting a
//! proof costs CPU — a per-proof cost a distributed abuser cannot dodge by
//! rotating IPs. The work is bound to the per-challenge id: find a `nonce` such
//! that `sha256("{challenge}:{nonce}")` has at least `difficulty` leading zero
//! bits. This mirrors `icaptcha/src/pow.ts`.
//!
//! At the service default (~20 bits) this is well under a second for a single
//! solve; we still cap the search so a hostile/misconfigured difficulty can
//! never hang the client.
use sha2::{Digest, Sha256};
/// PoW parameters advertised by the service in a challenge (mirrors the
/// service's `PowChallenge`).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PowChallenge {
/// Only `sha256-leading-zero-bits` is understood; other algorithms are
/// rejected by [`solve`] rather than silently mis-solved.
pub algorithm: String,
/// The string to hash against (the per-challenge id).
pub challenge: String,
/// Required leading zero bits.
pub difficulty: u32,
}
/// Hard cap on nonce search iterations. 2^26 ≈ 67M keeps a ~20-bit target (~1M
/// expected hashes) comfortably solvable while bounding worst-case work if the
/// service ever advertises an unexpectedly high difficulty.
const MAX_ITERS: u64 = 1 << 26;
const ALGORITHM: &str = "sha256-leading-zero-bits";
/// Leading zero bits of a byte slice.
fn leading_zero_bits(bytes: &[u8]) -> u32 {
let mut bits = 0;
for &b in bytes {
if b == 0 {
bits += 8;
} else {
bits += b.leading_zeros();
break;
}
}
bits
}
/// sha256(`{challenge}:{nonce}`).
fn pow_hash(challenge: &str, nonce: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(challenge.as_bytes());
hasher.update(b":");
hasher.update(nonce.as_bytes());
hasher.finalize().into()
}
/// Solve the PoW, returning a nonce whose hash meets the difficulty. Returns
/// `None` for an unknown algorithm or if no nonce is found within the cap (a
/// difficulty far above what the service uses in practice).
pub fn solve(pow: &PowChallenge) -> Option<String> {
if pow.algorithm != ALGORITHM {
tracing::warn!(algorithm = %pow.algorithm, "unknown iCaptcha PoW algorithm; cannot solve");
return None;
}
if pow.difficulty == 0 {
return Some("0".to_string());
}
for i in 0..MAX_ITERS {
let nonce = format!("{i:x}");
if leading_zero_bits(&pow_hash(&pow.challenge, &nonce)) >= pow.difficulty {
return Some(nonce);
}
}
tracing::warn!(
difficulty = pow.difficulty,
max_iters = MAX_ITERS,
"iCaptcha PoW not solved within iteration cap"
);
None
}
#[cfg(test)]
mod tests {
use super::*;
fn pow(challenge: &str, difficulty: u32) -> PowChallenge {
PowChallenge {
algorithm: ALGORITHM.to_string(),
challenge: challenge.to_string(),
difficulty,
}
}
#[test]
fn leading_zero_bits_counts_across_bytes() {
assert_eq!(leading_zero_bits(&[0xff]), 0);
assert_eq!(leading_zero_bits(&[0x7f]), 1);
assert_eq!(leading_zero_bits(&[0x01]), 7);
assert_eq!(leading_zero_bits(&[0x00, 0xff]), 8);
assert_eq!(leading_zero_bits(&[0x00, 0x01]), 15);
}
#[test]
fn solves_and_the_solution_verifies() {
let p = pow("challenge-abc", 12);
let nonce = solve(&p).expect("should solve a 12-bit target");
assert!(leading_zero_bits(&pow_hash(&p.challenge, &nonce)) >= 12);
}
#[test]
fn solution_is_bound_to_the_challenge() {
// A nonce solving one challenge should (overwhelmingly) not solve another
// at the same difficulty — the work is challenge-specific.
let nonce = solve(&pow("challenge-A", 12)).unwrap();
assert!(leading_zero_bits(&pow_hash("challenge-B", &nonce)) < 12);
}
#[test]
fn rejects_unknown_algorithm() {
let mut p = pow("c", 8);
p.algorithm = "scrypt".to_string();
assert!(solve(&p).is_none());
}
#[test]
fn zero_difficulty_is_trivially_solved() {
assert_eq!(solve(&pow("c", 0)).as_deref(), Some("0"));
}
}
-270
View File
@@ -1,270 +0,0 @@
//! Deterministic solvers for the computational iCaptcha challenge types.
//!
//! Prompt/answer formats mirror the iCaptcha service generators
//! (`icaptcha/src/generators/{arithmetic,algebra,sequence}.ts`). The service
//! grades numerics by value (`Number(a) === Number(b)`), so returning the plain
//! integer string is sufficient. Anagram/logic (need a dictionary/parser) and
//! the LLM types are intentionally NOT solved here — the client requests only
//! these three types and falls back to a hook/interactive prompt otherwise.
/// Solve a challenge of the given `type` from its prompt. Returns `None` for
/// types we don't solve locally or when the prompt can't be parsed.
pub fn solve(challenge_type: &str, prompt: &str) -> Option<String> {
match challenge_type {
"arithmetic" => solve_arithmetic(prompt).map(|n| n.to_string()),
"algebra" => solve_algebra(prompt).map(|n| n.to_string()),
"sequence" => solve_sequence(prompt).map(|n| n.to_string()),
_ => None,
}
}
/// `What is 12 + 7 - 3?` -> evaluate the additive chain left to right.
fn solve_arithmetic(prompt: &str) -> Option<i64> {
let expr = prompt
.trim()
.strip_prefix("What is ")?
.trim_end_matches('?')
.trim();
let mut tokens = expr.split_whitespace();
let mut acc: i64 = tokens.next()?.parse().ok()?;
while let Some(op) = tokens.next() {
let n: i64 = tokens.next()?.parse().ok()?;
match op {
"+" => acc += n,
"-" => acc -= n,
_ => return None,
}
}
Some(acc)
}
/// `Solve for x: 3x + 4 = 19` (and the `=`-both-sides and `a(x + b)` variants).
/// Parses each side into `coeff*x + const`, then x = (cR - cL) / (aL - aR).
fn solve_algebra(prompt: &str) -> Option<i64> {
let eq = prompt.trim().strip_prefix("Solve for x:")?.trim();
let (lhs, rhs) = eq.split_once('=')?;
let (al, cl) = parse_linear(lhs.trim())?;
let (ar, cr) = parse_linear(rhs.trim())?;
let denom = al - ar;
if denom == 0 {
return None;
}
let num = cr - cl;
if num % denom != 0 {
return None;
}
Some(num / denom)
}
/// Parse a linear expression in `x` into `(coeff_of_x, constant)`.
/// Handles `Nx`, `x`, integer constants, and a single `N(x ± M)` product,
/// with `+`/`-` separators (the formats the generator emits).
fn parse_linear(s: &str) -> Option<(i64, i64)> {
let s = s.trim();
// Parenthesized product: a(x ± b) -> coeff a, const a*(±b).
if let Some(open) = s.find('(') {
let a: i64 = s[..open].trim().parse().ok()?;
let close = s.find(')')?;
let inner = &s[open + 1..close]; // "x + 4" / "x - 4"
let mut it = inner.split_whitespace();
if it.next()? != "x" {
return None;
}
let (coeff_inner, const_inner) = match it.next() {
None => (1, 0),
Some(op) => {
let m: i64 = it.next()?.parse().ok()?;
match op {
"+" => (1, m),
"-" => (1, -m),
_ => return None,
}
}
};
return Some((a * coeff_inner, a * const_inner));
}
// Sum of `±`-separated terms.
let mut coeff = 0i64;
let mut konst = 0i64;
let mut sign = 1i64;
for tok in s.split_whitespace() {
match tok {
"+" => sign = 1,
"-" => sign = -1,
t => {
if let Some(cpart) = t.strip_suffix('x') {
let c: i64 = match cpart {
"" | "+" => 1,
"-" => -1,
_ => cpart.parse().ok()?,
};
coeff += sign * c;
} else {
let n: i64 = t.parse().ok()?;
konst += sign * n;
}
sign = 1;
}
}
}
Some((coeff, konst))
}
/// `What is the next number in this sequence? 2, 4, 6, 8, 10, ?`
fn solve_sequence(prompt: &str) -> Option<i64> {
let tail = prompt.split_once("sequence?")?.1;
let nums: Vec<i64> = tail
.split(',')
.filter_map(|t| t.trim().parse::<i64>().ok())
.collect();
if nums.len() < 3 {
return None;
}
next_in_sequence(&nums)
}
fn next_in_sequence(n: &[i64]) -> Option<i64> {
let last = *n.last()?;
// 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[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] == n[i - 1] + n[i - 2]) {
return Some(n[n.len() - 1] + n[n.len() - 2]);
}
// Squares: all perfect squares with consecutive roots.
let roots: Option<Vec<i64>> = n.iter().map(|&v| isqrt_exact(v)).collect();
if let Some(roots) = roots {
if roots.windows(2).all(|w| w[1] == w[0] + 1) {
let nr = roots[roots.len() - 1] + 1;
return Some(nr * nr);
}
}
// Alternating sign over an arithmetic magnitude (generator starts positive).
let signs_alternate = n
.iter()
.enumerate()
.all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 });
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
}
/// Exact integer square root, or `None` if `v` isn't a perfect square.
fn isqrt_exact(v: i64) -> Option<i64> {
if v < 0 {
return None;
}
let r = (v as f64).sqrt().round() as i64;
[r - 1, r, r + 1]
.into_iter()
.find(|&cand| cand >= 0 && cand * cand == v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arithmetic_chains() {
assert_eq!(
solve("arithmetic", "What is 12 + 7 - 3?").as_deref(),
Some("16")
);
assert_eq!(solve("arithmetic", "What is 5?").as_deref(), Some("5"));
assert_eq!(
solve("arithmetic", "What is 100 - 40 - 35 + 2?").as_deref(),
Some("27")
);
}
#[test]
fn algebra_linear() {
assert_eq!(
solve("algebra", "Solve for x: 3x + 4 = 19").as_deref(),
Some("5")
);
assert_eq!(
solve("algebra", "Solve for x: 5x - 8 = 12").as_deref(),
Some("4")
);
// both sides: 2x + 16 = 5x - 8 -> 3x = 24 -> x=8
assert_eq!(
solve("algebra", "Solve for x: 2x + 16 = 5x - 8").as_deref(),
Some("8")
);
// parenthesized: 3(x - 4) = 9 -> x-4=3 -> x=7
assert_eq!(
solve("algebra", "Solve for x: 3(x - 4) = 9").as_deref(),
Some("7")
);
// negative solution: 2x + 10 = 4 -> x=-3
assert_eq!(
solve("algebra", "Solve for x: 2x + 10 = 4").as_deref(),
Some("-3")
);
}
#[test]
fn sequence_patterns() {
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 2, 4, 6, 8, 10, ?"
)
.as_deref(),
Some("12")
);
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 3, 6, 12, 24, 48, ?"
)
.as_deref(),
Some("96")
);
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 1, 4, 9, 16, 25, ?"
)
.as_deref(),
Some("36")
);
assert_eq!(
solve(
"sequence",
"What is the next number in this sequence? 1, 1, 2, 3, 5, ?"
)
.as_deref(),
Some("8")
);
}
#[test]
fn unsupported_types_return_none() {
assert_eq!(solve("anagram", "Unscramble: tca"), None);
assert_eq!(solve("riddle", "What has keys but no locks?"), None);
}
}
@@ -1,94 +0,0 @@
//! INV-19 guard: the icaptcha flow must actually call the service (consume the
//! mock), not short-circuit or make a live call. If a future change let
//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which
//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go
//! RED because the mocked endpoints were never hit.
//!
//! The mock-consumed assertions alone only prove the mock was hit; they cannot
//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or
//! any other origin (a different host the mock never observes). To make the
//! "no live call" half of the guard load-bearing, the test blackholes every
//! non-loopback destination: `NO_PROXY` lets the loopback mock through while a
//! closed proxy port routes any other host nowhere, so the client contacting
//! anything but the injected mock fails the request instead of silently
//! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED.
//!
//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific
//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so
//! a runner with a working `HTTPS_PROXY` in its environment would route an https
//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback
//! mock is still consumed and this test stayed green. So we blackhole the
//! scheme-specific variables too. No restore is needed: this file has a single
//! `#[test]`, so it runs in its own process and the override never races a
//! sibling test.
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
#[test]
fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
// Blackhole every non-loopback origin: the loopback mock bypasses the proxy
// (NO_PROXY); any other host is forced through a closed port and fails.
// reqwest reads these at client-build time, and obtain_proof builds its
// client fresh, so this bounds the transport for the whole flow.
//
// Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest
// gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so
// an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a
// runner whose environment already has a working HTTPS_PROXY.
let blackhole = "http://127.0.0.1:1";
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
std::env::set_var("no_proxy", "127.0.0.1,localhost");
for var in [
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
] {
std::env::set_var(var, blackhole);
}
let mut server = mockito::Server::new();
// The two endpoints the flow hits, each expected exactly once.
let challenge = server
.mock("POST", "/v1/challenge")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"challengeId":"c1","type":"anagram","difficulty":1,"prompt":"listen","token":"tok-1"}"#,
)
.expect(1)
.create();
let answer = server
.mock("POST", "/v1/answer")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"passed","proof":"PROOF-XYZ"}"#)
.expect(1)
.create();
let cfg = IcaptchaCfg {
url: server.url(),
did: "did:key:zTEST".to_string(),
level: 1,
api_key: None,
};
// "anagram" is not a built-in solvable type, so the flow consults this
// solver callback. The answer body itself does not matter: the mocked
// /v1/answer returns "passed" regardless of what is submitted.
let solve = |_c: &Challenge| Some("silent".to_string());
let solver: &dyn Fn(&Challenge) -> Option<String> = &solve;
let proof = obtain_proof(&cfg, Some(solver))
.expect("obtain_proof should complete against the mocked service");
assert_eq!(proof, "PROOF-XYZ");
// INV-19: prove the client actually called the mocked endpoints. Had it made
// a live call or skipped the network, these would fail (mock never hit).
challenge.assert();
answer.assert();
}
+1 -1
View File
@@ -120,7 +120,7 @@ Positive:
Risks:
- Many commands default to the public node; `git-remote-gitlawb` defaults to localhost. This split should be called out in README examples.
- `gl sync trigger` requires a local identity and always sends a signed request; the `/api/v1/sync/trigger` route rejects unsigned calls, so the command fails locally when no identity is configured.
- `gl sync trigger` signs the request when a local identity is available and falls back to the legacy unsigned request for live compatibility.
- Several CLI commands parse dynamic JSON responses permissively; good for compatibility, but error messages can hide response-shape regressions.
## CI and release readiness
-10
View File
@@ -58,16 +58,6 @@ services:
GITLAWB_P2P_PORT: "${p2p_port}"
GITLAWB_REPOS_DIR: /data/repos
GITLAWB_KEY: /data/keys/identity.pem
# Only trust X-Forwarded-For when the Caddy sidecar above is actually
# deployed (domain_name != ""). Caddy appends the real client as the
# rightmost hop, letting the push rate limiter key on the true client IP
# rather than Caddy's container address. Without Caddy, this header is
# fully client-controlled and must not be trusted.
GITLAWB_TRUSTED_PROXY: "${domain_name != "" ? "x-forwarded-for" : ""}"
# iCaptcha proof-of-intelligence gate (spam protection on create/fork/register)
ICAPTCHA_MODE: ${icaptcha_mode}
ICAPTCHA_PUBKEY: ${icaptcha_pubkey}
ICAPTCHA_REQUIRED_LEVEL: "${icaptcha_required_level}"
GITLAWB_PUBLIC_URL: $${GITLAWB_PUBLIC_URL}
GITLAWB_BOOTSTRAP_PEERS: $${GITLAWB_BOOTSTRAP_PEERS}
GITLAWB_AUTO_SYNC: $${GITLAWB_AUTO_SYNC}
+10 -13
View File
@@ -290,19 +290,16 @@ locals {
)
compose_yaml = templatefile("${path.module}/compose.yaml.tftpl", {
image_repo = local.image_repo
image_tag = var.image_tag
domain_name = var.domain_name
db_host = var.use_rds ? aws_db_instance.node[0].address : ""
gitlawb_port = var.gitlawb_port
p2p_port = var.gitlawb_p2p_port
metrics_port = var.metrics_port
expose_metrics = local.expose_metrics
pg_user = var.postgres_user
pg_db = var.postgres_db
icaptcha_mode = var.icaptcha_mode
icaptcha_pubkey = var.icaptcha_pubkey
icaptcha_required_level = var.icaptcha_required_level
image_repo = local.image_repo
image_tag = var.image_tag
domain_name = var.domain_name
db_host = var.use_rds ? aws_db_instance.node[0].address : ""
gitlawb_port = var.gitlawb_port
p2p_port = var.gitlawb_p2p_port
metrics_port = var.metrics_port
expose_metrics = local.expose_metrics
pg_user = var.postgres_user
pg_db = var.postgres_db
})
user_data = templatefile("${path.module}/user-data.sh.tftpl", {
-22
View File
@@ -81,28 +81,6 @@ variable "image_tag" {
default = "latest"
}
# ---------------------------------------------------------------------------
# iCaptcha proof-of-intelligence gate (spam protection on writes)
# ---------------------------------------------------------------------------
variable "icaptcha_mode" {
description = "iCaptcha gate mode: off | shadow | enforce. The live gitlawb network enforces."
type = string
default = "enforce"
}
variable "icaptcha_pubkey" {
description = "base64url Ed25519 public key of the iCaptcha service, pinned so startup doesn't depend on fetching it. This is the live icaptcha.gitlawb.com key."
type = string
default = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8"
}
variable "icaptcha_required_level" {
description = "Minimum iCaptcha proof level required to pass the gate."
type = number
default = 3
}
# ---------------------------------------------------------------------------
# Networking / ingress
# ---------------------------------------------------------------------------
-14
View File
@@ -16,10 +16,6 @@ primary_region = "iad"
GITLAWB_BOOTSTRAP_PEERS = "https://node.gitlawb.com,https://node2.gitlawb.com,https://node3.gitlawb.com"
GITLAWB_AUTO_SYNC = "true"
GITLAWB_MAX_PACK_BYTES = "524288000"
# On Fly: the edge sets Fly-Client-IP (overwriting any client-supplied value),
# so the push rate limiter keys on the real client. Without this the limiter
# would fall back to the socket peer (Fly's proxy) and over-throttle.
GITLAWB_TRUSTED_PROXY = "fly"
[http_service]
internal_port = 7545
@@ -38,16 +34,6 @@ primary_region = "iad"
# slots (hard_limit) — root factor in the 2026-06-12 prod outage
idle_timeout = 120
[[http_service.checks]]
# Gate routing (and deploys) on real readiness: /ready probes the DB, so a
# node stuck retrying a dead or misconfigured database is never routed to,
# and a deploy with a bad DATABASE_URL fails visibly instead of going green.
interval = "15s"
timeout = "5s"
grace_period = "75s"
method = "GET"
path = "/ready"
[[services]]
protocol = "udp"
internal_port = 7546
-65
View File
@@ -1,65 +0,0 @@
# fly.toml app configuration file generated for gitlawb-node-2 on 2026-07-22T09:47:18+08:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'gitlawb-node-2'
primary_region = 'sjc'
[build]
dockerfile = '../../Dockerfile'
[env]
GITLAWB_AUTO_SYNC = 'true'
GITLAWB_BOOTSTRAP_PEERS = 'https://node.gitlawb.com,https://node3.gitlawb.com'
GITLAWB_HOST = '0.0.0.0'
GITLAWB_KEY = '/data/keys/identity.pem'
GITLAWB_MAX_PACK_BYTES = '524288000'
GITLAWB_P2P_PORT = '7546'
GITLAWB_PORT = '7545'
GITLAWB_PUBLIC_URL = 'https://node2.gitlawb.com'
GITLAWB_REPOS_DIR = '/data/repos'
ICAPTCHA_MODE = 'enforce'
ICAPTCHA_PUBKEY = 'xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8'
ICAPTCHA_REQUIRED_LEVEL = '3'
[[mounts]]
source = 'gitlawb_data'
destination = '/data'
[http_service]
internal_port = 7545
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 1
[http_service.concurrency]
type = 'connections'
hard_limit = 500
soft_limit = 400
[http_service.http_options]
idle_timeout = 120
[[http_service.checks]]
interval = '15s'
timeout = '5s'
grace_period = '30s'
method = 'GET'
path = '/ready'
[[services]]
protocol = 'udp'
internal_port = 7546
[[services.ports]]
port = 7546
[[vm]]
size = 'shared-cpu-1x'
memory = '1024'
[[metrics]]
port = 9091
path = '/metrics'
-65
View File
@@ -1,65 +0,0 @@
# fly.toml app configuration file generated for gitlawb-node-3 on 2026-07-22T09:47:19+08:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'gitlawb-node-3'
primary_region = 'nrt'
[build]
dockerfile = '../../Dockerfile'
[env]
GITLAWB_AUTO_SYNC = 'true'
GITLAWB_BOOTSTRAP_PEERS = 'https://node.gitlawb.com,https://node2.gitlawb.com'
GITLAWB_HOST = '0.0.0.0'
GITLAWB_KEY = '/data/keys/identity.pem'
GITLAWB_MAX_PACK_BYTES = '524288000'
GITLAWB_P2P_PORT = '7546'
GITLAWB_PORT = '7545'
GITLAWB_PUBLIC_URL = 'https://node3.gitlawb.com'
GITLAWB_REPOS_DIR = '/data/repos'
ICAPTCHA_MODE = 'enforce'
ICAPTCHA_PUBKEY = 'xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8'
ICAPTCHA_REQUIRED_LEVEL = '3'
[[mounts]]
source = 'gitlawb_data'
destination = '/data'
[http_service]
internal_port = 7545
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 1
[http_service.concurrency]
type = 'connections'
hard_limit = 500
soft_limit = 400
[http_service.http_options]
idle_timeout = 120
[[http_service.checks]]
interval = '15s'
timeout = '5s'
grace_period = '30s'
method = 'GET'
path = '/ready'
[[services]]
protocol = 'udp'
internal_port = 7546
[[services.ports]]
port = 7546
[[vm]]
size = 'shared-cpu-1x'
memory = '1024'
[[metrics]]
port = 9091
path = '/metrics'
-14
View File
@@ -193,19 +193,5 @@ else
echo " gl quickstart"
fi
# oh-my-zsh's default git plugin aliases gl='git pull', which silently shadows
# the gl binary in every interactive zsh (aliases beat PATH). Detect the common
# setup and say so now, at install time, instead of letting the first `gl`
# print git's baffling "fatal: not a git repository".
if [ -f "$HOME/.zshrc" ] && [ -d "$HOME/.oh-my-zsh/plugins/git" ] \
&& grep -Eq '^[[:space:]]*plugins=\(([^)]*[[:space:]])?git([[:space:]][^)]*)?\)' "$HOME/.zshrc" \
&& ! grep -Eq '^([^#]*[[:space:];&|])?unalias[[:space:]]+([^;&|#]*[[:space:]])?gl([[:space:]]|$)' "$HOME/.zshrc"; then
echo ""
echo "NOTE: oh-my-zsh's git plugin aliases gl='git pull', which will shadow"
echo "the gl command in interactive shells. To use gl by name, run:"
echo ""
echo " echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc"
fi
echo ""
echo "Docs: https://docs.gitlawb.com"
+1 -1
View File
@@ -46,7 +46,7 @@ build_darwin_arm64() {
echo "==> Building darwin-arm64 (native cargo)..."
cd "$ROOT"
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --locked -p gl -p git-remote-gitlawb
cargo build --release -p gl -p git-remote-gitlawb
cp target/release/gl "$OUT/gl-darwin-arm64"
cp target/release/git-remote-gitlawb "$OUT/git-remote-gitlawb-darwin-arm64"
chmod +x "$OUT/gl-darwin-arm64" "$OUT/git-remote-gitlawb-darwin-arm64"
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env bash
#
# Dependency-purity gate for the `gitlawb-core` crate.
#
# gitlawb-core is embedded by every consumer (the gl CLI, git-remote-gitlawb,
# the node daemon), so it must stay lean. This script recomputes gitlawb-core's
# NORMAL (non-dev, non-build) transitive dependency set and fails if it contains
# any crate not present in ci/gitlawb-core-allowed-deps.txt.
#
# Hard-fail direction: a crate present now but NOT allowlisted. That is the case
# the gate exists to catch (core silently gaining a heavy dependency).
# Informational only: an allowlisted crate no longer present (stale entry) — a
# legitimate dependency removal should not red CI, so it is reported, not failed.
#
# Runnable from anywhere in the repo.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
ALLOW="$ROOT/ci/gitlawb-core-allowed-deps.txt"
if [ ! -f "$ALLOW" ]; then
echo "ERROR: allowlist not found at $ALLOW" >&2
exit 1
fi
# `cargo tree` resolves WITHOUT --locked on purpose, unlike the blocking cargo
# jobs in pr-checks. This gate answers one question, whether gitlawb-core's
# dependency closure gained something unallowlisted, and a stale Cargo.lock
# would red it for a reason that has nothing to do with that closure. Lock
# staleness is already caught by the --locked cargo jobs, so it does not need a
# second gate here.
#
# Resolving can refresh Cargo.lock as a side effect, so snapshot and restore
# it: this check must never leave the working tree dirty when run locally.
lock_backup="$(mktemp)"
cp "$ROOT/Cargo.lock" "$lock_backup"
restore_lock() { cp "$lock_backup" "$ROOT/Cargo.lock"; rm -f "$lock_backup"; }
trap restore_lock EXIT
# Current normal-dependency closure of gitlawb-core, one crate name per line.
# Must match the regen command documented in the allowlist header exactly.
current="$(
cargo tree -p gitlawb-core --edges normal --prefix none --manifest-path "$ROOT/Cargo.toml" \
| sed -E 's/ v[0-9].*$//' \
| grep -v '^gitlawb-core$' \
| sort -u
)"
# Allowlist with comments and blank lines stripped.
allowed="$(grep -vE '^[[:space:]]*(#|$)' "$ALLOW" | sort -u)"
# comm needs sorted input; both sides are sorted above.
offenders="$(comm -23 <(printf '%s\n' "$current") <(printf '%s\n' "$allowed"))"
stale="$(comm -13 <(printf '%s\n' "$current") <(printf '%s\n' "$allowed"))"
if [ -n "$stale" ]; then
echo "NOTE: allowlisted crates no longer in gitlawb-core's dependency tree"
echo " (safe to prune from ci/gitlawb-core-allowed-deps.txt):"
printf ' %s\n' $stale
echo
fi
if [ -n "$offenders" ]; then
{
echo "ERROR: gitlawb-core gained dependencies not on the allowlist:"
printf ' %s\n' $offenders
echo
echo "gitlawb-core must stay embeddable and lean. If a new dependency is"
echo "intentional, add it to ci/gitlawb-core-allowed-deps.txt (regen command"
echo "is in that file's header). Otherwise, drop the dependency."
} >&2
exit 1
fi
echo "gitlawb-core dependency purity: OK ($(printf '%s\n' "$current" | wc -l | tr -d ' ') normal deps, all allowlisted)."