mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a46e8cbb7 | ||
|
|
e4f5e87e0b | ||
|
|
38e654a289 | ||
|
|
4f814459cc | ||
|
|
d45318281c |
No files matched your search
@@ -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=
|
||||
@@ -89,12 +72,6 @@ GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
|
||||
# rolling upgrades so existing live nodes can still communicate.
|
||||
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
|
||||
|
||||
# Require the authenticated pusher to be the repo owner on git-receive-pack.
|
||||
# A valid did:key signature is authentication, not authorization: anyone can
|
||||
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
|
||||
# Keep false until the repo owner is ready for owner-only writes.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=false
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
|
||||
GITLAWB_P2P_BOOTSTRAP=
|
||||
@@ -107,64 +84,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
|
||||
|
||||
# ── iCaptcha proof-of-intelligence gate ───────────────────────────────────
|
||||
# Optional gate on create_repo + register: require callers to present an
|
||||
# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com.
|
||||
# Default off = fully inert (no behavior change).
|
||||
# off - gate disabled (default)
|
||||
# shadow - verify + log would-be rejections, but always allow
|
||||
# enforce - reject requests without a valid, strong-enough proof
|
||||
ICAPTCHA_MODE=off
|
||||
# Optional base64url Ed25519 public key override; if unset it is fetched from
|
||||
# ICAPTCHA_URL/v1/pubkey at startup. Recommended in production: pinning the key
|
||||
# removes the startup dependency on the iCaptcha service being reachable and
|
||||
# avoids silently running inert if that one-time fetch fails.
|
||||
ICAPTCHA_PUBKEY=
|
||||
# Minimum proof difficulty level required to pass.
|
||||
ICAPTCHA_REQUIRED_LEVEL=3
|
||||
# Base URL of the iCaptcha service.
|
||||
ICAPTCHA_URL=https://icaptcha.gitlawb.com
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -2,10 +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:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -58,23 +54,6 @@ jobs:
|
||||
toolchain: [stable, beta]
|
||||
# Beta is informational: an upstream beta regression should warn, not block merges.
|
||||
continue-on-error: ${{ matrix.toolchain == 'beta' }}
|
||||
# Postgres for the DB-backed integration tests (#[sqlx::test] provisions an
|
||||
# isolated database per test off this connection). The health-check gate keeps
|
||||
# `cargo test` from racing a not-yet-ready server.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: gitlawb_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -92,8 +71,6 @@ jobs:
|
||||
key: ${{ matrix.toolchain }}
|
||||
|
||||
- name: cargo test
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
|
||||
run: cargo test --workspace
|
||||
|
||||
build-release:
|
||||
@@ -137,30 +114,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
|
||||
@@ -216,63 +173,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 -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,11 +1,7 @@
|
||||
name: PR Triage
|
||||
|
||||
# Runs on pull_request_target, not pull_request: fork PRs get a read-only
|
||||
# GITHUB_TOKEN under pull_request regardless of the permissions below, so the
|
||||
# label/comment writes 403. This job never checks out or runs PR head code (it
|
||||
# only reads context.payload and the files API), so pull_request_target is safe.
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
@@ -24,7 +20,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;
|
||||
@@ -68,8 +64,8 @@ jobs:
|
||||
if (isFork && touchesWorkflows) want.add("workflow-change");
|
||||
|
||||
const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs"));
|
||||
// A test can live in a tests/ dir, a *_test.rs file, or (the common Rust
|
||||
// pattern) an inline #[test] / #[cfg(test)] block added inside the source file.
|
||||
// A test can live in a tests/ dir, a *_test.rs file, or — the common Rust
|
||||
// pattern — an inline #[test] / #[cfg(test)] block added inside the source file.
|
||||
const addsInlineTest = files.some(f =>
|
||||
f.filename.endsWith(".rs") && f.patch &&
|
||||
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
|
||||
@@ -108,7 +104,7 @@ jobs:
|
||||
|
||||
const reasons = {
|
||||
"needs-issue": "Link the issue this addresses (`Closes #123`). For protocol changes, open an issue first.",
|
||||
"needs-description": "Add a short summary of what changes and why: the template's Summary and Motivation sections.",
|
||||
"needs-description": "Add a short summary of what changes and why — the template's Summary and Motivation sections.",
|
||||
"needs-tests": "This changes Rust source but no tests changed. Tests are required for fixes and strongly encouraged for features.",
|
||||
};
|
||||
const lines = advisory.map(l => `- ${reasons[l]}`).join("\n");
|
||||
|
||||
+55
-595
@@ -4,34 +4,14 @@ 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 }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Least privilege by default; every job opts in to exactly what it needs.
|
||||
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:
|
||||
@@ -44,64 +24,26 @@ jobs:
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
||||
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.3.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 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 }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
- 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
|
||||
@@ -113,130 +55,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:
|
||||
@@ -244,9 +101,6 @@ jobs:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Windows is best-effort: a failure there must not fail the job or block the
|
||||
# downstream npm/Homebrew jobs that only consume the unix artifacts.
|
||||
continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -255,24 +109,17 @@ 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
|
||||
os: windows-latest
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
@@ -280,50 +127,38 @@ 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
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
# The gitlawb-node daemon is a Linux/macOS service; on Windows we ship only
|
||||
# the two CLI binaries. A Windows-only failure must not block the release.
|
||||
- name: Determine binaries
|
||||
shell: bash
|
||||
run: |
|
||||
BINS="gl git-remote-gitlawb gitlawb-node"
|
||||
case "${{ matrix.target }}" in *windows*) BINS="gl git-remote-gitlawb" ;; esac
|
||||
echo "BINS=$BINS" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=""
|
||||
for b in $BINS; do args="$args -p $b"; done
|
||||
cargo build --release --target ${{ matrix.target }} $args
|
||||
run: cargo build --release --target ${{ matrix.target }} -p gl -p git-remote-gitlawb -p gitlawb-node
|
||||
|
||||
# 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
|
||||
VERSION="${{ needs.release-please.outputs.version }}"
|
||||
BIN_DIR="target/${{ matrix.target }}/release"
|
||||
EXE=""
|
||||
case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac
|
||||
for bin in $BINS; do
|
||||
for bin in gl git-remote-gitlawb gitlawb-node; do
|
||||
echo "== $bin --version =="
|
||||
out="$("$BIN_DIR/$bin$EXE" --version)"
|
||||
out="$("$BIN_DIR/$bin" --version)"
|
||||
echo "$out"
|
||||
# Each binary prints "<name> <version>"; assert the released version is present.
|
||||
grep -qF "$VERSION" <<<"$out" || {
|
||||
@@ -333,401 +168,26 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Package
|
||||
id: pkg
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ matrix.target }}"
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}"
|
||||
BIN_DIR="target/${TARGET}/release"
|
||||
EXE=""
|
||||
case "$TARGET" in *windows*) EXE=".exe" ;; esac
|
||||
|
||||
# Portable sha256 → "<hash> <file>" so install scripts and brew can parse it.
|
||||
sha256_file() {
|
||||
local f="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$f"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$f"
|
||||
else
|
||||
local h
|
||||
h=$(certutil -hashfile "$f" SHA256 | sed -n 2p | tr -d ' \r')
|
||||
printf '%s %s\n' "$h" "$f"
|
||||
fi
|
||||
}
|
||||
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${{ matrix.target }}"
|
||||
mkdir -p "dist/$NAME"
|
||||
for bin in $BINS; do
|
||||
cp "$BIN_DIR/$bin$EXE" "dist/$NAME/"
|
||||
done
|
||||
cp "target/${{ matrix.target }}/release/gl" "dist/$NAME/"
|
||||
cp "target/${{ matrix.target }}/release/git-remote-gitlawb" "dist/$NAME/"
|
||||
cp "target/${{ matrix.target }}/release/gitlawb-node" "dist/$NAME/"
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "dist/$NAME/"
|
||||
|
||||
cd dist
|
||||
case "$TARGET" in
|
||||
*windows*)
|
||||
# 7z is preinstalled on windows-latest runners.
|
||||
7z a -tzip "$NAME.zip" "$NAME" >/dev/null
|
||||
sha256_file "$NAME.zip" > "$NAME.zip.sha256"
|
||||
;;
|
||||
*)
|
||||
tar czf "$NAME.tar.gz" "$NAME"
|
||||
sha256_file "$NAME.tar.gz" > "$NAME.tar.gz.sha256"
|
||||
;;
|
||||
esac
|
||||
tar czf "$NAME.tar.gz" "$NAME"
|
||||
shasum -a 256 "$NAME.tar.gz" > "$NAME.tar.gz.sha256"
|
||||
echo "asset=dist/$NAME.tar.gz" >> "$GITHUB_OUTPUT"
|
||||
echo "checksum=dist/$NAME.tar.gz.sha256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: ${{ needs.release-please.outputs.tag_name }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/*.sha256
|
||||
|
||||
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 != '')
|
||||
) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm trusted publishing (OIDC) + provenance
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.npm_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: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm platform package -> Rust target triple (unix only; Windows is not
|
||||
# published to npm).
|
||||
MAP="
|
||||
gl-darwin-arm64:aarch64-apple-darwin
|
||||
gl-darwin-x64:x86_64-apple-darwin
|
||||
gl-linux-arm64:aarch64-unknown-linux-musl
|
||||
gl-linux-x64:x86_64-unknown-linux-musl
|
||||
"
|
||||
mkdir -p _dl
|
||||
for entry in $MAP; do
|
||||
pkg="${entry%%:*}"
|
||||
target="${entry#*:}"
|
||||
archive="gitlawb-node-${VERSION}-${target}.tar.gz"
|
||||
echo "==> $pkg <- $archive"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$archive" --dir _dl --clobber
|
||||
tar -xzf "_dl/$archive" -C _dl
|
||||
src="_dl/gitlawb-node-${VERSION}-${target}"
|
||||
cp "$src/gl" "npm/packages/$pkg/gl"
|
||||
cp "$src/git-remote-gitlawb" "npm/packages/$pkg/git-remote-gitlawb"
|
||||
chmod +x "npm/packages/$pkg/gl" "npm/packages/$pkg/git-remote-gitlawb"
|
||||
done
|
||||
|
||||
- name: Set versions
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const v = process.env.VERSION;
|
||||
for (const p of fs.readdirSync("npm/packages")) {
|
||||
const f = `npm/packages/${p}/package.json`;
|
||||
const pkg = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||
pkg.version = v;
|
||||
if (pkg.optionalDependencies) {
|
||||
for (const k of Object.keys(pkg.optionalDependencies)) {
|
||||
pkg.optionalDependencies[k] = v;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(f, JSON.stringify(pkg, null, 2) + "\n");
|
||||
}
|
||||
'
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
VERSION: ${{ steps.rel.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.
|
||||
# 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.
|
||||
for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do
|
||||
name="@gitlawb/$pkg"
|
||||
if npm view "$name@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "==> $name@$VERSION already published, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "==> npm publish $name@$VERSION"
|
||||
npm publish "npm/packages/$pkg" --provenance --access public
|
||||
done
|
||||
|
||||
homebrew-bump:
|
||||
name: Bump Homebrew tap
|
||||
needs: [release-please, release-binaries]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
env:
|
||||
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
run: |
|
||||
if [ -z "${HOMEBREW_TAP_PAT:-}" ]; then
|
||||
echo "::warning::HOMEBREW_TAP_PAT is not set — skipping Homebrew bump."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout tap repo
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Gitlawb/homebrew-tap
|
||||
token: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
path: tap
|
||||
persist-credentials: false
|
||||
|
||||
- name: Regenerate formula
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
mkdir -p _sums
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern '*.tar.gz.sha256' --dir _sums --clobber
|
||||
sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; }
|
||||
SHA_MAC_ARM="$(sha aarch64-apple-darwin)"
|
||||
SHA_MAC_X64="$(sha x86_64-apple-darwin)"
|
||||
SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)"
|
||||
SHA_LNX_X64="$(sha x86_64-unknown-linux-musl)"
|
||||
|
||||
mkdir -p tap/Formula
|
||||
cat > tap/Formula/gl.rb <<EOF
|
||||
class Gl < Formula
|
||||
desc "Gitlawb CLI — decentralized git for AI agents and developers"
|
||||
homepage "https://gitlawb.com"
|
||||
version "${VERSION}"
|
||||
license "MIT OR Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "${base}/gitlawb-node-${VERSION}-aarch64-apple-darwin.tar.gz"
|
||||
sha256 "${SHA_MAC_ARM}"
|
||||
end
|
||||
on_intel do
|
||||
url "${base}/gitlawb-node-${VERSION}-x86_64-apple-darwin.tar.gz"
|
||||
sha256 "${SHA_MAC_X64}"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "${base}/gitlawb-node-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
|
||||
sha256 "${SHA_LNX_ARM}"
|
||||
end
|
||||
on_intel do
|
||||
url "${base}/gitlawb-node-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
sha256 "${SHA_LNX_X64}"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "gl"
|
||||
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
|
||||
end
|
||||
EOF
|
||||
|
||||
- name: Commit and push
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
working-directory: tap
|
||||
env:
|
||||
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
# Stage first so a brand-new (untracked) formula is detected by the no-op check.
|
||||
git add Formula/gl.rb
|
||||
if git diff --cached --quiet; then
|
||||
echo "Formula already up to date."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "gl ${VERSION}"
|
||||
# Credentials are not persisted in .git/config; supply the token only for the push.
|
||||
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Gitlawb/homebrew-tap.git" HEAD:main
|
||||
|
||||
web-sync:
|
||||
name: Sync web (install scripts + version)
|
||||
needs: [release-please, release-binaries]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
env:
|
||||
WEB_SYNC_PAT: ${{ secrets.WEB_SYNC_PAT }}
|
||||
run: |
|
||||
if [ -z "${WEB_SYNC_PAT:-}" ]; then
|
||||
echo "::warning::WEB_SYNC_PAT is not set — skipping web sync."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout node (release tag)
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
path: node
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout web
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Gitlawb/web
|
||||
token: ${{ secrets.WEB_SYNC_PAT }}
|
||||
path: web
|
||||
persist-credentials: false
|
||||
|
||||
# web-sync publishes install.ps1 + version.json, so the Windows ZIP must exist.
|
||||
# Windows is best-effort for npm/Homebrew (continue-on-error), so verify the
|
||||
# asset is present before advertising a release the site can't actually serve.
|
||||
- name: Verify website release assets
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
|
||||
--jq '.assets[].name' > /tmp/release-assets
|
||||
for asset in \
|
||||
"gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip" \
|
||||
"gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip.sha256"; do
|
||||
grep -Fxq "$asset" /tmp/release-assets || {
|
||||
echo "::error::release asset missing: $asset (Windows build likely failed); not syncing web"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Sync and open PR
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Canonical install scripts (source of truth lives in the node repo).
|
||||
cp node/install.sh web/public/install.sh
|
||||
cp node/install.ps1 web/public/install.ps1
|
||||
# Single version source the site can read.
|
||||
printf '{\n "version": "%s",\n "tag": "%s"\n}\n' "$VERSION" "$TAG" > web/public/version.json
|
||||
# One-time, idempotent drift fixes: drop orphaned binaries, fix stale link.
|
||||
rm -rf web/public/bin
|
||||
if [ -f web/public/skill.md ]; then
|
||||
sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/node/releases#g' web/public/skill.md
|
||||
fi
|
||||
|
||||
cd web
|
||||
# Stage first so brand-new (untracked) files are caught by the no-op check.
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "web already up to date."
|
||||
exit 0
|
||||
fi
|
||||
branch="release-sync/${TAG}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$branch"
|
||||
git commit -m "chore: sync gitlawb ${TAG} (install scripts + version)"
|
||||
# Credentials are not persisted in .git/config; supply the token only for the push.
|
||||
git push --force "https://x-access-token:${GH_TOKEN}@github.com/Gitlawb/web.git" "HEAD:$branch"
|
||||
gh pr create --repo Gitlawb/web --head "$branch" \
|
||||
--title "Sync gitlawb ${TAG}" \
|
||||
--body "Automated sync from Gitlawb/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \
|
||||
|| {
|
||||
# Only swallow the "PR already exists" case; surface auth/API failures
|
||||
# (a failed gh pr list must not be misread as "PR exists").
|
||||
pr_count="$(gh pr list --repo Gitlawb/web --head "$branch" --state open --json number --jq 'length')" || exit 1
|
||||
if [ "$pr_count" != "0" ]; then
|
||||
echo "PR for $branch already exists; branch was updated."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
${{ steps.pkg.outputs.asset }}
|
||||
${{ steps.pkg.outputs.checksum }}
|
||||
@@ -23,18 +23,17 @@ jobs:
|
||||
# Act on PRs only; never on issues.
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
# Only PRs the triage step flagged as missing a real description.
|
||||
# needs-issue is advisory-only (linking an issue is a SHOULD, and many
|
||||
# legitimate small PRs have none) so it does not drive auto-close.
|
||||
only-pr-labels: "needs-description"
|
||||
# Only PRs the triage step flagged as needing an author response.
|
||||
only-pr-labels: "needs-issue,needs-description"
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "workflow-change,security,pinned"
|
||||
remove-stale-when-updated: true
|
||||
stale-pr-message: >
|
||||
This PR has been inactive for 14 days and is still missing a description.
|
||||
It will be closed in 7 days if there's no update. Push a change or leave a
|
||||
comment to keep it open. No hard feelings, you can reopen anytime.
|
||||
This PR has been inactive for 14 days and is still missing a linked issue
|
||||
or a description. It will be closed in 7 days if there's no update. Push a
|
||||
change or leave a comment to keep it open — no hard feelings, you can reopen
|
||||
anytime.
|
||||
close-pr-message: >
|
||||
Closing due to inactivity. Reopen whenever you're ready to update it. See
|
||||
Closing due to inactivity. Reopen whenever you're ready to update it — see
|
||||
CONTRIBUTING.md for what helps us review quickly.
|
||||
ascending: true
|
||||
@@ -23,6 +23,3 @@ keys/
|
||||
# Logs
|
||||
*.log
|
||||
.openclaude-profile.json
|
||||
|
||||
# Per-operator Fly config (node1 prod config is pulled from Fly, not tracked)
|
||||
infra/fly/node1.fly.toml
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.0"
|
||||
".": "0.3.8"
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* agent profiles (display name, bio, avatar, social links) ([#23](https://github.com/Gitlawb/node/issues/23)) ([09a3397](https://github.com/Gitlawb/node/commit/09a339745eca40a2567d911e947e3fc7426fc621))
|
||||
* **db:** versioned schema migrations with idempotent backfill ([#21](https://github.com/Gitlawb/node/issues/21)) ([927e4d0](https://github.com/Gitlawb/node/commit/927e4d0cfb4ea11dca6930780939afaa067797ba))
|
||||
* encrypted replication for private subtrees (B1/B2/B3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#36](https://github.com/Gitlawb/node/issues/36)) ([5ff7af8](https://github.com/Gitlawb/node/commit/5ff7af84fa21fab53a12dfa04a0e7fb7e7d672e6))
|
||||
* **git-remote-gitlawb:** add --version and --help flags ([#30](https://github.com/Gitlawb/node/issues/30)) ([3a401eb](https://github.com/Gitlawb/node/commit/3a401eb0792f9c5f5a10de6c16861655ab3836e0))
|
||||
* **gitlawb-attest:** External Attestation v1 for ref-update certs ([#20](https://github.com/Gitlawb/node/issues/20)) ([924bccd](https://github.com/Gitlawb/node/commit/924bccd8e53e9be2dc9d6d4e4f1376952d6462bb))
|
||||
* **node:** blind recipient identities at rest and gate B1 by repo readability ([#40](https://github.com/Gitlawb/node/issues/40)) ([abdc775](https://github.com/Gitlawb/node/commit/abdc7757708d2bbb2bfda99bb65d50756142a42e))
|
||||
* **node:** enforce per-route authorization across the REST and GraphQL surface ([#87](https://github.com/Gitlawb/node/issues/87)) ([2202b00](https://github.com/Gitlawb/node/commit/2202b0097fab6976ab366a2cdc385f1146a72f86))
|
||||
* **node:** graceful shutdown + Prometheus metrics endpoint ([#22](https://github.com/Gitlawb/node/issues/22)) ([2ce4da9](https://github.com/Gitlawb/node/commit/2ce4da9cc5a4791e8ae5a1cd90270b67f60d9ec3))
|
||||
* **node:** iCaptcha proof-of-intelligence gate on create_repo + register ([#108](https://github.com/Gitlawb/node/issues/108)) ([adc20f9](https://github.com/Gitlawb/node/commit/adc20f9effad7b42fab55002875d209c4ed79518))
|
||||
* **node:** iCaptcha-aware repo propagation gate with quarantine ([#125](https://github.com/Gitlawb/node/issues/125)) ([8b9ceec](https://github.com/Gitlawb/node/commit/8b9ceec25ef338965d1db72d39a7f2adb5300cc9))
|
||||
* **node:** owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH ([#31](https://github.com/Gitlawb/node/issues/31)) ([#68](https://github.com/Gitlawb/node/issues/68)) ([0a15e76](https://github.com/Gitlawb/node/commit/0a15e763d2ab46737a3831715facbe51045b33ba))
|
||||
* **node:** peer partial-mirrors for repos with private subtrees ([#35](https://github.com/Gitlawb/node/issues/35)) ([e365a57](https://github.com/Gitlawb/node/commit/e365a57b51c167abf06e87bfeb0565c80ed1b849))
|
||||
* **node:** pin the per-push object delta instead of re-enumerating the whole repo ([#90](https://github.com/Gitlawb/node/issues/90)) ([1af4fdf](https://github.com/Gitlawb/node/commit/1af4fdf485c7084cd160551b757b1ad1eed65cc6))
|
||||
* **node:** replication enforcement (Phase 2) for [#18](https://github.com/Gitlawb/node/issues/18) ([#34](https://github.com/Gitlawb/node/issues/34)) ([8680d0f](https://github.com/Gitlawb/node/commit/8680d0f9d6600bba1a52d15624f8a2802a169511))
|
||||
* **node:** signature-gated agent self-deregister ([#29](https://github.com/Gitlawb/node/issues/29)) ([#63](https://github.com/Gitlawb/node/issues/63)) ([ff492b4](https://github.com/Gitlawb/node/commit/ff492b452126f5568dac4286a7249f0cadb8b380))
|
||||
* **node:** subtree content withholding (Phase 3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#28](https://github.com/Gitlawb/node/issues/28)) ([61b3830](https://github.com/Gitlawb/node/commit/61b383019fd895a1a6adfad934ad6c626e0f095e))
|
||||
* path-scoped repository visibility (Phase 1) for [#18](https://github.com/Gitlawb/node/issues/18) ([#25](https://github.com/Gitlawb/node/issues/25)) ([6abaf1d](https://github.com/Gitlawb/node/commit/6abaf1d7ed8fc55c6547568ae7247131311bde98))
|
||||
* per-DID rate limiting on creation endpoints (10/hour) ([#13](https://github.com/Gitlawb/node/issues/13)) ([b12c6bc](https://github.com/Gitlawb/node/commit/b12c6bc3283c2647224a62fb520a3cb7acf4a747))
|
||||
* **sync:** auto-register as replica with origin after successful mirror ([#56](https://github.com/Gitlawb/node/issues/56)) ([c03c9af](https://github.com/Gitlawb/node/commit/c03c9af8fadafe262a2bf3cf25e19edf7160d376))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** blob endpoint returns 400/404 instead of 500 on bad paths ([#37](https://github.com/Gitlawb/node/issues/37)) ([b61a1bd](https://github.com/Gitlawb/node/commit/b61a1bd46ffe78b39c0967e97b0ad349eba0b046))
|
||||
* **core:** route seed access through the zeroizing wrapper ([#41](https://github.com/Gitlawb/node/issues/41)) ([#64](https://github.com/Gitlawb/node/issues/64)) ([c9f43b0](https://github.com/Gitlawb/node/commit/c9f43b010576edb3c92a3be3d935cad232250344))
|
||||
* **core:** zeroize the derived X25519 secret ([#65](https://github.com/Gitlawb/node/issues/65)) ([#91](https://github.com/Gitlawb/node/issues/91)) ([2f6611a](https://github.com/Gitlawb/node/commit/2f6611a30fbb66cef9991cf4c6e507548acc5038))
|
||||
* **gl:** paginate gl-clone Arweave recovery and make /encrypted-blobs parsing schema-strict ([#49](https://github.com/Gitlawb/node/issues/49)) ([#70](https://github.com/Gitlawb/node/issues/70)) ([2153b0b](https://github.com/Gitlawb/node/commit/2153b0b7a67a48c29a33cd70b80cfbb69760805d))
|
||||
* **infra:** drop fly idle_timeout 600 -> 120 ([#38](https://github.com/Gitlawb/node/issues/38)) ([a2217bf](https://github.com/Gitlawb/node/commit/a2217bff27ff3d6cae02bd8a12f8a0af7ce2b0a1))
|
||||
* **node:** anchor the real old_sha and issue a per-ref certificate ([#72](https://github.com/Gitlawb/node/issues/72)) ([6809201](https://github.com/Gitlawb/node/commit/6809201daa223d6f833be6e95876a7b4e1f2b0b5))
|
||||
* **node:** close under-withholding via full ref scope and full-history classification ([#42](https://github.com/Gitlawb/node/issues/42)) ([#84](https://github.com/Gitlawb/node/issues/84)) ([3e1e904](https://github.com/Gitlawb/node/commit/3e1e9045e4e3aa5ea0aee69767ceb01637920d2a))
|
||||
* **node:** dedupe mirror and canonical repo rows on list surfaces ([#6](https://github.com/Gitlawb/node/issues/6)) ([#73](https://github.com/Gitlawb/node/issues/73)) ([3e8e333](https://github.com/Gitlawb/node/commit/3e8e333aa03d7a2fe455d5d83f23089d58feb8c9))
|
||||
* **node:** enforce path-scoped visibility on the REST read API ([#52](https://github.com/Gitlawb/node/issues/52)) ([e37ea7f](https://github.com/Gitlawb/node/commit/e37ea7fec6d5a3171b526c84f884670bbbd258fb))
|
||||
* **node:** fail closed when a recipient DID can't be resolved ([#47](https://github.com/Gitlawb/node/issues/47)) ([#67](https://github.com/Gitlawb/node/issues/67)) ([abc9ad0](https://github.com/Gitlawb/node/commit/abc9ad03acb48e608234650a25049622673fa53a))
|
||||
* **node:** gate fork_repo on per-caller path-scoped visibility ([#98](https://github.com/Gitlawb/node/issues/98)) ([#109](https://github.com/Gitlawb/node/issues/109)) ([6ae316c](https://github.com/Gitlawb/node/commit/6ae316cc88521747cbacb2a612b9433897d2e490))
|
||||
* **node:** gate GET /ipfs/{cid} on per-caller path-scoped visibility ([#110](https://github.com/Gitlawb/node/issues/110)) ([#128](https://github.com/Gitlawb/node/issues/128)) ([174f25a](https://github.com/Gitlawb/node/commit/174f25a206380b26796b8782e1bd860b0a409fc9))
|
||||
* **node:** gate repo-listing and stats surfaces on visibility ([#97](https://github.com/Gitlawb/node/issues/97), [#99](https://github.com/Gitlawb/node/issues/99), [#101](https://github.com/Gitlawb/node/issues/101), [#104](https://github.com/Gitlawb/node/issues/104)) ([#111](https://github.com/Gitlawb/node/issues/111)) ([828dd27](https://github.com/Gitlawb/node/commit/828dd279a286f58bbb3c73627b2a1e23778b25cf))
|
||||
* **node:** make Tigris repo hydration resilient to corrupt archives & failed writes ([#54](https://github.com/Gitlawb/node/issues/54)) ([7a99d0f](https://github.com/Gitlawb/node/commit/7a99d0f27cfa869e9f8a803f16cff30191745a51))
|
||||
* **node:** preserve promisor mirror mode on unknown withheld-paths lookup ([#48](https://github.com/Gitlawb/node/issues/48)) ([#69](https://github.com/Gitlawb/node/issues/69)) ([96fcfdb](https://github.com/Gitlawb/node/commit/96fcfdb1ca6d4cd27226670068702c9f177e283a))
|
||||
* **node:** reap leaked git child processes ([#53](https://github.com/Gitlawb/node/issues/53)) ([#61](https://github.com/Gitlawb/node/issues/61)) ([803d83e](https://github.com/Gitlawb/node/commit/803d83efdbcfa9d98affac1c5aaf8aacc511ae64))
|
||||
* **node:** reject malformed path globs with non-trailing or empty-segment wildcards ([#74](https://github.com/Gitlawb/node/issues/74)) ([#75](https://github.com/Gitlawb/node/issues/75)) ([3d880cd](https://github.com/Gitlawb/node/commit/3d880cd56fed10e4c5ae787b52ce411384950f5e))
|
||||
* **node:** skip withheld-walk when no path-scoped rule can withhold ([#60](https://github.com/Gitlawb/node/issues/60)) ([338ff83](https://github.com/Gitlawb/node/commit/338ff83584f2ab960be7e42ecec64191f5aaeb95))
|
||||
* **release:** give crates explicit versions for release-please ([#129](https://github.com/Gitlawb/node/issues/129)) ([788a868](https://github.com/Gitlawb/node/commit/788a8686c1deafe03573703d72eb927cde81f54d))
|
||||
* **release:** use simple release-type + generic Cargo.toml updaters ([#130](https://github.com/Gitlawb/node/issues/130)) ([12bfb1b](https://github.com/Gitlawb/node/commit/12bfb1b3c86b38f0b22d488cd6e85dfd96e0c37f))
|
||||
* **security:** gate webhook creation through the public-host validator ([#81](https://github.com/Gitlawb/node/issues/81)) ([#92](https://github.com/Gitlawb/node/issues/92)) ([f28fa02](https://github.com/Gitlawb/node/commit/f28fa02236bd65c6a4fb690131ab14640cc53a12))
|
||||
* **security:** reject non-public peer URLs + prune poisoned peers ([#78](https://github.com/Gitlawb/node/issues/78)) ([a8cc33a](https://github.com/Gitlawb/node/commit/a8cc33a185f2649d3fe100ec271ee5739a55eba7))
|
||||
+3
-4
@@ -69,10 +69,9 @@ expected to clear a basic quality bar:
|
||||
- **A real description.** Say what changes and why. "Update code" is not a description.
|
||||
|
||||
A triage bot labels PRs that are missing these and leaves a short note. Nothing is closed
|
||||
automatically while you're engaging. A flagged PR still missing a description after 14 days of
|
||||
inactivity gets a stale warning, and is closed 7 days later if still untouched. A missing issue
|
||||
link alone never triggers closure; that label stays advisory. Closed PRs can be reopened at any
|
||||
time once updated.
|
||||
automatically while you're engaging. A flagged PR that goes 14 days with no linked issue or
|
||||
description gets a stale warning, and is closed 7 days later if still untouched. Closed PRs can
|
||||
be reopened at any time once updated.
|
||||
|
||||
## Development environment
|
||||
|
||||
|
||||
Generated
+26
-46
@@ -329,7 +329,7 @@ dependencies = [
|
||||
"keccak-asm",
|
||||
"paste",
|
||||
"proptest",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rapidhash",
|
||||
"ruint",
|
||||
"rustc-hash",
|
||||
@@ -2107,7 +2107,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2262,7 +2262,7 @@ checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"digest 0.10.7",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"rustversion",
|
||||
]
|
||||
@@ -2293,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",
|
||||
]
|
||||
@@ -2928,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3300,11 +3300,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.5.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3312,7 +3311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.5.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3329,7 +3328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.5.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3356,7 +3355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.5.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3366,7 +3365,6 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -3378,7 +3376,6 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"libc",
|
||||
"libp2p-core",
|
||||
"libp2p-dns",
|
||||
"libp2p-gossipsub",
|
||||
@@ -3405,14 +3402,13 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.5.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3420,7 +3416,6 @@ dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3603,7 +3598,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
@@ -3625,7 +3620,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
@@ -3815,21 +3810,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"
|
||||
@@ -4555,7 +4535,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -5302,7 +5282,7 @@ dependencies = [
|
||||
"bit-vec",
|
||||
"bitflags 2.11.0",
|
||||
"num-traits",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
@@ -5362,14 +5342,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -5436,9 +5416,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.3"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
@@ -5758,7 +5738,7 @@ dependencies = [
|
||||
"primitive-types",
|
||||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rlp",
|
||||
"ruint-macro",
|
||||
"serde_core",
|
||||
@@ -5821,7 +5801,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6696,9 +6676,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6715,7 +6695,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7078,7 +7058,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
|
||||
+1
-12
@@ -6,17 +6,10 @@ members = [
|
||||
"crates/gl",
|
||||
"crates/git-remote-gitlawb",
|
||||
"crates/gitlawb-attest",
|
||||
"crates/icaptcha-client",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
# Per-crate versions live in each crate's [package] so release-please can manage
|
||||
# them (it cannot rewrite an inherited `version.workspace = true`). They are kept
|
||||
# in lockstep by release-please's generic updater: each crate's `version` line
|
||||
# carries an `# x-release-please-version` annotation and is listed under
|
||||
# `extra-files` in release-please-config.json. The release type is `simple`
|
||||
# (not `rust`) because the rust strategy unconditionally tries to rewrite this
|
||||
# virtual workspace root as a package manifest and errors out.
|
||||
version = "0.3.9"
|
||||
edition = "2021"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -55,7 +48,3 @@ uuid = { version = "1", features = ["v4"] }
|
||||
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
+1
-9
@@ -44,7 +44,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime
|
||||
@@ -73,11 +72,4 @@ VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:${GITLAWB_PORT}/health || exit 1
|
||||
|
||||
# Run under tini so the node is never PID 1. As PID 1 a process must reap
|
||||
# reparented orphans itself; the node does not, so git's reparented grandchildren
|
||||
# (e.g. pack-objects orphaned when a served upload-pack dies on a client
|
||||
# disconnect) would accumulate as zombies until fork() fails with EAGAIN (#53).
|
||||
# tini reaps them and forwards SIGTERM/SIGINT to the node, which runs its own
|
||||
# graceful shutdown. (No `-g`: the node manages its own children, so group-wide
|
||||
# signalling would only disturb in-flight git helpers during shutdown.)
|
||||
ENTRYPOINT ["tini", "--", "gitlawb-node"]
|
||||
ENTRYPOINT ["gitlawb-node"]
|
||||
@@ -119,18 +119,10 @@ docker compose down
|
||||
|
||||
## Install the CLI
|
||||
|
||||
Use the installer once releases are available for your platform:
|
||||
|
||||
```bash
|
||||
# npm (macOS / Linux)
|
||||
npm install -g @gitlawb/gl
|
||||
|
||||
# Homebrew (macOS / Linux)
|
||||
brew install gitlawb/tap/gl
|
||||
|
||||
# curl (macOS / Linux)
|
||||
curl -fsSL https://gitlawb.com/install.sh | sh
|
||||
|
||||
# PowerShell (Windows)
|
||||
irm https://gitlawb.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
@@ -185,45 +177,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 +255,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 +295,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. |
|
||||
|
||||
+2
-26
@@ -1,40 +1,16 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-06-30",
|
||||
"updated": "2026-05-16",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"operator": "Gitlawb",
|
||||
"operator": "Gitlawb (Kevin)",
|
||||
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
|
||||
"http_url": "https://node.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-04-29"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node2",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node2.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node3",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node3.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-manila",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://manila.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "rapybus-gitlawb",
|
||||
"operator": "RapyBus S.A.C.",
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.7.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
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"
|
||||
@@ -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,20 +266,35 @@ 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);
|
||||
|
||||
// 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.
|
||||
let pack_resp = req
|
||||
.body(request_body)
|
||||
.send()
|
||||
.with_context(|| format!("POST {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")
|
||||
.body(request_body.clone());
|
||||
|
||||
// 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pack_resp = req.send().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 +306,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 +343,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 +429,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 +472,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 +520,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,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.7.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -127,7 +127,7 @@ impl Attestation {
|
||||
self.type_
|
||||
)));
|
||||
}
|
||||
Ok(P::deserialize(&self.payload)?)
|
||||
Ok(serde_json::from_value(self.payload.clone())?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -129,7 +129,7 @@ impl HttpSignature {
|
||||
pub fn missing_components(&self) -> Vec<&str> {
|
||||
COVERED_COMPONENTS
|
||||
.iter()
|
||||
.filter(|c| !self.components.iter().any(|s| s.as_str() == **c))
|
||||
.filter(|c| !self.components.contains(&c.to_string()))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.7.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
ed25519-dalek = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -29,10 +28,9 @@ async-graphql-axum = "7"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "limit"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "macros", "migrate"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
bytes = "1"
|
||||
libc = "0.2"
|
||||
cid = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -40,9 +38,6 @@ hmac = { workspace = true }
|
||||
http-body-util = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
dirs-next = "2"
|
||||
# NFC-normalize visibility globs/paths at the matcher seam so a deny rule
|
||||
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
|
||||
unicode-normalization = "0.1"
|
||||
reqwest = { workspace = true }
|
||||
libp2p-core = "0.43"
|
||||
libp2p-gossipsub = "0.49.4"
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::{Extension, Json};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -21,14 +20,6 @@ fn agent_key_segment(did: &str) -> &str {
|
||||
did.split(':').next_back().unwrap_or(did)
|
||||
}
|
||||
|
||||
/// Whether `caller` (the verified `AuthenticatedDid`) is the same identity as
|
||||
/// `target` (the DID being acted on), tolerant of full `did:key:...` vs short
|
||||
/// key-only forms on either side. Used to gate self-deregistration so a DID
|
||||
/// can only retire itself.
|
||||
fn caller_matches_did(caller: &str, target: &str) -> bool {
|
||||
agent_key_segment(caller) == agent_key_segment(target)
|
||||
}
|
||||
|
||||
async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
|
||||
let normalized_did = normalize_agent_did(did);
|
||||
if state.db.get_agent(&normalized_did).await?.is_some() {
|
||||
@@ -80,8 +71,6 @@ pub struct AgentResponse {
|
||||
pub capabilities: Vec<String>,
|
||||
pub registered_at: String,
|
||||
pub last_seen: Option<String>,
|
||||
/// Lifecycle status: `active` or `revoked`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// GET /api/v1/agents
|
||||
@@ -98,7 +87,6 @@ pub async fn list_agents(
|
||||
capabilities: a.capabilities,
|
||||
registered_at: a.registered_at,
|
||||
last_seen: a.last_seen,
|
||||
status: a.status,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "agents": list })))
|
||||
@@ -123,7 +111,6 @@ pub async fn show_agent(
|
||||
capabilities: agent.capabilities,
|
||||
registered_at: agent.registered_at,
|
||||
last_seen: agent.last_seen,
|
||||
status: agent.status,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -146,97 +133,9 @@ pub async fn get_trust(
|
||||
}))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/agents/{did}
|
||||
///
|
||||
/// Self-deregistration: the holder of a DID's key marks their own agent
|
||||
/// `revoked`, removing it from discovery (issue #29). Authenticated by the
|
||||
/// rfc9421 signature middleware; a caller may only retire its own DID.
|
||||
pub async fn deregister_agent(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
// Authorize on the verified identity, and act on it too. The path DID is
|
||||
// only an intent check (it must name the caller); the row we revoke is the
|
||||
// authenticated DID itself, never a value derived from the untrusted path.
|
||||
// Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix
|
||||
// resolver act on a different identity than the one just authorized.
|
||||
if !caller_matches_did(&auth.0, &did) {
|
||||
return Err(AppError::BadRequest(
|
||||
"an agent can only deregister itself".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if state.db.revoke_agent(&auth.0).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(AppError::NotFound(format!("agent {} not found", auth.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse};
|
||||
|
||||
#[test]
|
||||
fn caller_matches_own_did_full_form() {
|
||||
assert!(caller_matches_did(
|
||||
"did:key:z6MkExample",
|
||||
"did:key:z6MkExample"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_matches_own_did_short_form() {
|
||||
// Authenticated full DID vs short path form, and vice versa.
|
||||
assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample"));
|
||||
assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_cannot_revoke_a_different_did() {
|
||||
// The core authorization property for issue #29.
|
||||
assert!(!caller_matches_did(
|
||||
"did:key:z6MkAttacker",
|
||||
"did:key:z6MkVictim"
|
||||
));
|
||||
assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_response_surfaces_status() {
|
||||
// A revoked DID's response must carry its status so callers can see it
|
||||
// is retired (issue #29).
|
||||
let resp = AgentResponse {
|
||||
did: "did:key:orphan".to_string(),
|
||||
trust_score: 0.1,
|
||||
capabilities: vec!["reputation:score".to_string()],
|
||||
registered_at: "2026-06-19T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
status: "revoked".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
|
||||
assert_eq!(json["status"], "revoked");
|
||||
assert!(json.get("replaced_by").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_response_active_status() {
|
||||
let resp = AgentResponse {
|
||||
did: "did:key:active".to_string(),
|
||||
trust_score: 0.5,
|
||||
capabilities: vec![],
|
||||
registered_at: "2026-06-19T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
status: "active".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
|
||||
assert_eq!(json["status"], "active");
|
||||
}
|
||||
use super::{agent_key_segment, normalize_agent_did};
|
||||
|
||||
#[test]
|
||||
fn normalize_agent_did_preserves_full_did() {
|
||||
|
||||
@@ -79,9 +79,12 @@ pub async fn create_bounty(
|
||||
return Err(AppError::BadRequest("amount must be positive".into()));
|
||||
}
|
||||
|
||||
// Read-gate: anyone who can read the repo may fund a bounty on it (this closes
|
||||
// the private-repo bounty leak); ownership is not required.
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
// Verify repo exists
|
||||
let _ = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let bounty = BountyRecord {
|
||||
@@ -110,28 +113,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 +136,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 +165,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!(
|
||||
@@ -297,12 +213,8 @@ pub async fn submit_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
let is_claimant = bounty
|
||||
.claimant_did
|
||||
.as_deref()
|
||||
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
|
||||
if !is_claimant {
|
||||
return Err(AppError::Forbidden("only the claimant can submit".into()));
|
||||
if bounty.claimant_did.as_deref() != Some(&auth.0) {
|
||||
return Err(AppError::BadRequest("only the claimant can submit".into()));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -337,8 +249,8 @@ pub async fn approve_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the bounty creator can approve".into(),
|
||||
));
|
||||
}
|
||||
@@ -384,8 +296,8 @@ pub async fn cancel_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the bounty creator can cancel".into(),
|
||||
));
|
||||
}
|
||||
@@ -420,19 +332,6 @@ pub async fn dispute_bounty(
|
||||
)));
|
||||
}
|
||||
|
||||
// Only the bounty creator or the current claimant may dispute (N19 — this
|
||||
// endpoint had no identity check at all).
|
||||
let is_creator = crate::api::did_matches(&auth.0, &bounty.creator_did);
|
||||
let is_claimant = bounty
|
||||
.claimant_did
|
||||
.as_deref()
|
||||
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
|
||||
if !is_creator && !is_claimant {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the bounty creator or claimant can dispute this bounty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if deadline exceeded
|
||||
if let Some(ref claimed_at) = bounty.claimed_at {
|
||||
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
|
||||
|
||||
@@ -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
@@ -9,55 +9,28 @@
|
||||
//! `git cat-file <type> <sha256>` (i.e. without the git framing header).
|
||||
//! This is consistent with how `gitlawb_core::cid::Cid::from_git_object_bytes`
|
||||
//! computes CIDs when objects are pushed.
|
||||
//!
|
||||
//! Serving is access-controlled: an object is returned only from a repo row the
|
||||
//! requesting caller is permitted to read (per-caller path-scoped visibility,
|
||||
//! see `get_by_cid`).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Extension, Json,
|
||||
Json,
|
||||
};
|
||||
use cid::CidGeneric;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
/// GET /ipfs/{cid}
|
||||
///
|
||||
/// 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_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.
|
||||
///
|
||||
/// Scope: this closes the direct unauthenticated scan, including the dangling
|
||||
/// case. A stale-public mirror row still serves withheld content (tracked
|
||||
/// separately, #124).
|
||||
/// the given CIDv1. Returns the raw object content bytes with appropriate
|
||||
/// headers if found, or 404 if not found.
|
||||
pub async fn get_by_cid(
|
||||
Path(cid_str): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Response> {
|
||||
// 1. Decode the CID and extract the SHA-256 digest
|
||||
let cid = CidGeneric::<64>::from_str(&cid_str)
|
||||
@@ -73,8 +46,6 @@ pub async fn get_by_cid(
|
||||
}
|
||||
|
||||
let sha256_hex = hex::encode(mh.digest());
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let caller_owned = caller.map(|c| c.to_string());
|
||||
|
||||
// 2. Search all repos for an object with this SHA-256
|
||||
let repos = state
|
||||
@@ -83,126 +54,39 @@ pub async fn get_by_cid(
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Fetch every repo's visibility rules in one query rather than one per row
|
||||
// (the gate runs each row against its OWN rules — KTD2a). A row absent from
|
||||
// the map has no rules.
|
||||
let repo_ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state
|
||||
.db
|
||||
.list_visibility_rules_for_repos(&repo_ids)
|
||||
.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();
|
||||
|
||||
for repo in &repos {
|
||||
// Repo-level read gate against THIS row's own rules (KTD2a).
|
||||
let rules: &[crate::db::VisibilityRule] = rules_by_repo
|
||||
.get(&repo.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
if visibility_check(rules, repo.is_public, &repo.owner_did, caller, "/") == Decision::Deny {
|
||||
continue;
|
||||
}
|
||||
|
||||
let repo_path = match state.repo_store.acquire(&repo.owner_did, &repo.name).await {
|
||||
Ok(p) => p,
|
||||
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,
|
||||
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 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) {
|
||||
let rp = repo_path.clone();
|
||||
let r = rules.to_vec();
|
||||
let is_public = repo.is_public;
|
||||
let owner = repo.owner_did.clone();
|
||||
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(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
// Fail closed on EITHER a task panic (JoinError) or a walk error:
|
||||
// we cannot prove the caller may read here, so skip this repo and
|
||||
// let a public copy (if any) serve. Never serve on an unproven gate.
|
||||
let set = match walk {
|
||||
Ok(Ok(set)) => set,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk failed; skipping repo");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
allowed_memo.insert(repo.id.clone(), set);
|
||||
}
|
||||
let in_allowed = allowed_memo
|
||||
.get(&repo.id)
|
||||
.is_some_and(|set| set.contains(&sha256_hex));
|
||||
if !in_allowed {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error reading git object");
|
||||
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,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error reading git object content");
|
||||
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
|
||||
|
||||
@@ -39,11 +39,11 @@ pub async fn create_issue(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<CreateIssueRequest>,
|
||||
) -> Result<(StatusCode, Json<IssueRecord>)> {
|
||||
// Authorize the caller as a reader before accepting an issue: a non-reader
|
||||
// must not be able to file an issue against a private repo they cannot read.
|
||||
// Mirrors create_issue_comment / create_review / create_bounty.
|
||||
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 issue_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -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
|
||||
@@ -159,9 +161,11 @@ pub async fn create_issue_comment(
|
||||
));
|
||||
}
|
||||
|
||||
// Read-gate: a commenter must be able to read the repo, but need not own it.
|
||||
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 disk_path = state
|
||||
.repo_store
|
||||
@@ -189,38 +193,21 @@ 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 })))
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/{owner}/{repo}/issues/{id}/close
|
||||
pub async fn close_issue(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Extension(_auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -236,33 +223,6 @@ pub async fn close_issue(
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
// Owner OR issue author may close. The author lives in the issue's git-JSON
|
||||
// blob (not a DB column); a None author (legacy issues) falls back to
|
||||
// owner-only. Read it under the write guard, before mutating.
|
||||
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
|
||||
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
Ok(None) => {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
|
||||
}
|
||||
Err(e) => {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Git(e.to_string()));
|
||||
}
|
||||
};
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
let is_author = author_did
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let close_result = git_issues::close_issue(&disk_path, &issue_id);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct LabelRequest {
|
||||
/// POST /api/v1/repos/:owner/:repo/labels
|
||||
pub async fn add_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Extension(_auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<LabelRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
@@ -39,7 +39,6 @@ pub async fn add_label(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let added = state.db.add_label(&record.id, &label).await?;
|
||||
let status = if added {
|
||||
@@ -56,7 +55,7 @@ pub async fn add_label(
|
||||
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
|
||||
pub async fn remove_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Extension(_auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, label)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -64,25 +63,21 @@ pub async fn remove_label(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
state.db.remove_label(&record.id, &label).await?;
|
||||
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
|
||||
}
|
||||
|
||||
/// 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 })))
|
||||
|
||||
@@ -47,13 +47,6 @@ pub(crate) async fn authorize_repo_read(
|
||||
.get_repo(owner, name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// A quarantined mirror (admitted by the iCaptcha propagation gate but not
|
||||
// validated) is hidden from every reader — serve/clone and fork alike — as if
|
||||
// it did not exist, until an operator releases it. Checked before the
|
||||
// visibility gate so its existence is never disclosed.
|
||||
if state.db.is_repo_quarantined(&record.id).await? {
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
|
||||
}
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, path) == Decision::Deny
|
||||
{
|
||||
@@ -61,623 +54,3 @@ pub(crate) async fn authorize_repo_read(
|
||||
}
|
||||
Ok((record, rules))
|
||||
}
|
||||
|
||||
/// Match a presented DID against a stored DID that may be the full `did:key:<id>`
|
||||
/// form or the bare `<id>` short form (mirror rows store the bare key). Collapse
|
||||
/// 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.
|
||||
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
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
|
||||
/// and the author check (close policy) share one normalization.
|
||||
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
|
||||
if did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
"only the repo owner can perform this action".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod did_tests {
|
||||
use super::did_matches;
|
||||
|
||||
#[test]
|
||||
fn full_matches_bare_same_key() {
|
||||
assert!(did_matches("did:key:zABC", "zABC"));
|
||||
assert!(did_matches("zABC", "did:key:zABC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_method_collision() {
|
||||
assert!(!did_matches("did:key:zABC", "did:gitlawb:zABC"));
|
||||
assert!(!did_matches("did:key:zABC", "did:web:zABC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_match_and_distinct_keys() {
|
||||
assert!(did_matches("did:key:zABC", "did:key:zABC"));
|
||||
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
|
||||
/// handler must contain its expected gate marker in its own body; removing a
|
||||
/// gate fails this test. Source-level (no DB), so it runs everywhere. When a new
|
||||
/// route is added to an in-scope group, add its row here with a deliberate gate
|
||||
/// type — that forced decision is the point.
|
||||
///
|
||||
/// Markers are gate-SHAPED — a call (`require_repo_owner(`, `did_matches(`) or a
|
||||
/// binding/comparison expression (`caller != &record.owner_did`,
|
||||
/// `let owner_did = auth.0`) — never a bare identifier that could also appear in
|
||||
/// a log line. Full-line comments are stripped before matching, so a marker that
|
||||
/// survives only as a comment above a deleted gate does NOT satisfy a row.
|
||||
#[cfg(test)]
|
||||
mod authz_guard {
|
||||
/// The body of `func` with full-line comments removed. Bounds the slice at the
|
||||
/// next top-level fn item so a marker in a later handler can't leak in,
|
||||
/// tolerating `pub async`, `pub(crate) async`, `async`, `pub`, and bare `fn`
|
||||
/// declarations (the old single-`pub async fn` delimiter over-ran on any other
|
||||
/// form).
|
||||
fn fn_body(src: &str, func: &str) -> String {
|
||||
let needle = format!("fn {func}(");
|
||||
let start = src
|
||||
.find(&needle)
|
||||
.unwrap_or_else(|| panic!("handler `{func}` not found (renamed or removed?)"));
|
||||
let rest = &src[start..];
|
||||
let end = [
|
||||
"\npub async fn ",
|
||||
"\npub(crate) async fn ",
|
||||
"\nasync fn ",
|
||||
"\npub fn ",
|
||||
"\nfn ",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
|
||||
.min()
|
||||
.unwrap_or(rest.len());
|
||||
rest[..end]
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("//"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_in_scope_mutation_has_its_gate() {
|
||||
let pulls = include_str!("pulls.rs");
|
||||
let webhooks = include_str!("webhooks.rs");
|
||||
let labels = include_str!("labels.rs");
|
||||
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 protect = include_str!("protect.rs");
|
||||
let visibility = include_str!("visibility.rs");
|
||||
let profiles = include_str!("profiles.rs");
|
||||
let repos = include_str!("repos.rs");
|
||||
let register = include_str!("register.rs");
|
||||
let ipfs = include_str!("ipfs.rs");
|
||||
|
||||
// (source, handler, expected gate marker)
|
||||
let rows: &[(&str, &str, &str)] = &[
|
||||
// Bucket A — owner-gate (require_repo_owner -> 403)
|
||||
(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("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
// Bucket A' — owner OR author (did_matches against the author)
|
||||
(pulls, "close_pr", "did_matches("),
|
||||
(issues, "close_issue", "did_matches("),
|
||||
// Bucket B — read-gate (authorize_repo_read)
|
||||
(pulls, "create_review", "authorize_repo_read("),
|
||||
(pulls, "create_comment", "authorize_repo_read("),
|
||||
(pulls, "create_pr", "authorize_repo_read("),
|
||||
(issues, "create_issue_comment", "authorize_repo_read("),
|
||||
(issues, "create_issue", "authorize_repo_read("),
|
||||
(bounties, "create_bounty", "authorize_repo_read("),
|
||||
(repos, "fork_repo", "authorize_repo_read("),
|
||||
// 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("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(tasks, "complete_task", "did_matches("),
|
||||
(tasks, "fail_task", "did_matches("),
|
||||
(repos, "create_repo", "let owner_did = auth.0"),
|
||||
(profiles, "set_profile", "let did = auth.0"),
|
||||
(register, "register", "did_matches("),
|
||||
(stars, "star_repo", "caller = &auth.0"),
|
||||
(stars, "unstar_repo", "caller = &auth.0"),
|
||||
// Bucket D — non-owner-by-design, positive per-route marker
|
||||
(bounties, "claim_bounty", "claim_bounty(&id, &auth.0"),
|
||||
(bounties, "submit_bounty", "did_matches("),
|
||||
(bounties, "approve_bounty", "did_matches("),
|
||||
(bounties, "cancel_bounty", "did_matches("),
|
||||
(bounties, "dispute_bounty", "did_matches("),
|
||||
(replicas, "register_replica", "did_matches("),
|
||||
(replicas, "unregister_replica", "replica_did = &auth.0"),
|
||||
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
|
||||
(protect, "protect_branch", "did_matches("),
|
||||
(protect, "unprotect_branch", "did_matches("),
|
||||
(visibility, "set_visibility", "require_owner("),
|
||||
(visibility, "remove_visibility", "require_owner("),
|
||||
(visibility, "list_visibility", "require_owner("),
|
||||
];
|
||||
|
||||
// The visibility rows prove require_owner is CALLED; this proves the helper
|
||||
// itself does DID-safe matching, not a raw/trailing-segment compare.
|
||||
assert!(
|
||||
fn_body(visibility, "require_owner").contains("did_matches("),
|
||||
"visibility::require_owner must use did_matches for DID-safe owner matching"
|
||||
);
|
||||
|
||||
for (src, func, marker) in rows {
|
||||
let body = fn_body(src, func);
|
||||
assert!(
|
||||
body.contains(marker),
|
||||
"handler `{func}` is missing its gate marker `{marker}` — gate removed or route reclassified"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves the comment-stripping that GUARD-1 added: a marker that appears only
|
||||
/// in a full-line comment (the real `replicas.rs` false-pass shape) must NOT
|
||||
/// satisfy a row.
|
||||
#[test]
|
||||
fn comment_only_marker_does_not_satisfy_a_row() {
|
||||
let src = "pub async fn demo() {\n // did_matches( handles the owner form\n do_thing();\n}\n";
|
||||
assert!(
|
||||
!fn_body(src, "demo").contains("did_matches("),
|
||||
"a marker present only in a comment must not count as an enforced gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// All `pub async fn` / `pub(crate) async fn` handler names declared in `src`.
|
||||
/// Verb-agnostic on purpose: a repo-scoped read of any name is in scope, so a
|
||||
/// handler named `fetch_*` / `replicate_*` / `info_refs` cannot escape the gate
|
||||
/// check by not being called `list_*` / `get_*`.
|
||||
fn handler_names(src: &str) -> Vec<String> {
|
||||
["pub async fn ", "pub(crate) async fn "]
|
||||
.iter()
|
||||
.flat_map(|decl| {
|
||||
src.match_indices(decl).map(move |(i, _)| {
|
||||
src[i + decl.len()..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect::<String>()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True when the handler's signature takes an owner+repo path
|
||||
/// (`Path<(String, String...)>`), i.e. it is a repo-scoped read rather than a
|
||||
/// node-wide aggregate (`/stats`, `/ipfs/pins`, the global feeds).
|
||||
fn is_repo_scoped(body: &str) -> bool {
|
||||
let header = &body[..body.find('{').unwrap_or(body.len())];
|
||||
header.contains("Path<(String, String")
|
||||
}
|
||||
|
||||
/// True when at least one gate marker runs for EVERY service — i.e. some
|
||||
/// marker sits outside any `if service == ...` discriminator block. A gate
|
||||
/// that appears ONLY inside such a block (as the info/refs advertisement gate
|
||||
/// did before #119: `visibility_check` ran under `if service ==
|
||||
/// "git-upload-pack"`, leaving `git-receive-pack` ungated) covers a subset of
|
||||
/// services and must NOT count as a full gate. Other handlers carry no
|
||||
/// `service ==` discriminator, so for them this matches the plain
|
||||
/// substring check. NOTE: only `if service ==` is detected — a
|
||||
/// `match service { .. }` discriminator is NOT tracked and a gate inside one
|
||||
/// arm would pass as full; avoid that shape, or extend the span loop below.
|
||||
fn gate_runs_unconditionally(body: &str, markers: &[&str]) -> bool {
|
||||
// Brace-matched spans of each `if service == ...` block.
|
||||
let mut cond_spans: Vec<(usize, usize)> = Vec::new();
|
||||
let mut search = 0;
|
||||
while let Some(rel) = body[search..].find("if service ==") {
|
||||
let cond_start = search + rel;
|
||||
let Some(brace_rel) = body[cond_start..].find('{') else {
|
||||
break;
|
||||
};
|
||||
let open = cond_start + brace_rel;
|
||||
let mut depth = 0i32;
|
||||
let mut end = body.len();
|
||||
for (i, c) in body[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = open + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cond_spans.push((open, end));
|
||||
// On an unclosed block `end` stays at body.len() (the fail-safe
|
||||
// direction: treat the rest as conditional rather than mask a gate);
|
||||
// clamp so the next slice can't index past the end and panic.
|
||||
search = (end + 1).min(body.len());
|
||||
}
|
||||
markers.iter().any(|m| {
|
||||
body.match_indices(m)
|
||||
.any(|(pos, _)| !cond_spans.iter().any(|(s, e)| pos >= *s && pos <= *e))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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`
|
||||
/// / `did_matches` / a `&auth.0` self-binding) — or be listed in KNOWN_UNGATED
|
||||
/// (the tracked, ungated reads). A new ungated handler of ANY verb trips this,
|
||||
/// and a completeness scan over `src/api/` trips it for a whole new module that
|
||||
/// adds a repo-scoped handler without being wired into `sources`. Mutations are
|
||||
/// also checked precisely, per handler, by the mutation guard above; here they
|
||||
/// only need *some* binding so the net stays verb-agnostic.
|
||||
///
|
||||
/// Scope and limits (this is a source scrape; the runtime route guard covers
|
||||
/// behaviour):
|
||||
/// - It proves a gate is CALLED, not that it runs on the requested path: a
|
||||
/// content handler gating `"/"` instead of the subtree still passes here.
|
||||
/// - It sees handlers taking the owner+repo tuple `Path<(String, String..)>`; a
|
||||
/// repo identified by a struct/custom extractor would be missed (the helper
|
||||
/// unit tests pin these boundaries).
|
||||
/// - KNOWN_UNGATED entries need a real open issue and review; the staleness
|
||||
/// assert removes one only once its gate lands.
|
||||
#[test]
|
||||
fn every_repo_scoped_handler_is_gated() {
|
||||
let sources: &[(&str, &str)] = &[
|
||||
(include_str!("bounties.rs"), "bounties.rs"),
|
||||
(include_str!("certs.rs"), "certs.rs"),
|
||||
(include_str!("changelog.rs"), "changelog.rs"),
|
||||
(include_str!("encrypted.rs"), "encrypted.rs"),
|
||||
(include_str!("events.rs"), "events.rs"),
|
||||
(include_str!("issues.rs"), "issues.rs"),
|
||||
(include_str!("labels.rs"), "labels.rs"),
|
||||
(include_str!("protect.rs"), "protect.rs"),
|
||||
(include_str!("pulls.rs"), "pulls.rs"),
|
||||
(include_str!("replicas.rs"), "replicas.rs"),
|
||||
(include_str!("repos.rs"), "repos.rs"),
|
||||
(include_str!("stars.rs"), "stars.rs"),
|
||||
(include_str!("visibility.rs"), "visibility.rs"),
|
||||
(include_str!("webhooks.rs"), "webhooks.rs"),
|
||||
];
|
||||
let listed: std::collections::HashSet<&str> = sources.iter().map(|(_, f)| *f).collect();
|
||||
assert!(!listed.is_empty(), "read-guard `sources` is empty");
|
||||
|
||||
// Completeness: every api/*.rs NOT already in `sources` must declare no
|
||||
// repo-scoped handler, so a brand-new module cannot add an ungated handler
|
||||
// the scrape never looks at. Reads the directory at test time (so the list
|
||||
// cannot silently drift from the filesystem) and only inspects unlisted
|
||||
// files — listed files are covered by the per-handler loop below.
|
||||
let api_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/api");
|
||||
for (_, f) in sources {
|
||||
assert!(
|
||||
std::path::Path::new(api_dir).join(f).exists(),
|
||||
"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");
|
||||
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 \
|
||||
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 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.
|
||||
// A repo-scoped mutation passes here on its write gate; the mutation guard
|
||||
// above additionally verifies the exact gate type per handler. NOTE: a local
|
||||
// rename of `caller`/`replica_did` must be paired with a marker update here.
|
||||
let markers = [
|
||||
"authorize_repo_read(",
|
||||
"visibility_check(",
|
||||
"require_repo_owner(",
|
||||
"require_owner(",
|
||||
"did_matches(",
|
||||
"caller = &auth.0",
|
||||
"replica_did = &auth.0",
|
||||
];
|
||||
|
||||
// Every KNOWN_UNGATED name must be a real handler (catch typos / renames).
|
||||
let all: std::collections::HashSet<String> =
|
||||
sources.iter().flat_map(|(s, _)| handler_names(s)).collect();
|
||||
for (n, _) in known_ungated {
|
||||
assert!(
|
||||
all.contains(*n),
|
||||
"KNOWN_UNGATED lists `{n}`, which is not a real handler (renamed or removed?)"
|
||||
);
|
||||
}
|
||||
|
||||
let mut checked = 0usize;
|
||||
for (src, file) in sources {
|
||||
for name in handler_names(src) {
|
||||
let body = fn_body(src, &name);
|
||||
if !is_repo_scoped(&body) {
|
||||
continue; // node-wide aggregate, not a repo-scoped surface
|
||||
}
|
||||
checked += 1;
|
||||
let gated = gate_runs_unconditionally(&body, &markers);
|
||||
assert!(
|
||||
gated || is_known(&name),
|
||||
"repo-scoped handler `{name}` ({file}) has no authz gate and is \
|
||||
not in KNOWN_UNGATED — add the visibility/owner gate with the \
|
||||
caller, or track it there"
|
||||
);
|
||||
assert!(
|
||||
!(gated && is_known(&name)),
|
||||
"handler `{name}` ({file}) is now gated — remove it from \
|
||||
KNOWN_UNGATED so the allowlist stays an accurate gap list"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Tripwire: if the scrape silently stopped finding handlers (e.g. a parser
|
||||
// regression in handler_names/is_repo_scoped), this floor fails loudly
|
||||
// instead of the guard passing vacuously. Current count is ~54 repo-scoped
|
||||
// handlers; 20 is a deliberate floor that trips only on a gross regression.
|
||||
assert!(
|
||||
checked >= 20,
|
||||
"egress guard only checked {checked} repo-scoped handlers — the scrape likely broke"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins the `handler_names` boundary: it collects every `pub`/`pub(crate)
|
||||
/// async fn` regardless of verb (so a `fetch_*` read cannot escape), and skips
|
||||
/// private `async fn` and sync `fn` helpers.
|
||||
#[test]
|
||||
fn handler_names_finds_all_pub_async_and_skips_others() {
|
||||
let src = "pub async fn list_things() {}\n\
|
||||
pub async fn fetch_thing() {}\n\
|
||||
pub(crate) async fn get_crate_thing() {}\n\
|
||||
async fn private_helper() {}\n\
|
||||
fn sync_helper() {}\n";
|
||||
let names = handler_names(src);
|
||||
// Verb-agnostic: a non-list/get read verb is still seen.
|
||||
assert!(names.contains(&"list_things".to_string()));
|
||||
assert!(names.contains(&"fetch_thing".to_string()));
|
||||
// pub(crate) routed handlers are in scope too.
|
||||
assert!(names.contains(&"get_crate_thing".to_string()));
|
||||
// Private/sync helpers are not routed handlers and are skipped.
|
||||
assert!(!names
|
||||
.iter()
|
||||
.any(|n| n == "private_helper" || n == "sync_helper"));
|
||||
}
|
||||
|
||||
/// Pins the `is_repo_scoped` boundary: an owner+repo tuple Path is repo-scoped;
|
||||
/// a single-segment or absent Path is a node-wide aggregate.
|
||||
#[test]
|
||||
fn is_repo_scoped_requires_owner_repo_path() {
|
||||
let repo =
|
||||
"fn get_x(s: State, Path((owner, name)): Path<(String, String)>) {\n body();\n}";
|
||||
let three = "fn get_y(Path((o, n, id)): Path<(String, String, String)>) {\n body();\n}";
|
||||
let node_wide = "fn list_z(s: State<AppState>) {\n body();\n}";
|
||||
let single = "fn get_w(Path(cid): Path<String>) {\n body();\n}";
|
||||
assert!(is_repo_scoped(repo), "owner+repo tuple is repo-scoped");
|
||||
assert!(is_repo_scoped(three), "owner+repo+id tuple is repo-scoped");
|
||||
assert!(!is_repo_scoped(node_wide), "no Path is node-wide");
|
||||
assert!(
|
||||
!is_repo_scoped(single),
|
||||
"single-segment Path is not repo-scoped"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins `gate_runs_unconditionally`: a gate nested only inside an
|
||||
/// `if service == ...` block is conditional (does NOT count), while the same
|
||||
/// gate at the top level — or an additional unconditional one — does.
|
||||
#[test]
|
||||
fn conditional_service_gate_is_not_a_full_gate() {
|
||||
let markers = ["visibility_check("];
|
||||
// Gate runs only for one service: not a full gate.
|
||||
let conditional = "fn f() {\n \
|
||||
if service == \"git-upload-pack\" {\n \
|
||||
visibility_check(rules, caller);\n \
|
||||
}\n \
|
||||
if service == \"git-receive-pack\" { acquire_fresh(); }\n}";
|
||||
assert!(
|
||||
!gate_runs_unconditionally(conditional, &markers),
|
||||
"a gate only inside `if service ==` covers a subset of services"
|
||||
);
|
||||
// Same gate at top level: full gate.
|
||||
let unconditional = "fn f() {\n \
|
||||
visibility_check(rules, caller);\n \
|
||||
if service == \"git-receive-pack\" { acquire_fresh(); }\n}";
|
||||
assert!(
|
||||
gate_runs_unconditionally(unconditional, &markers),
|
||||
"an unconditional gate runs for every service"
|
||||
);
|
||||
// A gate inside EACH of two service blocks, none outside: still a
|
||||
// subset (no service clears it unconditionally), so not a full gate.
|
||||
let both_conditional = "fn f() {\n \
|
||||
if service == \"git-upload-pack\" { visibility_check(a); }\n \
|
||||
if service == \"git-receive-pack\" { visibility_check(b); }\n}";
|
||||
assert!(
|
||||
!gate_runs_unconditionally(both_conditional, &markers),
|
||||
"a gate inside every service block is still conditional"
|
||||
);
|
||||
// A marker inside one block AND again unconditionally: the
|
||||
// unconditional occurrence makes it a full gate (exercises the
|
||||
// match_indices scan past the in-block hit).
|
||||
let inside_and_outside = "fn f() {\n \
|
||||
if service == \"git-upload-pack\" { visibility_check(a); }\n \
|
||||
visibility_check(b);\n}";
|
||||
assert!(
|
||||
gate_runs_unconditionally(inside_and_outside, &markers),
|
||||
"an unconditional occurrence counts even when another is conditional"
|
||||
);
|
||||
// No marker at all: not gated.
|
||||
assert!(!gate_runs_unconditionally(
|
||||
"fn f() { do_thing(); }",
|
||||
&markers
|
||||
));
|
||||
// An unclosed `if service ==` block (e.g. phantom brace from a string
|
||||
// literal) must not panic on the slice advance; the span runs to EOF, so
|
||||
// the in-block marker reads as conditional. Real Rust source is balanced,
|
||||
// so this only guards the scraper against a future pathological body.
|
||||
let unclosed = "fn f() { if service == \"x\" { visibility_check(a);";
|
||||
assert!(
|
||||
!gate_runs_unconditionally(unclosed, &markers),
|
||||
"an unclosed service block must not panic and stays conditional"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,123 +40,6 @@ pub struct PeerResponse {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
|
||||
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
|
||||
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
|
||||
/// (`64:ff9b::/96`). Returns `None` for native IPv6. Callers fold the result
|
||||
/// back through the v4 range checks so loopback/private addresses smuggled in
|
||||
/// via any of these encodings are rejected, not just the mapped/compatible pair.
|
||||
///
|
||||
/// INCOMPLETE-NAT64 CONTRACT: non-well-known NAT64 prefixes (e.g. the RFC 8215
|
||||
/// local-use `64:ff9b:1::/48`) are deliberately **not** decoded here — their v4
|
||||
/// sits at a prefix-length-dependent offset (RFC 6052 §2.2) — so they return
|
||||
/// `None`. Any caller that needs them blocked must reject the wider
|
||||
/// `64:ff9b::/32` itself; `is_public_http_url` does this in its native-v6 arm.
|
||||
fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
|
||||
return Some(v4);
|
||||
}
|
||||
let s = v6.segments();
|
||||
// 6to4 (2002::/16) embeds W.X.Y.Z in the next two segments.
|
||||
if s[0] == 0x2002 {
|
||||
let [a, b] = s[1].to_be_bytes();
|
||||
let [c, d] = s[2].to_be_bytes();
|
||||
return Some(std::net::Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
// NAT64 well-known prefix (64:ff9b::/96) embeds the IPv4 in the low 32 bits.
|
||||
if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
|
||||
let [a, b] = s[6].to_be_bytes();
|
||||
let [c, d] = s[7].to_be_bytes();
|
||||
return Some(std::net::Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
|
||||
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
|
||||
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
|
||||
/// and by the boot-time prune of already-poisoned rows.
|
||||
pub fn is_public_http_url(raw: &str) -> bool {
|
||||
let url = match reqwest::Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
let mut host = match url.host_str() {
|
||||
Some(h) => h.to_ascii_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
// Drop a single trailing dot (FQDN root): `localhost.` resolves the same as
|
||||
// `localhost`, so normalize before the suffix/equality checks below.
|
||||
if let Some(stripped) = host.strip_suffix('.') {
|
||||
host = stripped.to_string();
|
||||
}
|
||||
if host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// host_str() keeps brackets on IPv6 literals (e.g. "[::1]"); strip them
|
||||
// before parsing as an IP.
|
||||
let ip_candidate = host.trim_start_matches('[').trim_end_matches(']');
|
||||
if let Ok(ip) = ip_candidate.parse::<std::net::IpAddr>() {
|
||||
// Reject loopback/unspecified on the literal as given — catches `::1`
|
||||
// and `::` before the IPv4-folding below (`::1`.to_ipv4() would
|
||||
// otherwise map to a non-loopback `0.0.0.1`).
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
|
||||
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
|
||||
// loopback/private addresses smuggled in via an IPv6 encoding, then
|
||||
// re-check loopback/unspecified.
|
||||
let ip = match ip {
|
||||
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
|
||||
v4 => v4,
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
|
||||
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
|
||||
// 0.0.0.0 itself is already caught by the is_unspecified check).
|
||||
if v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|
||||
|| o[0] == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// fc00::/7 (unique-local) or fe80::/10 (link-local)
|
||||
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
|
||||
// decodable well-known /96 — e.g. the RFC 8215 local-use
|
||||
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
|
||||
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
|
||||
// Rather than risk a wrong decode across every prefix length we
|
||||
// reject the whole NAT64 space here. The well-known /96 was
|
||||
// already folded to its v4 above and never reaches this arm.
|
||||
if s[0] == 0x0064 && s[1] == 0xff9b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// GET /api/v1/peers
|
||||
pub async fn list_peers(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
|
||||
let peers = state.db.list_peers().await?;
|
||||
@@ -200,14 +83,10 @@ pub async fn announce(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the URL is a public http(s) endpoint. The announce route is
|
||||
// reachable unauthenticated (until all peers sign), so without this an
|
||||
// attacker can register loopback/private "peers" (localhost:5432, etc.)
|
||||
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
|
||||
// the real peers under junk so node-origin repos stop replicating.
|
||||
if !is_public_http_url(&req.http_url) {
|
||||
// Validate the URL is HTTP/HTTPS
|
||||
if !req.http_url.starts_with("http://") && !req.http_url.starts_with("https://") {
|
||||
return Err(AppError::BadRequest(
|
||||
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
"http_url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -296,8 +175,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 +226,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(
|
||||
@@ -395,7 +270,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(),
|
||||
@@ -437,13 +311,7 @@ pub async fn ping_peer(
|
||||
|
||||
// Async ping
|
||||
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
|
||||
// Use the shared no-redirect client: bare `reqwest::get` follows redirects,
|
||||
// so a peer could answer with `302 -> http://127.0.0.1/` and turn the ping
|
||||
// into an SSRF probe.
|
||||
let ok = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
let ok = reqwest::get(&url)
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
@@ -456,544 +324,3 @@ pub async fn ping_peer(
|
||||
"reachable": ok,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_public_http_url;
|
||||
|
||||
#[test]
|
||||
fn accepts_public_https_and_http() {
|
||||
assert!(is_public_http_url("https://node.gitlawb.com"));
|
||||
assert!(is_public_http_url("https://manila.gitlawb.com/"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_loopback_private_and_internal() {
|
||||
for bad in [
|
||||
"http://localhost:7545",
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://localhost:22/",
|
||||
"http://0.0.0.0:7545",
|
||||
// RFC1122 "this host" block 0.0.0.0/8 beyond 0.0.0.0 itself,
|
||||
// including the upper boundary (1.0.0.0 is public, tested below).
|
||||
"http://0.0.0.1:7545",
|
||||
"http://0.1.2.3/",
|
||||
"http://0.255.255.255/",
|
||||
"http://10.0.0.5:7545",
|
||||
"http://192.168.1.10/",
|
||||
"http://172.16.0.1:7545",
|
||||
"http://169.254.1.1/",
|
||||
"http://[::1]:7545",
|
||||
"http://gitlawb-node.internal:7545",
|
||||
"http://my-node.local/",
|
||||
"ftp://node.gitlawb.com",
|
||||
"not-a-url",
|
||||
"",
|
||||
// Trailing-dot FQDN of an internal host.
|
||||
"http://localhost./",
|
||||
// CGNAT (100.64.0.0/10).
|
||||
"http://100.64.0.1:7545",
|
||||
"http://100.127.255.255/",
|
||||
// IPv4-mapped / IPv4-compatible IPv6 smuggling private/loopback v4.
|
||||
"http://[::ffff:127.0.0.1]:7545",
|
||||
"http://[::ffff:10.0.0.1]/",
|
||||
"http://[::ffff:192.168.1.1]:7545",
|
||||
"http://[::127.0.0.1]/",
|
||||
// 6to4 (2002::/16) embedding loopback/private v4.
|
||||
"http://[2002:7f00:1::]:7545",
|
||||
"http://[2002:a00:1::]/",
|
||||
"http://[2002:c0a8:101::]/",
|
||||
// NAT64 well-known prefix (64:ff9b::/96) embedding loopback/private v4.
|
||||
"http://[64:ff9b::7f00:1]/",
|
||||
"http://[64:ff9b::a00:1]:7545",
|
||||
// NAT64 local-use prefix (64:ff9b:1::/48, RFC 8215) — rejected
|
||||
// outright rather than decoded, so the whole NAT64 space is closed.
|
||||
"http://[64:ff9b:1::7f00:1]/",
|
||||
"http://[64:ff9b:1::a00:1]:7545",
|
||||
"http://[64:ff9b:1:a00::]/",
|
||||
// Local-use NAT64 wrapping a *public* v4 (203.0.113.10) is still
|
||||
// rejected: the block is prefix-based, not payload-based.
|
||||
"http://[64:ff9b:1::cb00:710a]/",
|
||||
] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_6to4_and_nat64_wrapping_public_v4() {
|
||||
// Fold-and-recheck stays consistent with the mapped/compatible handling:
|
||||
// a transition address that wraps a public v4 is allowed, only the
|
||||
// private/loopback-wrapping forms above are rejected. 203.0.113.10.
|
||||
assert!(is_public_http_url("http://[2002:cb00:710a::]/"));
|
||||
assert!(is_public_http_url("http://[64:ff9b::cb00:710a]/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_public_outside_cgnat_range() {
|
||||
// 100.0.0.0/10 boundary: .63 and .128 are public, only 100.64-127 is CGNAT.
|
||||
assert!(is_public_http_url("http://100.63.255.255:7545"));
|
||||
assert!(is_public_http_url("http://100.128.0.1/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_first_address_above_this_host_block() {
|
||||
// 1.0.0.0 is the first address above the rejected 0.0.0.0/8 block;
|
||||
// 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);
|
||||
}
|
||||
|
||||
const NOTIFY_BODY: &str = r#"{"repo":"demo","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)"
|
||||
);
|
||||
}
|
||||
|
||||
#[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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -23,10 +23,15 @@ pub async fn protect_branch(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// Only the repo owner can protect branches (DID-safe match, shared idiom).
|
||||
// Only the repo owner can protect branches
|
||||
let caller = &auth.0;
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
if caller != &record.owner_did && caller != owner_short {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the repo owner can protect branches".into(),
|
||||
));
|
||||
}
|
||||
@@ -58,8 +63,13 @@ pub async fn unprotect_branch(
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let caller = &auth.0;
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
if caller != &record.owner_did && caller != owner_short {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the repo owner can unprotect branches".into(),
|
||||
));
|
||||
}
|
||||
@@ -79,14 +89,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?;
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ pub async fn create_pr(
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<CreatePrRequest>,
|
||||
) -> Result<(StatusCode, Json<PullRequest>)> {
|
||||
// Authorize the caller as a reader before accepting a PR: a non-reader must
|
||||
// not be able to open a PR (and fire its webhooks) against a private repo
|
||||
// they cannot read. Mirrors create_review / create_comment / create_bounty.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let author_did = auth.0;
|
||||
let target_branch = req
|
||||
@@ -194,11 +194,6 @@ pub async fn merge_pr(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
// Owner-only merge (N7). Merging writes the served tree, so this is the same
|
||||
// trust boundary as owner-only push; it subsumes branch protection (a
|
||||
// non-owner cannot merge to any branch, protected or not).
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
@@ -257,7 +252,6 @@ pub async fn merge_pr(
|
||||
/// POST /api/v1/repos/:owner/:repo/pulls/:number/close
|
||||
pub async fn close_pr(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -272,15 +266,6 @@ pub async fn close_pr(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
// Owner OR author may close (forge norm).
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
let is_author = crate::api::did_matches(&auth.0, &pr.author_did);
|
||||
if !is_owner && !is_author {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the PR author can close this PR".into(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.close_pr(&pr.id).await?;
|
||||
|
||||
webhooks::fire_event(
|
||||
@@ -305,9 +290,11 @@ pub async fn create_review(
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
Json(req): Json<CreateReviewRequest>,
|
||||
) -> Result<(StatusCode, Json<PrReview>)> {
|
||||
// Read-gate: a reviewer must be able to read the repo, but need not own it.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -382,9 +369,11 @@ pub async fn create_comment(
|
||||
));
|
||||
}
|
||||
|
||||
// Read-gate: a commenter must be able to read the repo, but need not own it.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
|
||||
@@ -35,32 +35,14 @@ pub struct RegisterResponse {
|
||||
/// Accepts only requests with a valid HTTP Signature (enforced by middleware).
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
axum::Extension(auth): axum::Extension<crate::auth::AuthenticatedDid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>)> {
|
||||
// iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify up front; spend
|
||||
// the proof only once the request is admissible, just before the write, so a
|
||||
// rejected request never burns a valid proof.
|
||||
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;
|
||||
|
||||
// Parse and validate the DID
|
||||
let agent_did: Did = req
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Bind registration to the authenticated signer: an agent may only register
|
||||
// itself, not create or refresh a trust row for a DID it does not control.
|
||||
if !crate::api::did_matches(&auth.0, agent_did.as_str()) {
|
||||
return Err(AppError::Forbidden(
|
||||
"did must be the authenticated signer".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Request is admissible — spend the proof now, immediately before the write.
|
||||
proof.consume(&state.db).await?;
|
||||
|
||||
// Store the agent in the local index
|
||||
state
|
||||
.db
|
||||
|
||||
@@ -47,9 +47,8 @@ pub async fn register_replica(
|
||||
|
||||
let replica_did = &auth.0;
|
||||
|
||||
// Don't let an owner register themselves as a replica of their own repo
|
||||
// (did_matches handles the full vs bare did:key owner form).
|
||||
if crate::api::did_matches(replica_did, &record.owner_did) {
|
||||
// Don't let an owner register themselves as a replica of their own repo.
|
||||
if replica_did == &record.owner_did {
|
||||
return Err(AppError::BadRequest(
|
||||
"the repo owner is not a replica of their own repo".into(),
|
||||
));
|
||||
@@ -113,17 +112,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?;
|
||||
|
||||
|
||||
+305
-1666
File diff suppressed because it is too large.
Load diff
@@ -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?;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! POST /api/v1/tasks/{id}/fail — fail task
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
@@ -18,18 +18,9 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::AgentTask;
|
||||
use crate::state::{AppState, TaskEventBroadcast};
|
||||
|
||||
/// 403 in this module's error shape (`(StatusCode, Json<Value>)`, not `AppError`).
|
||||
fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": "forbidden", "message": msg })),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Request / response types ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -64,11 +55,13 @@ pub struct ClaimTaskBody {
|
||||
#[derive(Deserialize)]
|
||||
pub struct CompleteTaskBody {
|
||||
pub result: Option<String>,
|
||||
pub by_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FailTaskBody {
|
||||
pub reason: Option<String>,
|
||||
pub by_did: Option<String>,
|
||||
}
|
||||
|
||||
fn task_to_json(t: &AgentTask) -> Value {
|
||||
@@ -94,20 +87,15 @@ fn task_to_json(t: &AgentTask) -> Value {
|
||||
/// POST /api/v1/tasks
|
||||
pub async fn create_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Json(body): Json<CreateTaskBody>,
|
||||
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
||||
// Bind the delegator to the authenticated signer (N13).
|
||||
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
|
||||
return Err(forbidden("delegator_did must be the authenticated signer"));
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: body.repo_id,
|
||||
kind: body.kind,
|
||||
status: "pending".to_string(),
|
||||
delegator_did: auth.0,
|
||||
delegator_did: body.delegator_did,
|
||||
assignee_did: body.assignee_did,
|
||||
capability: body.capability,
|
||||
ucan_token: body.ucan_token,
|
||||
@@ -166,25 +154,24 @@ pub async fn get_task(
|
||||
/// POST /api/v1/tasks/{id}/claim
|
||||
pub async fn claim_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ClaimTaskBody>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Bind the assignee to the authenticated signer (N13).
|
||||
if !crate::api::did_matches(&auth.0, &body.assignee_did) {
|
||||
return Err(forbidden("assignee_did must be the authenticated signer"));
|
||||
}
|
||||
let task = state.db.claim_task(&id, &auth.0).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let task = state
|
||||
.db
|
||||
.claim_task(&id, &body.assignee_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let _ = state.task_event_tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: auth.0,
|
||||
by_did: body.assignee_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(Json(task_to_json(&task)))
|
||||
@@ -193,44 +180,17 @@ pub async fn claim_task(
|
||||
/// POST /api/v1/tasks/{id}/complete
|
||||
pub async fn complete_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<CompleteTaskBody>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Authorize the actor, not just bind their identity: the N13 signer-binding
|
||||
// proved the caller was whoever they claimed, but never that they were the
|
||||
// task's assignee. Load the task and require the caller to be its assignee;
|
||||
// finish_task then transitions only a claimed task.
|
||||
let existing = state
|
||||
.db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "task not found" })),
|
||||
)
|
||||
})?;
|
||||
if !crate::api::did_matches(
|
||||
&auth.0,
|
||||
existing.assignee_did.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
return Err(forbidden("only the task assignee can complete it"));
|
||||
}
|
||||
let by_did = auth.0;
|
||||
let by_did = body.by_did.unwrap_or_default();
|
||||
let task = state
|
||||
.db
|
||||
.finish_task(&id, "completed", body.result.as_deref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
@@ -247,36 +207,10 @@ pub async fn complete_task(
|
||||
/// POST /api/v1/tasks/{id}/fail
|
||||
pub async fn fail_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<FailTaskBody>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Authorize the actor, not just bind their identity (see complete_task): only
|
||||
// the task's assignee may fail it, and finish_task transitions only a claimed
|
||||
// task.
|
||||
let existing = state
|
||||
.db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "task not found" })),
|
||||
)
|
||||
})?;
|
||||
if !crate::api::did_matches(
|
||||
&auth.0,
|
||||
existing.assignee_did.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
return Err(forbidden("only the task assignee can fail it"));
|
||||
}
|
||||
let by_did = auth.0;
|
||||
let by_did = body.by_did.unwrap_or_default();
|
||||
let reason = body.reason.unwrap_or_default();
|
||||
let task = state
|
||||
.db
|
||||
@@ -284,7 +218,7 @@ pub async fn fail_task(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -26,16 +26,17 @@ pub struct RemoveVisibilityRequest {
|
||||
}
|
||||
|
||||
fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
|
||||
// DID-safe owner match (collapses did:key full vs bare on both sides, never
|
||||
// across methods), shared with require_repo_owner — not a trailing-segment
|
||||
// compare that only normalized the owner side.
|
||||
if crate::api::did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
if caller != record.owner_did && caller != owner_short {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the repo owner can manage visibility".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject malformed globs before they reach the store, where they would
|
||||
@@ -56,23 +57,9 @@ fn validate_path_glob(path_glob: &str) -> Result<()> {
|
||||
"path_glob must not end with '/'".into(),
|
||||
));
|
||||
}
|
||||
// Reject empty path segments. `glob_prefix` collapses "//**" to "/" (whole
|
||||
// repo), so without this an operator could bypass the "/**" guard above and
|
||||
// grant repo-wide read with "//**", or set a dead rule like "//secret/**"
|
||||
// whose prefix never matches a real single-slash path and silently leaves
|
||||
// the subtree readable.
|
||||
if path_glob.contains("//") {
|
||||
if path_glob.contains('*') && !path_glob.ends_with("/**") {
|
||||
return Err(AppError::BadRequest(
|
||||
"path_glob must not contain empty path segments".into(),
|
||||
));
|
||||
}
|
||||
// Strip the one permitted trailing "/**" before checking for stray
|
||||
// wildcards, otherwise a "*" elsewhere in the prefix (e.g. "/a*b/**" or
|
||||
// "/a/**/**") slips through because the string still ends with "/**".
|
||||
let prefix = path_glob.strip_suffix("/**").unwrap_or(path_glob);
|
||||
if prefix.contains('*') {
|
||||
return Err(AppError::BadRequest(
|
||||
"the only supported wildcard is a single trailing '/**'".into(),
|
||||
"the only supported wildcard is a trailing '/**'".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -253,22 +240,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_globs() {
|
||||
// empty, no leading slash, whole-repo via "/**", trailing slash,
|
||||
// non-trailing wildcards, double wildcards, and empty path segments
|
||||
// (which would collapse to whole-repo or a dead rule) are all rejected.
|
||||
for g in [
|
||||
"",
|
||||
"secret/**",
|
||||
"/**",
|
||||
"/**/**",
|
||||
"/secret/",
|
||||
"/a*b",
|
||||
"/*/x",
|
||||
"/a*b/**",
|
||||
"/a/**/**",
|
||||
"//**",
|
||||
"//secret/**",
|
||||
] {
|
||||
// empty, no leading slash, whole-repo via "/**", trailing slash, and
|
||||
// non-trailing wildcards are all rejected.
|
||||
for g in ["", "secret/**", "/**", "/secret/", "/a*b", "/*/x"] {
|
||||
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -38,16 +38,11 @@ pub async fn create_webhook(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Gate the target through the same hardened public-host validator the peer
|
||||
// announce path uses, so an authenticated owner cannot register a webhook
|
||||
// that makes the node POST to loopback/private/link-local/metadata
|
||||
// endpoints (SSRF). Delivery runs on the shared no-redirect client
|
||||
// (main.rs), which closes the 3xx-to-internal bounce.
|
||||
if !crate::api::peers::is_public_http_url(&req.url) {
|
||||
// Validate URL is http/https
|
||||
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
|
||||
return Err(AppError::BadRequest(
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
"webhook URL must be http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -73,26 +68,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
|
||||
@@ -114,7 +95,6 @@ pub async fn list_webhooks(
|
||||
/// DELETE /api/v1/repos/:owner/:repo/hooks/:id
|
||||
pub async fn delete_webhook(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -122,7 +102,6 @@ pub async fn delete_webhook(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Verify the webhook belongs to this repo
|
||||
let hook = state
|
||||
@@ -138,35 +117,3 @@ pub async fn delete_webhook(
|
||||
state.db.delete_webhook(&id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true, "id": id })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::peers::is_public_http_url;
|
||||
|
||||
// create_webhook gates req.url through is_public_http_url. Pin the exact
|
||||
// SSRF targets from issue #81 so the webhook path can never regress to the
|
||||
// old scheme-only check, independent of the validator's own peer tests.
|
||||
#[test]
|
||||
fn webhook_url_gate_rejects_ssrf_targets() {
|
||||
for bad in [
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost/",
|
||||
"http://10.0.0.5/",
|
||||
"http://[::1]/",
|
||||
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
|
||||
"http://[2002:7f00:1::]/",
|
||||
"http://[64:ff9b::7f00:1]/",
|
||||
"ftp://example.com/",
|
||||
"not-a-url",
|
||||
] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_gate_allows_public_targets() {
|
||||
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545/"));
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ mod tests {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000000000000000000000000000".into(),
|
||||
new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
|
||||
cid: Some("bafyreib5...".into()),
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
@@ -270,7 +270,7 @@ mod tests {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
old_sha: "0".repeat(64),
|
||||
new_sha: "a1b2c3d4".repeat(8),
|
||||
cid: None,
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
@@ -286,44 +286,6 @@ mod tests {
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_anchor_body_carries_real_old_sha() {
|
||||
// The anchored body must serialize the real old→new transition the
|
||||
// node was handed, never a zero placeholder. Regression guard for the
|
||||
// push handler that used to hardcode `old_sha` to 64 zeros (#26).
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let real_old = "1111111111111111111111111111111111111111";
|
||||
let real_new = "2222222222222222222222222222222222222222";
|
||||
let _mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_body(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)),
|
||||
mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"id":"TX_REAL_OLD_SHA","timestamp":1710000000000,"version":"1.0.0"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let anchor = RefAnchor {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: real_old.into(),
|
||||
new_sha: real_new.into(),
|
||||
cid: None,
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
node_did: "did:key:z6Mknnd...".into(),
|
||||
};
|
||||
|
||||
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
|
||||
assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA");
|
||||
// The mock only matches when the posted JSON carries both real SHAs.
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arweave_url() {
|
||||
let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
|
||||
|
||||
@@ -17,18 +17,6 @@ use crate::state::AppState;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// Whether `caller` is authorized to push to `record`.
|
||||
///
|
||||
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
|
||||
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
|
||||
/// intentionally a distinct, intent-named gate rather than a bare owner check so
|
||||
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
|
||||
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
|
||||
/// call sites.
|
||||
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
|
||||
crate::api::did_matches(caller, &record.owner_did)
|
||||
}
|
||||
|
||||
use gitlawb_core::http_sig::{
|
||||
build_signing_string, compute_content_digest, HttpSignature, COVERED_COMPONENTS,
|
||||
};
|
||||
@@ -514,11 +502,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -49,14 +49,6 @@ pub struct Config {
|
||||
)]
|
||||
pub require_signed_peer_writes: bool,
|
||||
|
||||
/// Require the authenticated pusher to be the repo owner on `git-receive-pack`.
|
||||
/// Authentication (a valid did:key signature) is not authorization on its own:
|
||||
/// any party can sign as their own DID. When true, pushes whose authenticated
|
||||
/// DID is not the repo owner are rejected. Keep false during rolling upgrades;
|
||||
/// flip it on once owners are ready for owner-only writes.
|
||||
#[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)]
|
||||
pub enforce_owner_push: bool,
|
||||
|
||||
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
|
||||
#[arg(long, env = "GITLAWB_IPFS_API", default_value = "")]
|
||||
pub ipfs_api: String,
|
||||
@@ -140,20 +132,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 +144,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 +161,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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+151
-3002
File diff suppressed because it is too large.
Load diff
@@ -38,70 +38,6 @@ fn did_to_key(did: &str) -> Option<VerifyingKey> {
|
||||
Did::from_str(did).ok()?.to_verifying_key().ok()
|
||||
}
|
||||
|
||||
/// Resolve every recipient DID to its verifying key, all-or-nothing.
|
||||
///
|
||||
/// Returns `Ok(keys)` only when every DID resolves. If any DID fails, returns
|
||||
/// `Err(unresolved)` listing the unresolvable DID strings so the caller can fail
|
||||
/// closed rather than seal to a partial recipient set (#47): sealing to a subset
|
||||
/// while recording the full set as covered permanently locks out the dropped
|
||||
/// readers. Resolution is local-only, so `did:web`/`did:gitlawb` recipients (and
|
||||
/// any malformed `did:key`) land in the unresolved set until off-`did:key`
|
||||
/// resolution exists.
|
||||
fn resolve_all_recipients(dids: &BTreeSet<String>) -> Result<Vec<VerifyingKey>, Vec<String>> {
|
||||
let mut keys = Vec::with_capacity(dids.len());
|
||||
let mut unresolved = Vec::new();
|
||||
for did in dids {
|
||||
match did_to_key(did) {
|
||||
Some(k) => keys.push(k),
|
||||
None => unresolved.push(did.clone()),
|
||||
}
|
||||
}
|
||||
if unresolved.is_empty() {
|
||||
Ok(keys)
|
||||
} else {
|
||||
Err(unresolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do with a single withheld blob, decided without any DB or IO so the
|
||||
/// fail-closed invariant (#47) is unit-testable in isolation.
|
||||
#[derive(Debug)]
|
||||
enum SealPlan {
|
||||
/// An existing envelope already covers exactly this recipient set; nothing to do.
|
||||
SkipUnchanged,
|
||||
/// No recipient DID resolved to a key, so there is nothing to seal to.
|
||||
SkipNoRecipients,
|
||||
/// At least one recipient DID is unresolvable. Fail closed: never seal to a
|
||||
/// partial set. Carries the unresolvable DIDs for logging.
|
||||
SkipUnresolvable(Vec<String>),
|
||||
/// Seal to `keys` and record coverage under `tag`.
|
||||
Seal {
|
||||
keys: Vec<VerifyingKey>,
|
||||
tag: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decide what to do with one blob given its desired recipient set and the tag
|
||||
/// already stored for it (if any). Pure: no DB, no IO.
|
||||
///
|
||||
/// This is the #47 fail-closed gate in isolation: it returns `Seal` only when
|
||||
/// EVERY recipient DID resolves, so no caller can seal to a partial set. A
|
||||
/// changed recipient set (different tag) re-seals so a newly added reader can
|
||||
/// recover the blob; reader removal is not retroactive (the old envelope is
|
||||
/// already public). The comparison is on the opaque node-keyed tag, never the
|
||||
/// DID list.
|
||||
fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&str>) -> SealPlan {
|
||||
let tag = recipients_tag(node_seed, dids);
|
||||
if stored_tag == Some(tag.as_str()) {
|
||||
return SealPlan::SkipUnchanged;
|
||||
}
|
||||
match resolve_all_recipients(dids) {
|
||||
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
|
||||
Ok(keys) => SealPlan::Seal { keys, tag },
|
||||
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt and pin every withheld blob. `recipients` maps blob oid -> DID set;
|
||||
/// `node_seed` keys the opaque recipients tag. Returns `(oid, cid)` for each blob
|
||||
/// actually sealed and recorded this call (the per-push delta), used by Option B3
|
||||
@@ -115,53 +51,32 @@ pub async fn encrypt_and_pin(
|
||||
recipients: &HashMap<String, BTreeSet<String>>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut sealed = Vec::new();
|
||||
let mut skipped_unresolvable = 0usize;
|
||||
for (oid, dids) in recipients {
|
||||
// A DB read failure is not a cache miss: re-sealing here would do an
|
||||
// avoidable IPFS write during a partial outage. Skip and retry next push.
|
||||
let stored_tag = match db.encrypted_blob_recipients_tag(repo_id, oid).await {
|
||||
Ok(t) => t,
|
||||
// Skip only if an existing envelope already covers exactly these
|
||||
// recipients. If the recipient set changed (e.g. a reader was added to
|
||||
// the rule), re-seal so the new reader can recover the blob. Reader
|
||||
// removal is not retroactive: the old envelope is already public. The
|
||||
// comparison is on the opaque node-keyed tag, never the DID list.
|
||||
let tag = recipients_tag(node_seed, dids);
|
||||
match db.encrypted_blob_recipients_tag(repo_id, oid).await {
|
||||
Ok(Some(stored_tag)) if stored_tag == tag => continue,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
// A DB read failure is not a cache miss: re-sealing here would do
|
||||
// an avoidable IPFS write during a partial outage. Skip and retry
|
||||
// on the next push.
|
||||
tracing::warn!(oid = %oid, err = %e, "recipients_tag lookup failed; skipping reseal");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Fail closed: plan_seal returns Seal only when every recipient DID
|
||||
// resolves, so we never seal to a partial set and record the full set as
|
||||
// covered (which would permanently lock out the dropped readers, #47).
|
||||
let (keys, tag) = match plan_seal(node_seed, dids, stored_tag.as_deref()) {
|
||||
SealPlan::SkipUnchanged => continue,
|
||||
SealPlan::SkipNoRecipients => {
|
||||
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
SealPlan::SkipUnresolvable(unresolved) => {
|
||||
skipped_unresolvable += 1;
|
||||
// DIDs are user-controlled (rule reader_dids); log a bounded
|
||||
// sample, not an unbounded dump. Wording stays neutral about the
|
||||
// cause: a malformed did:key is not DHT-pending and never will
|
||||
// resolve, unlike a did:gitlawb awaiting anchoring.
|
||||
let sample: Vec<&String> = unresolved.iter().take(3).collect();
|
||||
tracing::warn!(
|
||||
oid = %oid,
|
||||
unresolved_count = unresolved.len(),
|
||||
unresolved_sample = ?sample,
|
||||
"unresolvable recipient DID(s); skipping encrypted pin to avoid sealing to a partial set"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
SealPlan::Seal { keys, tag } => (keys, tag),
|
||||
};
|
||||
}
|
||||
let keys: Vec<VerifyingKey> = dids.iter().filter_map(|d| did_to_key(d)).collect();
|
||||
if keys.is_empty() {
|
||||
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
let data = match crate::git::store::read_object(repo_path, oid) {
|
||||
Ok(Some((_t, bytes))) => bytes,
|
||||
Ok(None) => {
|
||||
tracing::warn!(oid = %oid, "git object not found; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(oid = %oid, err = %e, "read_object failed; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let envelope = match seal_blob(&data, &keys) {
|
||||
Ok(e) => e,
|
||||
@@ -172,14 +87,7 @@ pub async fn encrypt_and_pin(
|
||||
};
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope).await {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
Ok(_) => {
|
||||
tracing::warn!(oid = %oid, "pin_git_object returned no cid; skipping");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(oid = %oid, err = %e, "pin_git_object failed; skipping");
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if let Err(e) = db.record_encrypted_blob(repo_id, oid, &cid, &tag).await {
|
||||
tracing::warn!(oid = %oid, err = %e, "record_encrypted_blob failed");
|
||||
@@ -187,85 +95,16 @@ pub async fn encrypt_and_pin(
|
||||
}
|
||||
sealed.push((oid.clone(), cid.clone()));
|
||||
}
|
||||
// One aggregate signal so a coverage collapse is a single greppable line, not
|
||||
// a per-oid scrape. In a fully-migrated did:gitlawb org every blob skips and
|
||||
// recovery coverage silently drops to zero; this is the operator's cue that
|
||||
// the gap is the deliberate fail-closed posture, not a malfunction.
|
||||
if skipped_unresolvable > 0 {
|
||||
tracing::warn!(
|
||||
sealed = sealed.len(),
|
||||
skipped = skipped_unresolvable,
|
||||
"encrypted-pin coverage reduced: blobs skipped for unresolvable recipients"
|
||||
);
|
||||
}
|
||||
sealed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use super::recipients_tag;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn did_key(seed: u8) -> String {
|
||||
let vk = SigningKey::from_bytes(&[seed; 32]).verifying_key();
|
||||
Did::from_verifying_key(&vk).to_string()
|
||||
}
|
||||
|
||||
// Accepts both `&[String]` (resolve tests, built from `did_key`) and
|
||||
// `&[&str]` (tag tests, built from literals).
|
||||
fn set<S: AsRef<str>>(dids: &[S]) -> BTreeSet<String> {
|
||||
dids.iter().map(|s| s.as_ref().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_did_key_recipients_resolve() {
|
||||
let dids = set(&[did_key(1), did_key(2)]);
|
||||
let keys = resolve_all_recipients(&dids).expect("all did:key resolve");
|
||||
assert_eq!(keys.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_set_with_did_gitlawb_fails_closed() {
|
||||
// Core #47 regression: a resolvable subset must never yield a sealable
|
||||
// key set. Two did:key plus one did:gitlawb -> Err naming only the
|
||||
// unresolvable DID.
|
||||
let gitlawb = Did::gitlawb("zSomeDhtKey").to_string();
|
||||
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
|
||||
assert_eq!(unresolved, vec![gitlawb]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_web_recipient_fails_closed() {
|
||||
let web = Did::web("example.com").to_string();
|
||||
let dids = set(&[did_key(1), web.clone()]);
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("did:web cannot resolve locally");
|
||||
assert_eq!(unresolved, vec![web]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_did_key_fails_closed() {
|
||||
// The third state: not a method-resolution gap but a permanently broken
|
||||
// did:key (invalid multibase). Must also fail closed.
|
||||
let broken = "did:key:z!!!invalid".to_string();
|
||||
let dids = set(&[did_key(1), broken.clone()]);
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("malformed did:key fails");
|
||||
assert_eq!(unresolved, vec![broken]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_set_resolves_to_empty_keys() {
|
||||
let dids = BTreeSet::new();
|
||||
let keys = resolve_all_recipients(&dids).expect("empty set is not an error");
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_unresolvable_did_returns_that_did() {
|
||||
let gitlawb = Did::gitlawb("zOnlyOne").to_string();
|
||||
let dids: BTreeSet<String> = [gitlawb.clone()].into_iter().collect();
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
|
||||
assert_eq!(unresolved, vec![gitlawb]);
|
||||
fn set(dids: &[&str]) -> BTreeSet<String> {
|
||||
dids.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,69 +133,4 @@ mod tests {
|
||||
"tag must depend on the node seed, not be a plain hash"
|
||||
);
|
||||
}
|
||||
|
||||
// plan_seal is the seal/skip decision `encrypt_and_pin` acts on. Testing it
|
||||
// directly pins the #47 fail-closed invariant at the function that owns it,
|
||||
// which a unit test of `resolve_all_recipients` alone cannot do (it can't
|
||||
// catch the caller falling through to a partial seal).
|
||||
const SEED: [u8; 32] = [9u8; 32];
|
||||
|
||||
#[test]
|
||||
fn plan_seal_seals_when_all_recipients_resolve() {
|
||||
let dids = set(&[did_key(1), did_key(2)]);
|
||||
match plan_seal(&SEED, &dids, None) {
|
||||
SealPlan::Seal { keys, tag } => {
|
||||
assert_eq!(keys.len(), 2, "must seal to the full recipient set");
|
||||
assert_eq!(
|
||||
tag,
|
||||
recipients_tag(&SEED, &dids),
|
||||
"records the full-set tag"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Seal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_fails_closed_on_any_unresolvable_recipient() {
|
||||
// The #47 invariant at the decision boundary: one unresolvable DID among
|
||||
// resolvable ones must NOT yield a Seal (which would seal a partial set).
|
||||
let gitlawb = Did::gitlawb("zPending").to_string();
|
||||
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
|
||||
match plan_seal(&SEED, &dids, None) {
|
||||
SealPlan::SkipUnresolvable(unresolved) => assert_eq!(unresolved, vec![gitlawb]),
|
||||
other => panic!("must fail closed, never seal a partial set; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_skips_empty_recipient_set() {
|
||||
let dids = BTreeSet::new();
|
||||
assert!(matches!(
|
||||
plan_seal(&SEED, &dids, None),
|
||||
SealPlan::SkipNoRecipients
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_skips_when_tag_unchanged() {
|
||||
let dids = set(&[did_key(1)]);
|
||||
let stored = recipients_tag(&SEED, &dids);
|
||||
assert!(matches!(
|
||||
plan_seal(&SEED, &dids, Some(&stored)),
|
||||
SealPlan::SkipUnchanged
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_reseals_when_recipient_set_changed() {
|
||||
// A stored tag for a DIFFERENT set is a miss: a newly added reader must
|
||||
// trigger a re-seal, not be skipped as unchanged.
|
||||
let dids = set(&[did_key(1), did_key(2)]);
|
||||
let stale = recipients_tag(&SEED, &set(&[did_key(1)]));
|
||||
match plan_seal(&SEED, &dids, Some(&stale)) {
|
||||
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2),
|
||||
other => panic!("changed recipient set must re-seal; got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,98 +23,21 @@ 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("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 +52,8 @@ 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(),
|
||||
),
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
AppError::Internal(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -165,21 +72,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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,18 +23,13 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
|
||||
use std::io::Write;
|
||||
let mut child = hash_output;
|
||||
// Write the blob to stdin, but always reap the child afterward even if the
|
||||
// write fails, so a stdin-write error can't drop the Child unwaited and leak
|
||||
// a zombie (#53).
|
||||
let write_result = match child.stdin.take() {
|
||||
Some(mut stdin) => stdin.write_all(json.as_bytes()),
|
||||
None => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"git hash-object stdin unavailable",
|
||||
)),
|
||||
};
|
||||
if let Some(stdin) = child.stdin.take() {
|
||||
let mut stdin = stdin;
|
||||
stdin
|
||||
.write_all(json.as_bytes())
|
||||
.context("failed to write to git hash-object stdin")?;
|
||||
}
|
||||
let output = child.wait_with_output().context("git hash-object failed")?;
|
||||
write_result.context("failed to write to git hash-object stdin")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod issues;
|
||||
pub mod push_delta;
|
||||
pub mod repo_store;
|
||||
pub mod smart_http;
|
||||
pub mod store;
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
//! Push-delta object enumeration for the IPFS/Pinata pin path.
|
||||
//!
|
||||
//! The pin path needs the set of git objects to consider for pinning after a
|
||||
//! push. Historically both pin functions enumerated the *whole* repo
|
||||
//! (`git cat-file --batch-all-objects`) on every push, so cost scaled with
|
||||
//! repo size rather than push size (finding N16). This module computes the
|
||||
//! per-push *delta* instead — the objects a push introduced — and keeps the
|
||||
//! whole-repo lister for the reconciliation sweep and the fail-closed fallback.
|
||||
//!
|
||||
//! ## Correctness framing (do not confuse with the #84 withholding direction)
|
||||
//!
|
||||
//! The pin enumeration is part of the *exposure* set, not the withheld filter.
|
||||
//! On the *delta* path, narrowing per push only *shrinks* what is pinned, so it
|
||||
//! cannot create an under-withholding leak — the reachable-only withheld filter
|
||||
//! (`visibility_pack`) still subtracts from the smaller set we feed it. The only
|
||||
//! risk there is *under-pinning* (a durability gap) the sweep backstops.
|
||||
//!
|
||||
//! The *full-scan* path is different: `list_all_objects` includes dangling
|
||||
//! objects the reachable withheld set never classified, so subtracting that set
|
||||
//! is not enough — a dangling private blob would slip through (#99). The caller
|
||||
//! signals a full scan via [`PinCandidateSet::full_scan`] and must then apply
|
||||
//! the fail-closed blob filter (`visibility_pack::replicable_objects_fail_closed`)
|
||||
//! instead of the plain reachable-only subtraction.
|
||||
//!
|
||||
//! Because the pin candidate set needs only the OID *set* (never the per-path
|
||||
//! information the withheld classifier needs), `git rev-list --objects
|
||||
//! --no-object-names` is safe here. The "rev-list reports one path per object"
|
||||
//! trap from #84 applies only to `visibility_pack`'s per-path `ls-tree` walk.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// Env var that forces the push path to always full-scan, bypassing the delta
|
||||
/// optimization (KTD7 kill-switch). Reuses the already-tested fallback branch
|
||||
/// rather than introducing a second enumeration path. Does not gate the sweep.
|
||||
const FORCE_FULL_SCAN_ENV: &str = "GITLAWB_PIN_FORCE_FULLSCAN";
|
||||
|
||||
/// The objects a push introduced, or a signal that the delta could not be
|
||||
/// computed safely and the caller should fall back to a full scan.
|
||||
///
|
||||
/// Three-valued by design: `Delta(vec![])` (a no-op push — pin nothing) is a
|
||||
/// *different* fact from `FullScanRequired` (could not compute — fall back),
|
||||
/// and they drive different actions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PinCandidates {
|
||||
/// Objects reachable from the new tips but not the old tips.
|
||||
Delta(Vec<String>),
|
||||
/// The delta is not safe to use; the caller must enumerate the whole repo
|
||||
/// with [`list_all_objects`] instead. Returned on a non-commit tip, any git
|
||||
/// error, or the force-full-scan kill-switch.
|
||||
FullScanRequired,
|
||||
}
|
||||
|
||||
/// Resolve the set of objects introduced by a push from its ref updates.
|
||||
///
|
||||
/// `new_tips` / `old_tips` are the non-zero new/old SHAs of the push's ref
|
||||
/// updates (the caller strips the all-zeros create/delete sentinel). Keeping
|
||||
/// the helper on plain SHA slices (not the `api` `RefUpdate` type) keeps it
|
||||
/// unit-testable without the HTTP layer.
|
||||
///
|
||||
/// Fail-closed: any condition where the introduced set cannot be safely
|
||||
/// determined returns [`PinCandidates::FullScanRequired`] rather than a partial
|
||||
/// set, so the caller full-scans instead of silently under-pinning.
|
||||
pub fn resolve_push_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> PinCandidates {
|
||||
// KTD7 kill-switch: force the (already-tested) full-scan fallback. The env
|
||||
// read is split out from the pure logic so the resolver stays unit-testable
|
||||
// without touching process-global state.
|
||||
resolve_push_delta_inner(repo_path, new_tips, old_tips, force_full_scan())
|
||||
}
|
||||
|
||||
/// Whether the force-full-scan kill-switch env var is set.
|
||||
fn force_full_scan() -> bool {
|
||||
std::env::var_os(FORCE_FULL_SCAN_ENV).is_some()
|
||||
}
|
||||
|
||||
// Intentionally private but reachable from the test module (via `use super::*`)
|
||||
// so the kill-switch can be exercised without mutating process-global env.
|
||||
fn resolve_push_delta_inner(
|
||||
repo_path: &Path,
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
force_full_scan: bool,
|
||||
) -> PinCandidates {
|
||||
if force_full_scan {
|
||||
tracing::debug!("{FORCE_FULL_SCAN_ENV} set — forcing full scan");
|
||||
return PinCandidates::FullScanRequired;
|
||||
}
|
||||
|
||||
// A push that only deleted refs introduces no objects.
|
||||
if new_tips.is_empty() {
|
||||
return PinCandidates::Delta(Vec::new());
|
||||
}
|
||||
|
||||
// Ref-type guard (the fail-closed mechanism). `git rev-list` does NOT error
|
||||
// on a non-commit tip — a blob/tree/tag-of-non-commit all exit 0 and walk
|
||||
// the object — so the rev-list exit code cannot catch this. Check each new
|
||||
// tip's *fully-peeled* type with `cat-file -t '<sha>^{}'` (recursive peel:
|
||||
// tag-of-commit -> commit, tag-of-tree -> tree, tag-of-tag-of-commit ->
|
||||
// commit). Bare `cat-file -t` returns `tag` for an annotated tag, and
|
||||
// `for-each-ref %(*objecttype)` peels only one level — neither is correct.
|
||||
for tip in new_tips {
|
||||
match peeled_object_type(repo_path, tip) {
|
||||
Some(t) if t == "commit" => {}
|
||||
other => {
|
||||
tracing::debug!(
|
||||
tip = %tip,
|
||||
peeled_type = ?other,
|
||||
"push tip is not a commit (or type lookup failed) — forcing full scan"
|
||||
);
|
||||
return PinCandidates::FullScanRequired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match rev_list_delta(repo_path, new_tips, old_tips) {
|
||||
Ok(oids) => PinCandidates::Delta(oids),
|
||||
Err(e) => {
|
||||
tracing::debug!(err = %e, "push-delta rev-list failed — forcing full scan");
|
||||
PinCandidates::FullScanRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the fully-peeled object type of `sha` (e.g. `commit`, `tree`,
|
||||
/// `blob`), or `None` if the object is missing/unpeelable or git errored.
|
||||
fn peeled_object_type(repo_path: &Path, sha: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["cat-file", "-t", &format!("{sha}^{{}}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Run `git rev-list --objects --no-object-names <new> --not <old>` and return
|
||||
/// the bare OID set. Decides on `status.success()` *before* parsing stdout, so
|
||||
/// a walk that prints a valid prefix then errors mid-walk is discarded.
|
||||
fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Result<Vec<String>> {
|
||||
let mut args: Vec<&str> = vec!["rev-list", "--objects", "--no-object-names"];
|
||||
args.extend_from_slice(new_tips);
|
||||
if !old_tips.is_empty() {
|
||||
args.push("--not");
|
||||
args.extend_from_slice(old_tips);
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git rev-list for push delta")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git rev-list failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List every object in the repository via
|
||||
/// `git cat-file --batch-all-objects --batch-check='%(objectname)'`.
|
||||
///
|
||||
/// This is the whole-repo enumeration the push path falls back to and the
|
||||
/// reconciliation sweep relies on. It returns *all* objects (including
|
||||
/// unreachable/dangling ones), which is what the sweep needs to catch
|
||||
/// stragglers — do not swap it for a reachability walk.
|
||||
pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Like [`list_all_objects`] but pairs each OID with its object type, via
|
||||
/// `--batch-check='%(objectname) %(objecttype)'`. The pin path's fail-closed
|
||||
/// filter needs to tell blobs (content, withholdable) from commits/trees
|
||||
/// (structural, never withheld) without typing the candidate list itself.
|
||||
pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname) %(objecttype)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.split_whitespace();
|
||||
let oid = it.next()?;
|
||||
let ty = it.next()?;
|
||||
Some((oid.to_string(), ty.to_string()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every blob OID in the repo, including unreachable/dangling ones. The
|
||||
/// fail-closed pin filter drops any candidate blob absent from the reachable,
|
||||
/// visibility-allowed set; a dangling private blob is in this set but not the
|
||||
/// allowed set, so it never replicates (#99).
|
||||
pub fn all_blob_oids(repo_path: &Path) -> Result<HashSet<String>> {
|
||||
Ok(list_all_objects_with_type(repo_path)?
|
||||
.into_iter()
|
||||
.filter(|(_, ty)| ty == "blob")
|
||||
.map(|(oid, _)| oid)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The pin candidate OIDs for a push plus whether they came from a full-repo
|
||||
/// scan. `full_scan` is true when the delta could not be used and the whole
|
||||
/// object DB (including dangling objects) was enumerated — the caller must then
|
||||
/// apply the fail-closed blob filter, because the reachable-only withheld set
|
||||
/// cannot classify dangling blobs (#99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinCandidateSet {
|
||||
pub candidates: Vec<String>,
|
||||
pub full_scan: bool,
|
||||
}
|
||||
|
||||
/// Resolve the pin candidate OID set for a push, off the async worker.
|
||||
///
|
||||
/// Runs [`resolve_push_delta`] in `spawn_blocking` (git subprocess) and applies
|
||||
/// the full-scan fallback for [`PinCandidates::FullScanRequired`]. Owns the
|
||||
/// dispatch so the push call site stays thin and this wiring is unit-testable
|
||||
/// without the HTTP layer.
|
||||
///
|
||||
/// Every degraded path is **logged**, not silent: a full-scan fallback, a
|
||||
/// failed full scan, and a panicked blocking task each emit a warning. On a
|
||||
/// failed full scan or a task panic the candidate set is empty (pin nothing
|
||||
/// this push); that is a durability gap the reconciliation sweep backstops, and
|
||||
/// it can never leak because the withheld/fail-closed filter still runs on
|
||||
/// whatever set is returned. `full_scan` rides on the returned set so the caller
|
||||
/// knows when the dangling-inclusive filter is required.
|
||||
pub async fn resolve_candidates_for_push(
|
||||
repo_path: PathBuf,
|
||||
new_tips: Vec<String>,
|
||||
old_tips: Vec<String>,
|
||||
) -> PinCandidateSet {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let new_refs: Vec<&str> = new_tips.iter().map(String::as_str).collect();
|
||||
let old_refs: Vec<&str> = old_tips.iter().map(String::as_str).collect();
|
||||
match resolve_push_delta(&repo_path, &new_refs, &old_refs) {
|
||||
PinCandidates::Delta(objs) => {
|
||||
tracing::info!(delta = objs.len(), repo = %repo_path.display(), "pin candidate set from push delta");
|
||||
PinCandidateSet { candidates: objs, full_scan: false }
|
||||
}
|
||||
PinCandidates::FullScanRequired => {
|
||||
tracing::warn!(repo = %repo_path.display(), "pin delta unavailable (non-commit tip, git error, or force-full-scan) — full-scan fallback");
|
||||
match list_all_objects(&repo_path) {
|
||||
Ok(objs) => PinCandidateSet { candidates: objs, full_scan: true },
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_path.display(), err = %e, "full-scan fallback failed; pinning nothing this push (reconciliation sweep backstops)");
|
||||
PinCandidateSet { candidates: Vec::new(), full_scan: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(err = %e, "pin candidate computation task panicked; pinning nothing this push (reconciliation sweep backstops)");
|
||||
PinCandidateSet { candidates: Vec::new(), full_scan: false }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Minimal git helper for building test repos.
|
||||
struct Repo {
|
||||
_td: TempDir,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
fn new() -> Self {
|
||||
let td = TempDir::new().unwrap();
|
||||
let path = td.path().to_path_buf();
|
||||
let r = Repo { _td: td, path };
|
||||
r.git(&["init", "-q", "-b", "main"]);
|
||||
r.git(&["config", "user.email", "t@t"]);
|
||||
r.git(&["config", "user.name", "t"]);
|
||||
r
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&self.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn commit_file(&self, name: &str, body: &str) -> String {
|
||||
std::fs::write(self.path.join(name), body).unwrap();
|
||||
self.git(&["add", name]);
|
||||
self.git(&["commit", "-qm", &format!("add {name}")]);
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn rev(&self, r: &str) -> String {
|
||||
self.git(&["rev-parse", r])
|
||||
}
|
||||
}
|
||||
|
||||
const ZERO: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
fn delta(c: PinCandidates) -> Vec<String> {
|
||||
match c {
|
||||
PinCandidates::Delta(v) => v,
|
||||
PinCandidates::FullScanRequired => panic!("expected Delta, got FullScanRequired"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_new_commit_delta_excludes_preexisting_objects() {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c2], &[&c1]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
// The new blob b.txt and commit c2 are in the delta; the old blob a.txt
|
||||
// and commit c1 are not.
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
let old_blob = repo.rev(&format!("{c1}:a.txt"));
|
||||
assert!(got.contains(&c2), "new commit in delta");
|
||||
assert!(got.contains(&new_blob), "new blob in delta");
|
||||
assert!(!got.contains(&c1), "old commit excluded");
|
||||
assert!(!got.contains(&old_blob), "old blob excluded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_ref_is_superset_of_new_objects() {
|
||||
// A created ref (old tip all-zeros, filtered out by caller) with no old
|
||||
// tips walks everything reachable from the new tip — a superset of the
|
||||
// genuinely new objects, never fewer.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&c1));
|
||||
assert!(got.contains(&repo.rev("HEAD:a.txt")));
|
||||
assert!(got.contains(&repo.rev("HEAD^{tree}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_push_delta_is_objects_unique_to_new_tip() {
|
||||
let repo = Repo::new();
|
||||
let base = repo.commit_file("a.txt", "one\n");
|
||||
let old_tip = repo.commit_file("b.txt", "two\n");
|
||||
// Rewrite history: reset to base, commit a different file.
|
||||
repo.git(&["reset", "-q", "--hard", &base]);
|
||||
let new_tip = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&new_tip], &[&old_tip]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&new_tip), "new tip in delta");
|
||||
assert!(got.contains(&repo.rev(&format!("{new_tip}:c.txt"))));
|
||||
// No error; force-push computes new-minus-old cleanly.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_ref_only_yields_empty_delta_without_git() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
// All updates were deletions => new_tips empty after the caller strips zeros.
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[ZERO]),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&blob], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a blob tip must force full scan (rev-list would exit 0 and walk it)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tree], &[]),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_tag_of_tree_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
repo.git(&["tag", "-a", "treetag", "-m", "x", &tree]);
|
||||
let tag = repo.rev("treetag");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tag], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"annotated tag peeling to a tree must force full scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_of_tag_of_noncommit_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
repo.git(&["tag", "-a", "t1", "-m", "x", &blob]);
|
||||
let t1 = repo.rev("t1");
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&t2], &[]),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_tag_of_commit_proceeds_as_delta() {
|
||||
// DISCRIMINATING positive-path regression: an annotated tag whose peeled
|
||||
// target is a commit must return Delta, not FullScanRequired. A
|
||||
// FullScanRequired here means the guard used bare `cat-file -t` (returns
|
||||
// `tag`) instead of the `^{}` peel.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
repo.git(&["tag", "-a", "rel", "-m", "release", &c1]);
|
||||
let tag = repo.rev("rel");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
got.contains(&c1),
|
||||
"peeled commit's objects are in the delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_of_tag_of_commit_proceeds_as_delta() {
|
||||
// Deep-peel positive path: tag -> tag -> commit must recurse to `commit`
|
||||
// via `^{}` and return Delta. Complements tag_of_tag_of_noncommit (which
|
||||
// forces full scan) — guards against a regression in peel depth that
|
||||
// would silently downgrade a legitimate nested-tag push to full scan.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
repo.git(&["tag", "-a", "t1", "-m", "x", &c1]);
|
||||
let t1 = repo.rev("t1");
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
got.contains(&c1),
|
||||
"peeled commit's objects are in the delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_for_push_returns_delta() {
|
||||
// The extracted wiring returns the per-push delta on the happy path.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let set =
|
||||
resolve_candidates_for_push(repo.path.clone(), vec![c2.clone()], vec![c1.clone()])
|
||||
.await;
|
||||
assert!(!set.full_scan, "happy-path delta is not a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
assert!(
|
||||
got.contains(&c2) && got.contains(&new_blob),
|
||||
"new objects pinned"
|
||||
);
|
||||
assert!(!got.contains(&c1), "old commit excluded from delta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_for_push_falls_back_to_full_scan_on_noncommit_tip() {
|
||||
// A non-commit tip forces FullScanRequired, and the wiring resolves that
|
||||
// to the whole-repo list (a superset), never an empty set.
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
let all: HashSet<String> = list_all_objects(&repo.path).unwrap().into_iter().collect();
|
||||
let set = resolve_candidates_for_push(repo.path.clone(), vec![blob], vec![]).await;
|
||||
assert!(set.full_scan, "non-commit tip is signalled as a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
assert_eq!(got, all, "non-commit tip falls back to full repo scan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_oid_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let bogus = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[bogus], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a missing/corrupt tip OID must force full scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_noncommit_old_tip_is_safe() {
|
||||
// A present-but-non-commit old_sha (a --not arg) must not under-pin:
|
||||
// either rev-list errors (FullScanRequired) or it over-lists (Delta).
|
||||
// Either way the new tip's objects are covered; never fewer.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let old_tree = repo.rev(&format!("{c1}^{{tree}}"));
|
||||
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree]);
|
||||
match result {
|
||||
PinCandidates::FullScanRequired => {} // safe: caller full-scans
|
||||
PinCandidates::Delta(objs) => {
|
||||
// Safe direction: must still contain the new commit (over-list ok).
|
||||
assert!(objs.contains(&c2), "new tip objects must be covered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_ref_push_unions_ranges() {
|
||||
let repo = Repo::new();
|
||||
let base = repo.commit_file("a.txt", "one\n");
|
||||
// branch1 advances
|
||||
let b1 = repo.commit_file("b.txt", "two\n");
|
||||
// branch2 from base advances independently
|
||||
repo.git(&["checkout", "-q", "-b", "branch2", &base]);
|
||||
let b2 = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> =
|
||||
delta(resolve_push_delta(&repo.path, &[&b1, &b2], &[&base, &base]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&b1), "branch1 new commit");
|
||||
assert!(got.contains(&b2), "branch2 new commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_repo_no_tips_yields_empty_delta() {
|
||||
let repo = Repo::new();
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[]),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_full_scan_flag_overrides() {
|
||||
// Test the kill-switch via the pure inner fn so we never touch
|
||||
// process-global env (which would race with parallel tests, and is
|
||||
// unsafe in Rust 2024). The public resolve_push_delta wires the env
|
||||
// read into this same flag.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
assert_eq!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], true),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
// And with the flag off, the same push yields a Delta.
|
||||
assert!(matches!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], false),
|
||||
PinCandidates::Delta(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_all_objects_returns_full_repo() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
repo.commit_file("b.txt", "two\n");
|
||||
let all = list_all_objects(&repo.path).unwrap();
|
||||
// 2 commits + 2 trees + 2 blobs = 6 objects.
|
||||
assert_eq!(all.len(), 6, "got: {all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_blob_oids_includes_dangling_and_excludes_nonblobs() {
|
||||
// The all-blob universe must include a dangling blob (the #99 hazard) and
|
||||
// exclude commits/trees, so the fail-closed filter can tell a blob from a
|
||||
// structural object without typing the candidate list.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let reachable_blob = repo.rev("HEAD:a.txt");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
// Write a blob referenced by no tree/commit — dangling.
|
||||
std::fs::write(repo.path.join("orphan.bin"), b"dangling\n").unwrap();
|
||||
let dangling = repo.git(&["hash-object", "-w", "orphan.bin"]);
|
||||
|
||||
let blobs = all_blob_oids(&repo.path).unwrap();
|
||||
assert!(blobs.contains(&reachable_blob), "reachable blob present");
|
||||
assert!(
|
||||
blobs.contains(&dangling),
|
||||
"dangling blob present in the all-blob universe"
|
||||
);
|
||||
assert!(!blobs.contains(&c1), "commit is not a blob");
|
||||
assert!(!blobs.contains(&tree), "tree is not a blob");
|
||||
|
||||
// The typed lister tags each object; spot-check the dangling blob's type.
|
||||
let typed = list_all_objects_with_type(&repo.path).unwrap();
|
||||
assert!(
|
||||
typed
|
||||
.iter()
|
||||
.any(|(oid, ty)| oid == &dangling && ty == "blob"),
|
||||
"dangling object is typed as a blob"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,220 +75,29 @@ pub async fn receive_pack(
|
||||
.body(Body::from(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`.
|
||||
/// If the request future is dropped (client disconnect) or returns early, dropping
|
||||
/// the tokio `Child` does not signal `git`; it lingers until EPIPE, and its
|
||||
/// `pack-objects` child can reparent to PID 1 and never be reaped — a zombie that
|
||||
/// accumulates until `fork()` fails with EAGAIN (#53). Spawning the child in its
|
||||
/// own process group and signalling that group here tears the whole tree down at
|
||||
/// the source. SIGTERM (not SIGKILL) lets `git` run its cleanup — notably removing
|
||||
/// `.git/*.lock` files mid-`receive-pack` — before it exits, so an aborted push
|
||||
/// can't leave a stale lock that blocks the next one. The guard is disarmed once
|
||||
/// `wait_with_output()` returns, so a request that completed cleanly never signals.
|
||||
#[cfg(unix)]
|
||||
struct KillGroupOnDrop {
|
||||
pgid: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl KillGroupOnDrop {
|
||||
fn disarm(&mut self) {
|
||||
self.pgid = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for KillGroupOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pgid) = self.pgid {
|
||||
// SAFETY: kill(2) takes only integer arguments and borrows no Rust
|
||||
// memory. Signalling a stale group just returns ESRCH, which we ignore.
|
||||
unsafe {
|
||||
libc::kill(-pgid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
command
|
||||
async fn run_git_service(service: &str, repo_path: &Path, input: Bytes) -> Result<Vec<u8>> {
|
||||
let mut child = Command::new("git")
|
||||
.arg(service_to_command(service))
|
||||
.arg("--stateless-rpc")
|
||||
.arg(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// Run git in its own process group so the whole tree (git + pack-objects)
|
||||
// can be signalled together on disconnect rather than orphaning a grandchild.
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
// Write request body to git's stdin
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(&input).await?;
|
||||
}
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
let output = child.wait_with_output().await?;
|
||||
|
||||
// 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?))
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("{service} failed: {stderr}");
|
||||
}
|
||||
|
||||
write_result.context("failed to write to git stdin")?;
|
||||
|
||||
Ok(out)
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn service_to_command(service: &str) -> &str {
|
||||
@@ -354,25 +152,13 @@ pub fn build_filtered_pack(repo_path: &Path, withheld: &HashSet<String>) -> Resu
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
// Feed the object ids on stdin, but always reap the child afterward even if
|
||||
// the write fails or stdin is missing, so an error can't drop the Child
|
||||
// unwaited and leak a zombie (#53).
|
||||
let write_result: std::io::Result<()> = {
|
||||
{
|
||||
use std::io::Write as _;
|
||||
match child.stdin.take() {
|
||||
Some(mut stdin) => {
|
||||
let mut data = keep.join("\n").into_bytes();
|
||||
data.push(b'\n');
|
||||
stdin.write_all(&data)
|
||||
}
|
||||
None => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"git pack-objects stdin unavailable",
|
||||
)),
|
||||
}
|
||||
};
|
||||
let mut stdin = child.stdin.take().expect("stdin");
|
||||
stdin.write_all(keep.join("\n").as_bytes())?;
|
||||
stdin.write_all(b"\n")?;
|
||||
}
|
||||
let out = child.wait_with_output()?;
|
||||
write_result.context("failed to write object ids to git pack-objects stdin")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git pack-objects failed: {}",
|
||||
@@ -902,615 +688,4 @@ mod tests {
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
// ── #53 regression: served-git process-group teardown ──────────────────
|
||||
//
|
||||
// run_git_service runs git in its own process group and SIGTERMs that group
|
||||
// when the request future is dropped (client disconnect) or returns early, so
|
||||
// git AND its pack-objects child die together instead of orphaning a zombie.
|
||||
// These exercise the KillGroupOnDrop guard that wires that up.
|
||||
|
||||
#[cfg(unix)]
|
||||
fn alive(pid: i32) -> bool {
|
||||
// kill(pid, 0) probes existence: returns 0 while the pid exists, -1 once
|
||||
// it's gone (ESRCH). Same-uid here, so EPERM never applies.
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_group_guard_terminates_child_on_drop() {
|
||||
let mut child = tokio::process::Command::new("sleep")
|
||||
.arg("300")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pgid = child.id().map(|id| id as i32);
|
||||
|
||||
{
|
||||
let _guard = KillGroupOnDrop { pgid };
|
||||
} // guard drops here -> SIGTERM to the group
|
||||
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
let status = child.wait().await.unwrap();
|
||||
assert_eq!(
|
||||
status.signal(),
|
||||
Some(libc::SIGTERM),
|
||||
"child must be terminated by SIGTERM via its process group"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_group_guard_reaps_grandchild_on_drop() {
|
||||
// The #53 scenario: git forks pack-objects. A group kill must reach the
|
||||
// grandchild, not just the direct child. sh (the group leader) backgrounds
|
||||
// a sleep (standing in for pack-objects) and prints its pid.
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("sleep 300 & echo \"$!\"; wait")
|
||||
.stdout(Stdio::piped())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pgid = child.id().map(|id| id as i32);
|
||||
|
||||
// Read the backgrounded grandchild's pid from the first stdout line.
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
let n = stdout.read(&mut byte).await.unwrap();
|
||||
if n == 0 || byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
buf.push(byte[0]);
|
||||
}
|
||||
let grandchild: i32 = String::from_utf8(buf).unwrap().trim().parse().unwrap();
|
||||
assert!(alive(grandchild), "grandchild should be running");
|
||||
|
||||
{
|
||||
let _guard = KillGroupOnDrop { pgid };
|
||||
} // group SIGTERM reaches sh AND the sleep grandchild
|
||||
|
||||
let _ = child.wait().await; // reap sh
|
||||
|
||||
// The grandchild reparents to init and is reaped; poll until it's gone.
|
||||
let mut gone = false;
|
||||
for _ in 0..200 {
|
||||
if !alive(grandchild) {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(
|
||||
gone,
|
||||
"grandchild must be terminated by the group signal (#53)"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_group_guard_disarmed_does_not_kill() {
|
||||
// A request that completed cleanly disarms the guard; dropping it must not
|
||||
// signal anything.
|
||||
let mut child = tokio::process::Command::new("sleep")
|
||||
.arg("300")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let mut guard = KillGroupOnDrop {
|
||||
pgid: child.id().map(|id| id as i32),
|
||||
};
|
||||
guard.disarm();
|
||||
} // disarmed -> no kill
|
||||
|
||||
assert!(
|
||||
child.try_wait().unwrap().is_none(),
|
||||
"disarmed guard must not kill the child"
|
||||
);
|
||||
|
||||
// Clean up the still-running child.
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
//! Repos are stored as `repos/v1/{owner_slug}/{repo_name}.tar.zst` — a
|
||||
//! zstd-compressed tar archive of the bare repo directory.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
@@ -112,7 +110,8 @@ impl TigrisClient {
|
||||
.collect()
|
||||
.await
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
.into_bytes()
|
||||
.to_vec();
|
||||
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
@@ -157,23 +156,6 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
Ok(compressed)
|
||||
}
|
||||
|
||||
/// Per-repo-path lock serializing the publish (swap-into-place) step of
|
||||
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
|
||||
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
|
||||
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
|
||||
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
|
||||
// entry accrues per distinct repo path for the process lifetime. Bounded by
|
||||
// the number of repos a node hosts, so it's negligible for normal use, but
|
||||
// high-volume/churning deployments may want LRU or weak-ref eviction here.
|
||||
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
|
||||
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut map = locks.lock().expect("publish lock map poisoned");
|
||||
map.entry(local_path.to_path_buf())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
@@ -189,12 +171,10 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
// Unique per-extraction temp dir: a fixed name would let two concurrent
|
||||
// extractions of the same repo share one dir and clobber each other's
|
||||
// in-progress unpack. A fresh UUID also means it can't collide with a
|
||||
// leftover dir from a previously-interrupted run.
|
||||
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract.{}", uuid::Uuid::new_v4()));
|
||||
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract"));
|
||||
|
||||
// Clear any leftover temp dir from a previously-interrupted extraction.
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
|
||||
|
||||
// Unpack into the temp dir; on any failure, clean up and bail without
|
||||
@@ -212,11 +192,7 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
|
||||
// Swap the freshly-extracted repo into place. rename within the same parent
|
||||
// is effectively atomic, but most platforms refuse to rename onto a
|
||||
// non-empty dir, so remove the old copy first. Serialize this per repo path:
|
||||
// concurrent extractions unpack into isolated temp dirs, but their swaps
|
||||
// must not interleave or they race to a nondeterministic overwrite/failure.
|
||||
let lock = publish_lock(local_path);
|
||||
let _publish = lock.lock().expect("publish lock poisoned");
|
||||
// non-empty dir, so remove the old copy first.
|
||||
if local_path.exists() {
|
||||
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,21 +3,11 @@ use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::{AgentTask, Db};
|
||||
use crate::state::TaskEventBroadcast;
|
||||
|
||||
use super::types::{AgentTaskType, CreateTaskInput, FinishTaskInput};
|
||||
|
||||
/// The verified signer DID for this request, or an auth error (N2). The DID is
|
||||
/// attached request-scoped by the `/graphql` `optional_signature` layer; its
|
||||
/// absence means the request was unsigned, so every mutation rejects.
|
||||
fn require_signer<'a>(ctx: &'a Context<'_>) -> Result<&'a str> {
|
||||
ctx.data::<AuthenticatedDid>()
|
||||
.map(|d| d.0.as_str())
|
||||
.map_err(|_| async_graphql::Error::new("authentication required"))
|
||||
}
|
||||
|
||||
pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
@@ -28,13 +18,6 @@ impl MutationRoot {
|
||||
delegator_did: String,
|
||||
input: CreateTaskInput,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &delegator_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"delegator_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let delegator_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
@@ -64,13 +47,6 @@ impl MutationRoot {
|
||||
id: String,
|
||||
assignee_did: String,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &assignee_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"assignee_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let assignee_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
let task = db
|
||||
@@ -94,27 +70,8 @@ impl MutationRoot {
|
||||
by_did: String,
|
||||
input: FinishTaskInput,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &by_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"by_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: binding by_did to the signer is necessary but not
|
||||
// sufficient — only the task's assignee may finish it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can complete it",
|
||||
));
|
||||
}
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref())
|
||||
.await
|
||||
@@ -136,26 +93,8 @@ impl MutationRoot {
|
||||
by_did: String,
|
||||
input: FinishTaskInput,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &by_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"by_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: only the task's assignee may fail it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can fail it",
|
||||
));
|
||||
}
|
||||
let reason = input.reason.unwrap_or_default();
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
@@ -171,123 +110,3 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use async_graphql::{Request, Response};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn errors(resp: &Response) -> String {
|
||||
resp.errors
|
||||
.iter()
|
||||
.map(|e| e.message.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// N2: GraphQL mutations require a verified signer and bind the acting DID to
|
||||
/// it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// rejected; the matching signer passes the auth gate.
|
||||
#[sqlx::test]
|
||||
async fn mutation_requires_and_binds_signer(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let assignee = "did:key:zASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let q = format!(
|
||||
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{assignee}") {{ id }} }}"#
|
||||
);
|
||||
|
||||
// 1. Unsigned → rejected before any DB work.
|
||||
let resp = schema.execute(Request::new(&q)).await;
|
||||
assert!(
|
||||
errors(&resp).contains("authentication required"),
|
||||
"unsigned mutation must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// 2. Signed as someone other than the claimed assignee → rejected.
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(
|
||||
"did:key:zOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
|
||||
)))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("authenticated signer"),
|
||||
"DID mismatch must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// 3. Signed as the claimed assignee → passes the auth gate (any remaining
|
||||
// error is the missing task, not an auth error).
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
|
||||
/// assignee, not merely signing as the by_did you pass. A signer who is not
|
||||
/// the assignee is rejected even though the by_did binding passes; the
|
||||
/// assignee completes.
|
||||
#[sqlx::test]
|
||||
async fn complete_task_requires_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let assignee = "did:key:zGQLASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zGQLSTRANGERBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-g".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed task");
|
||||
state
|
||||
.db
|
||||
.claim_task("task-g", assignee)
|
||||
.await
|
||||
.expect("claim");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ completeTask(id: "task-g", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves and passes byDid=self (so the signer
|
||||
// binding passes), but is not the assignee → rejected by authorization.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("assignee"),
|
||||
"a non-assignee signer must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// The assignee completes with no error.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(assignee)).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"the assignee should complete the task: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,29 +12,11 @@ impl QueryRoot {
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.list_all_repos()
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
|
||||
// this surface does not enumerate private repos (#97). The caller DID is
|
||||
// threaded onto the context by optional_signature; absent = anonymous.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = db
|
||||
.list_visibility_rules_for_repos(&ids)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(repos
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
|
||||
})
|
||||
.map(|r| RepoType {
|
||||
name: r.name,
|
||||
owner_did: r.owner_did,
|
||||
@@ -49,49 +31,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 +48,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 +76,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,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
//! iCaptcha proof-of-intelligence gate.
|
||||
//!
|
||||
//! Spam-prone endpoints (repo creation, agent registration) can require the
|
||||
//! caller to present an iCaptcha proof: a small Ed25519-signed token minted by
|
||||
//! <https://icaptcha.gitlawb.com> after the caller solves an escalating
|
||||
//! challenge. We verify the proof OFFLINE (no per-request call to iCaptcha)
|
||||
//! using its published public key, and bind each proof to the authenticated
|
||||
//! agent DID so a proof cannot be shared between identities.
|
||||
//!
|
||||
//! Behaviour is controlled by `ICAPTCHA_MODE`:
|
||||
//! * `off` (default) — gate is inert, nothing is checked.
|
||||
//! * `shadow` — verify and log would-be rejections, but always allow.
|
||||
//! * `enforce` — reject requests without a valid, sufficiently-strong proof.
|
||||
//!
|
||||
//! Config (env):
|
||||
//! ICAPTCHA_MODE off | shadow | enforce (default off)
|
||||
//! ICAPTCHA_URL base URL (default https://icaptcha.gitlawb.com)
|
||||
//! ICAPTCHA_PUBKEY base64url Ed25519 public key (optional; else fetched from /v1/pubkey)
|
||||
//! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3)
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
const PROOF_HEADER: &str = "x-icaptcha-proof";
|
||||
|
||||
/// How long a mirror-admission `jti` stays in the replay ledger. The direct
|
||||
/// request path spends a proof against its own `exp` (it can't be presented once
|
||||
/// expired). The propagation path deliberately accepts already-expired proofs,
|
||||
/// so spending against `exp` would store an already-past expiry that the next
|
||||
/// sweep deletes — letting the same token admit another mirror minutes later.
|
||||
/// We instead retain the mirror replay record for a long, fixed window so the
|
||||
/// per-node single-use property is durable.
|
||||
const MIRROR_REPLAY_RETENTION_SECS: i64 = 365 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Mode {
|
||||
Off,
|
||||
Shadow,
|
||||
Enforce,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Off => "off",
|
||||
Mode::Shadow => "shadow",
|
||||
Mode::Enforce => "enforce",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `ICAPTCHA_MODE`. Returns `None` for unrecognized values so the caller
|
||||
/// can surface the typo instead of silently disabling the gate.
|
||||
fn parse_mode(s: &str) -> Option<Mode> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "off" => Some(Mode::Off),
|
||||
"shadow" => Some(Mode::Shadow),
|
||||
"enforce" => Some(Mode::Enforce),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `ICAPTCHA_REQUIRED_LEVEL`. Defaults to 3; warns (rather than silently
|
||||
/// lowering the threshold) when a non-empty value fails to parse.
|
||||
fn parse_required_level() -> u32 {
|
||||
const DEFAULT: u32 = 3;
|
||||
match std::env::var("ICAPTCHA_REQUIRED_LEVEL") {
|
||||
Ok(v) if !v.trim().is_empty() => v.trim().parse().unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
value = %v,
|
||||
default = DEFAULT,
|
||||
"invalid ICAPTCHA_REQUIRED_LEVEL; using default"
|
||||
);
|
||||
DEFAULT
|
||||
}),
|
||||
_ => DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
struct Verifier {
|
||||
mode: Mode,
|
||||
url: String,
|
||||
required_level: u32,
|
||||
key: Option<VerifyingKey>,
|
||||
}
|
||||
|
||||
static VERIFIER: OnceLock<Verifier> = OnceLock::new();
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ProofClaims {
|
||||
sub: String,
|
||||
level: u32,
|
||||
exp: i64,
|
||||
/// Unique proof id, consumed once so a proof cannot be replayed.
|
||||
jti: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Jwk {
|
||||
x: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Jwks {
|
||||
keys: Vec<Jwk>,
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn decode_key(b64url: &str) -> Option<VerifyingKey> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(b64url.trim()).ok()?;
|
||||
let arr: [u8; 32] = bytes.try_into().ok()?;
|
||||
VerifyingKey::from_bytes(&arr).ok()
|
||||
}
|
||||
|
||||
async fn fetch_key(url: &str) -> Option<VerifyingKey> {
|
||||
let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/'));
|
||||
// Bounded request: a hung /v1/pubkey must never block node startup. On
|
||||
// timeout/error we return None and the gate stays inert (fail safe).
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.ok()?;
|
||||
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
|
||||
decode_key(&jwks.keys.first()?.x)
|
||||
}
|
||||
|
||||
/// Initialize the gate from the environment. Call once at startup. Never panics;
|
||||
/// if the gate is active but no key can be loaded, it stays inert and warns.
|
||||
pub async fn init() {
|
||||
let raw_mode = std::env::var("ICAPTCHA_MODE").unwrap_or_default();
|
||||
let mode = parse_mode(&raw_mode).unwrap_or_else(|| {
|
||||
tracing::warn!(value = %raw_mode, "invalid ICAPTCHA_MODE; disabling iCaptcha gate");
|
||||
Mode::Off
|
||||
});
|
||||
let url = std::env::var("ICAPTCHA_URL")
|
||||
.unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string());
|
||||
let required_level = parse_required_level();
|
||||
|
||||
let key = if mode == Mode::Off {
|
||||
None
|
||||
} else {
|
||||
match std::env::var("ICAPTCHA_PUBKEY") {
|
||||
Ok(b64) if !b64.is_empty() => decode_key(&b64),
|
||||
_ => fetch_key(&url).await,
|
||||
}
|
||||
};
|
||||
|
||||
if mode != Mode::Off {
|
||||
if key.is_some() {
|
||||
tracing::info!(mode = mode.as_str(), required_level, "iCaptcha gate active");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
mode = mode.as_str(),
|
||||
"iCaptcha gate enabled but no public key could be loaded; staying inert"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = VERIFIER.set(Verifier {
|
||||
mode,
|
||||
url,
|
||||
required_level,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
/// Outcome of the synchronous, IO-free decision step.
|
||||
#[derive(Debug)]
|
||||
enum Decision {
|
||||
/// Allow the request (off, shadow, inert/no-key, or verified non-enforcing).
|
||||
Allow,
|
||||
/// Enforce mode and verification failed; reject with this reason.
|
||||
Reject(String),
|
||||
/// Enforce mode and the proof verified; the caller must consume its `jti`
|
||||
/// (and reject replays) before allowing, then may persist it.
|
||||
Consume(VerifiedProof),
|
||||
}
|
||||
|
||||
/// A verified proof: the raw token plus the claims we act on. Carried by a
|
||||
/// [`ProofGuard`] so that, once consumed, the gated handler can persist it with
|
||||
/// the created repo — letting the proof travel with the repo when it propagates
|
||||
/// to peers (see [`admit_mirror`]).
|
||||
#[derive(Debug)]
|
||||
pub struct VerifiedProof {
|
||||
token: String,
|
||||
sub: String,
|
||||
level: u32,
|
||||
jti: String,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
impl VerifiedProof {
|
||||
/// Persist this proof against a freshly-created repo so a mirroring peer can
|
||||
/// re-verify it. Best-effort callers may ignore failures; the gate's
|
||||
/// security does not depend on it (propagation just falls back to quarantine).
|
||||
pub async fn record_for_repo(&self, db: &crate::db::Db, repo_id: &str) -> Result<(), AppError> {
|
||||
db.record_repo_proof(
|
||||
repo_id,
|
||||
&self.token,
|
||||
&self.sub,
|
||||
self.level as i32,
|
||||
&self.jti,
|
||||
self.exp,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A verified proof awaiting consumption. Verification (which rejects invalid or
|
||||
/// missing proofs) is separated from consumption (which spends the single-use
|
||||
/// `jti`) so a request rejected by later validation never burns a valid proof.
|
||||
/// The caller must `consume()` this guard immediately before the gated write.
|
||||
/// For off/shadow/inert there is nothing to consume.
|
||||
#[must_use = "a verified iCaptcha proof must be consumed before the gated action"]
|
||||
pub struct ProofGuard(Option<VerifiedProof>);
|
||||
|
||||
impl ProofGuard {
|
||||
/// Spend the proof's `jti` (single-use) and return the verified proof so the
|
||||
/// caller can persist it. A replay is rejected. Returns `None` when there is
|
||||
/// nothing to consume (off/shadow/inert).
|
||||
pub async fn consume(self, db: &crate::db::Db) -> Result<Option<VerifiedProof>, AppError> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
Ok(Some(p))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the proof in `headers` belongs to `did`. Rejects missing/invalid
|
||||
/// proofs early (enforce mode); off/shadow never reject. Returns a [`ProofGuard`]
|
||||
/// the caller must `consume()` right before the gated write. Off/shadow/inert
|
||||
/// yield a no-op guard that consumes nothing.
|
||||
pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppError> {
|
||||
let v = match VERIFIER.get() {
|
||||
Some(v) => v,
|
||||
None => return Ok(ProofGuard(None)), // not initialized -> inert
|
||||
};
|
||||
match decide(v, headers, did, now_secs()) {
|
||||
Decision::Allow => Ok(ProofGuard(None)),
|
||||
Decision::Reject(reason) => Err(reject_error(v, &reason)),
|
||||
Decision::Consume(proof) => Ok(ProofGuard(Some(proof))),
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
|
||||
/// is fully unit-testable. The caller performs jti consumption for `Consume`.
|
||||
fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Decision {
|
||||
if v.mode == Mode::Off {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
// Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable
|
||||
// at startup), stay inert rather than rejecting every request. The operator
|
||||
// already saw a startup warning. An iCaptcha hiccup must never break repo
|
||||
// creation or registration.
|
||||
if v.key.is_none() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
let token = headers.get(PROOF_HEADER).and_then(|h| h.to_str().ok());
|
||||
let result = match token {
|
||||
Some(t) => verify_token(v, t, did, now, true).map(|claims| (t, claims)),
|
||||
None => Err("missing proof header".to_string()),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((token, claims)) => match v.mode {
|
||||
Mode::Enforce => Decision::Consume(VerifiedProof {
|
||||
token: token.to_string(),
|
||||
sub: claims.sub,
|
||||
level: claims.level,
|
||||
jti: claims.jti,
|
||||
exp: claims.exp,
|
||||
}),
|
||||
// Shadow/Off: never reject, and do not consume (observational only).
|
||||
_ => Decision::Allow,
|
||||
},
|
||||
Err(reason) => match v.mode {
|
||||
Mode::Shadow => {
|
||||
tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject");
|
||||
Decision::Allow
|
||||
}
|
||||
Mode::Enforce => Decision::Reject(reason),
|
||||
Mode::Off => Decision::Allow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Header-extracting wrapper over [`verify_token`] (enforces expiry). The
|
||||
/// production path inlines this in [`decide`]; retained as the unit-test entry
|
||||
/// point that also exercises header extraction.
|
||||
#[cfg(test)]
|
||||
fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<ProofClaims, String> {
|
||||
let proof = headers
|
||||
.get(PROOF_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or("missing proof header")?;
|
||||
verify_token(v, proof, did, now, true)
|
||||
}
|
||||
|
||||
/// Core verification of a raw proof token, separated for testability and reuse.
|
||||
/// Checks the Ed25519 signature, the required level, and the DID binding. `exp`
|
||||
/// is only enforced when `check_exp` is true: the direct request path enforces
|
||||
/// freshness, but the propagation path ([`admit_mirror`]) does not — a proof has
|
||||
/// usually expired by the time a repo mirrors, and the origin enforced freshness
|
||||
/// at creation. `now` is unix seconds.
|
||||
fn verify_token(
|
||||
v: &Verifier,
|
||||
token: &str,
|
||||
did: &str,
|
||||
now: i64,
|
||||
check_exp: bool,
|
||||
) -> Result<ProofClaims, String> {
|
||||
let key = v.key.as_ref().ok_or("verifier has no public key")?;
|
||||
|
||||
let (payload, sig_b64) = token.split_once('.').ok_or("malformed proof")?;
|
||||
let sig_bytes = URL_SAFE_NO_PAD
|
||||
.decode(sig_b64)
|
||||
.map_err(|_| "bad signature encoding")?;
|
||||
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad signature length")?;
|
||||
key.verify_strict(payload.as_bytes(), &sig)
|
||||
.map_err(|_| "signature verification failed")?;
|
||||
|
||||
let claims_bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| "bad payload encoding")?;
|
||||
let claims: ProofClaims = serde_json::from_slice(&claims_bytes).map_err(|_| "bad claims")?;
|
||||
|
||||
if check_exp && claims.exp < now {
|
||||
return Err("proof expired".to_string());
|
||||
}
|
||||
if claims.level < v.required_level {
|
||||
return Err(format!(
|
||||
"level {} below required {}",
|
||||
claims.level, v.required_level
|
||||
));
|
||||
}
|
||||
if !crate::api::did_matches(did, &claims.sub) {
|
||||
return Err("proof subject does not match authenticated DID".to_string());
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Outcome of admitting a repo mirrored from a peer.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum MirrorAdmission {
|
||||
/// Admit the mirror normally (off/shadow/inert, or a valid proof in enforce).
|
||||
Admit,
|
||||
/// Enforce mode and the mirror's proof is missing/invalid/replayed — mirror
|
||||
/// it but quarantine until an operator releases it. Carries the reason.
|
||||
Quarantine(String),
|
||||
}
|
||||
|
||||
/// Decide whether to admit a repo being mirrored from a peer, given the proof
|
||||
/// token the origin served (`None` if it had none or the fetch failed) and the
|
||||
/// mirrored repo's owner DID.
|
||||
///
|
||||
/// Off/shadow/inert always admit (shadow logs the would-be quarantine and writes
|
||||
/// nothing). In enforce mode the proof is re-verified offline against the local
|
||||
/// public key — signature, level, and owner-DID binding, but NOT expiry — and
|
||||
/// its `jti` is consumed in the local ledger, so one solved challenge admits at
|
||||
/// most one mirror per node (a malicious origin cannot reuse one proof to bless
|
||||
/// many spam repos here).
|
||||
///
|
||||
/// LIMITATION (proof is not repo-bound): an iCaptcha proof commits only to the
|
||||
/// solver's DID (`sub`), never to a repo — the proof is minted before the repo
|
||||
/// exists. So the guarantee enforced here is "a level≥N challenge was solved by
|
||||
/// this repo's owner DID", not "the owner intended THIS repo". A malicious origin
|
||||
/// that harvests a victim's public proof (served by `get_icaptcha_proof`) can use
|
||||
/// it to get ONE spam repo nominally owned by that victim admitted per node — the
|
||||
/// per-node `jti` single-use caps the amplification at one repo per distinct
|
||||
/// harvested proof, but does not eliminate it. Fully binding a proof to a repo
|
||||
/// would require the iCaptcha service to embed a repo/target identifier in the
|
||||
/// signed challenge (a protocol change, tracked as future work).
|
||||
pub async fn admit_mirror(
|
||||
db: &crate::db::Db,
|
||||
token: Option<&str>,
|
||||
owner_did: &str,
|
||||
) -> MirrorAdmission {
|
||||
let v = match VERIFIER.get() {
|
||||
Some(v) => v,
|
||||
None => return MirrorAdmission::Admit, // not initialized -> inert
|
||||
};
|
||||
// Off, or inert because no key could be loaded: admit unconditionally.
|
||||
if v.mode == Mode::Off || v.key.is_none() {
|
||||
return MirrorAdmission::Admit;
|
||||
}
|
||||
let shadow = v.mode == Mode::Shadow;
|
||||
|
||||
// Quarantine in enforce; in shadow just log and admit (observational, no IO).
|
||||
let quarantine = |reason: String| -> MirrorAdmission {
|
||||
if shadow {
|
||||
tracing::warn!(owner = %owner_did, reason, "iCaptcha (shadow) would quarantine mirror");
|
||||
MirrorAdmission::Admit
|
||||
} else {
|
||||
MirrorAdmission::Quarantine(reason)
|
||||
}
|
||||
};
|
||||
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return quarantine("origin served no iCaptcha proof".to_string()),
|
||||
};
|
||||
|
||||
match verify_token(v, token, owner_did, now_secs(), false) {
|
||||
Ok(claims) => {
|
||||
if shadow {
|
||||
// Observational: a valid proof would admit. Do not consume, so a
|
||||
// later switch to enforce still sees the jti as fresh.
|
||||
return MirrorAdmission::Admit;
|
||||
}
|
||||
// Retain the replay record on a fixed forward window, NOT the proof's
|
||||
// own (already-past) exp — otherwise the next sweep frees the jti and
|
||||
// the same token could admit another mirror here.
|
||||
let retain_until = now_secs().saturating_add(MIRROR_REPLAY_RETENTION_SECS);
|
||||
match db.consume_proof_jti(&claims.jti, retain_until).await {
|
||||
Ok(true) => MirrorAdmission::Admit,
|
||||
Ok(false) => MirrorAdmission::Quarantine(
|
||||
"iCaptcha proof already used to admit another mirror".to_string(),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(owner = %owner_did, err = %e, "iCaptcha mirror ledger error; quarantining");
|
||||
MirrorAdmission::Quarantine("iCaptcha proof ledger unavailable".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(reason) => quarantine(reason),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Real values captured from https://icaptcha.gitlawb.com (a live proof).
|
||||
const PUBKEY_X: &str = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8";
|
||||
const PROOF: &str = "eyJzdWIiOiJkaWQ6a2V5Onp0ZXN0IiwibGV2ZWwiOjMsImlzcyI6ImljYXB0Y2hhIiwiaWF0IjoxNzgyNTcyODUxLCJleHAiOjE3ODI1NzMxNTEsImp0aSI6IjRiNTIyOGE1YmVkNzEyMmRlZTlmNDdmZiJ9.5UXVPZ8Eo91VnlcvgDXtW-Fx7J2jr7h535SAstQEpigxBr7FF7V6R0XB4PBDgdoBPnhdH_kVEfRPfdHPSdB0CA";
|
||||
const SUB: &str = "did:key:ztest";
|
||||
const IAT: i64 = 1782572851; // within the proof's validity window
|
||||
|
||||
fn verifier(level: u32) -> Verifier {
|
||||
Verifier {
|
||||
mode: Mode::Enforce,
|
||||
url: "https://icaptcha.gitlawb.com".to_string(),
|
||||
required_level: level,
|
||||
key: decode_key(PUBKEY_X),
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_with(proof: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(PROOF_HEADER, proof.parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_real_proof() {
|
||||
let v = verifier(3);
|
||||
assert!(verify(&v, &headers_with(PROOF), SUB, IAT).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired_proof() {
|
||||
let v = verifier(3);
|
||||
let err = verify(&v, &headers_with(PROOF), SUB, 9_999_999_999).unwrap_err();
|
||||
assert!(err.contains("expired"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_did() {
|
||||
let v = verifier(3);
|
||||
let err = verify(&v, &headers_with(PROOF), "did:key:zsomeoneelse", IAT).unwrap_err();
|
||||
assert!(err.contains("subject"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insufficient_level() {
|
||||
let v = verifier(5); // proof is level 3
|
||||
let err = verify(&v, &headers_with(PROOF), SUB, IAT).unwrap_err();
|
||||
assert!(err.contains("below required"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_signature() {
|
||||
let v = verifier(3);
|
||||
// Flip one base64url char in the signature so it is guaranteed different.
|
||||
let (payload, sig) = PROOF.split_once('.').unwrap();
|
||||
let mut chars: Vec<char> = sig.chars().collect();
|
||||
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
|
||||
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
|
||||
assert!(verify(&v, &headers_with(&tampered), SUB, IAT).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_header() {
|
||||
let v = verifier(3);
|
||||
let err = verify(&v, &HeaderMap::new(), SUB, IAT).unwrap_err();
|
||||
assert!(err.contains("missing"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_accepts_documented_values_and_rejects_junk() {
|
||||
assert_eq!(parse_mode(""), Some(Mode::Off));
|
||||
assert_eq!(parse_mode("off"), Some(Mode::Off));
|
||||
assert_eq!(parse_mode(" Shadow "), Some(Mode::Shadow));
|
||||
assert_eq!(parse_mode("ENFORCE"), Some(Mode::Enforce));
|
||||
// Typos must NOT silently disable the gate.
|
||||
assert_eq!(parse_mode("enforced"), None);
|
||||
assert_eq!(parse_mode("on"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_mode_allows_everything() {
|
||||
let mut v = verifier(3);
|
||||
v.mode = Mode::Off;
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Allow
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_without_key_stays_inert() {
|
||||
// iCaptcha unreachable at startup -> no key -> must not reject.
|
||||
let v = Verifier {
|
||||
mode: Mode::Enforce,
|
||||
url: "https://icaptcha.gitlawb.com".to_string(),
|
||||
required_level: 3,
|
||||
key: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Allow
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_with_key_rejects_missing_proof() {
|
||||
let v = verifier(3);
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Reject(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_allows_despite_bad_proof() {
|
||||
let mut v = verifier(3);
|
||||
v.mode = Mode::Shadow;
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Allow
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_valid_proof_requires_consuming_its_jti() {
|
||||
// A verified proof under enforce must yield Consume carrying the jti, so
|
||||
// the caller can spend it once and reject replays.
|
||||
let v = verifier(3);
|
||||
match decide(&v, &headers_with(PROOF), SUB, IAT) {
|
||||
Decision::Consume(p) => {
|
||||
assert_eq!(p.jti, "4b5228a5bed7122dee9f47ff");
|
||||
assert_eq!(p.exp, 1782573151);
|
||||
assert_eq!(p.token, PROOF, "the raw token is retained for persistence");
|
||||
assert_eq!(p.sub, SUB);
|
||||
}
|
||||
other => panic!("expected Consume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagation_accepts_an_expired_proof() {
|
||||
// The mirror-admission path verifies sig/level/DID but NOT expiry, since a
|
||||
// proof has usually expired by the time a repo propagates. The same proof
|
||||
// that the direct path rejects as expired must verify here.
|
||||
let v = verifier(3);
|
||||
let now = 9_999_999_999; // far past the proof's exp
|
||||
assert!(
|
||||
verify_token(&v, PROOF, SUB, now, true).is_err(),
|
||||
"direct path (check_exp=true) rejects the expired proof"
|
||||
);
|
||||
assert!(
|
||||
verify_token(&v, PROOF, SUB, now, false).is_ok(),
|
||||
"propagation path (check_exp=false) accepts it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagation_still_enforces_signature_level_and_did() {
|
||||
let v = verifier(3);
|
||||
// wrong owner DID
|
||||
assert!(verify_token(&v, PROOF, "did:key:zother", IAT, false).is_err());
|
||||
// insufficient level
|
||||
let v5 = verifier(5);
|
||||
assert!(verify_token(&v5, PROOF, SUB, IAT, false).is_err());
|
||||
// tampered signature
|
||||
let (payload, sig) = PROOF.split_once('.').unwrap();
|
||||
let mut chars: Vec<char> = sig.chars().collect();
|
||||
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
|
||||
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
|
||||
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
//! If `ipfs_api` is empty the functions are no-ops, so the node works fine
|
||||
//! without a local IPFS daemon.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use gitlawb_core::cid::Cid;
|
||||
|
||||
@@ -83,27 +85,31 @@ pub async fn cat(ipfs_api: &str, cid: &str) -> Result<Vec<u8>> {
|
||||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
/// Pin any of the given candidate git objects that are not yet recorded in
|
||||
/// `pinned_cids`.
|
||||
///
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
|
||||
/// applies `visibility_pack::replicable_objects` on the delta path or the
|
||||
/// `..._fail_closed` filter on the full-scan path before calling, so this
|
||||
/// function never sees a withheld blob. `repo_path` is still needed to read each
|
||||
/// object's bytes. The twin in `pinata.rs` mirrors this shape — change both in
|
||||
/// lockstep.
|
||||
/// List all git objects in the given bare repo and pin any that are not yet
|
||||
/// recorded in `pinned_cids`.
|
||||
///
|
||||
/// Returns a list of `(sha256_hex, cid)` pairs for objects pinned this call.
|
||||
pub async fn pin_new_objects(
|
||||
ipfs_api: &str,
|
||||
repo_path: &std::path::Path,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
withheld: &HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
if ipfs_api.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Enumerate all objects in the repo
|
||||
let object_list = match list_all_objects(repo_path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_path.display(), err = %e, "failed to list git objects for IPFS pinning");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let object_list = crate::git::visibility_pack::replicable_objects(object_list, withheld);
|
||||
|
||||
let mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
@@ -144,3 +150,31 @@ pub async fn pin_new_objects(
|
||||
|
||||
pinned
|
||||
}
|
||||
|
||||
/// Run `git cat-file --batch-all-objects --batch-check='%(objectname)'`
|
||||
/// to get all object SHA-256 hashes in the repository.
|
||||
fn list_all_objects(repo_path: &std::path::Path) -> Result<Vec<String>> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("failed to run git cat-file: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("git cat-file failed: {stderr}"));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let hashes = stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(hashes)
|
||||
}
|
||||
+87
-557
@@ -9,7 +9,6 @@ mod encrypted_pin;
|
||||
mod error;
|
||||
mod git;
|
||||
mod graphql;
|
||||
mod icaptcha;
|
||||
mod ipfs_pin;
|
||||
mod metrics;
|
||||
mod operator;
|
||||
@@ -19,19 +18,11 @@ mod rate_limit;
|
||||
mod server;
|
||||
mod state;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
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 +35,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()
|
||||
@@ -96,101 +73,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() {
|
||||
@@ -201,15 +98,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Prune peer rows with non-public hosts (loopback/private/internal) that
|
||||
// were injected via the unauthenticated announce route — they poison the
|
||||
// sync-notify fan-out (SSRF + crowding out real peers).
|
||||
match db.prune_non_public_peers().await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(removed = n, "pruned non-public (poisoned) peer rows"),
|
||||
Err(e) => warn!(err = %e, "prune_non_public_peers failed (non-fatal)"),
|
||||
}
|
||||
|
||||
// Ensure repos directory exists
|
||||
std::fs::create_dir_all(&config.repos_dir).context("failed to create repos directory")?;
|
||||
|
||||
@@ -245,8 +133,11 @@ 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()?);
|
||||
let http_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?,
|
||||
);
|
||||
|
||||
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
|
||||
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
|
||||
@@ -282,82 +173,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");
|
||||
}
|
||||
|
||||
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
|
||||
icaptcha::init().await;
|
||||
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(config.clone()),
|
||||
@@ -372,11 +188,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,31 +217,43 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
// 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
|
||||
{
|
||||
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 {
|
||||
loop {
|
||||
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)
|
||||
.unwrap_or(0);
|
||||
if let Err(e) = db.sweep_expired_proofs(now).await {
|
||||
tracing::warn!(err = %e, "failed to sweep expired iCaptcha proofs");
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
@@ -443,10 +266,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 +277,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 +371,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 +397,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 +426,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 +487,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 +562,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 +582,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 +614,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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! no-op, so nodes without Pinata backing work fine.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Pin a single git object's raw bytes on Pinata (v3 API).
|
||||
///
|
||||
@@ -66,28 +67,35 @@ pub async fn pin_object(
|
||||
Ok(cid)
|
||||
}
|
||||
|
||||
/// Pin any of the given candidate git objects that haven't yet been sent to
|
||||
/// Pinata.
|
||||
/// Pin all git objects in a repo that haven't yet been sent to Pinata.
|
||||
///
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
|
||||
/// applies `visibility_pack::replicable_objects` on the delta path or the
|
||||
/// `..._fail_closed` filter on the full-scan path before calling. `repo_path` is
|
||||
/// still needed to read each object's bytes. The twin in `ipfs_pin.rs` mirrors
|
||||
/// this shape — change both in lockstep. Objects already recorded with a
|
||||
/// `pinata_cid` are skipped. Returns `(sha_hex, cid)` pairs for each newly
|
||||
/// pinned object.
|
||||
/// Objects already recorded with a `pinata_cid` in the DB are skipped.
|
||||
/// Returns `(sha_hex, cid)` pairs for each newly pinned object.
|
||||
pub async fn pin_new_objects(
|
||||
client: &reqwest::Client,
|
||||
upload_url: &str,
|
||||
jwt: &str,
|
||||
repo_path: &std::path::Path,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
withheld: &HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
if jwt.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let object_list = match list_all_objects(repo_path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
repo = %repo_path.display(),
|
||||
err = %e,
|
||||
"failed to list git objects for Pinata upload"
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
let object_list = crate::git::visibility_pack::replicable_objects(object_list, withheld);
|
||||
|
||||
let mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
@@ -126,6 +134,29 @@ pub async fn pin_new_objects(
|
||||
pinned
|
||||
}
|
||||
|
||||
fn list_all_objects(repo_path: &std::path::Path) -> Result<Vec<String>> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("failed to run git cat-file: {e}"))?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(anyhow::anyhow!("git cat-file failed: {stderr}"));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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,30 @@ 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;
|
||||
let window = state.entry(key.to_string()).or_insert_with(|| Window {
|
||||
timestamps: Vec::new(),
|
||||
});
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
if window.timestamps.len() >= self.max_requests {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
state.insert(
|
||||
key.to_string(),
|
||||
Window {
|
||||
timestamps: vec![now],
|
||||
},
|
||||
);
|
||||
window.timestamps.push(now);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -154,136 +80,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 +126,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,19 +20,8 @@ use crate::auth;
|
||||
use crate::rate_limit;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn graphql_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<axum::Extension<crate::auth::AuthenticatedDid>>,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
// `optional_signature` attaches the verified DID when a signature is present.
|
||||
// Thread it into request-scoped GraphQL data; mutations enforce its presence
|
||||
// in-resolver (N2) while queries stay open.
|
||||
let mut inner = req.into_inner();
|
||||
if let Some(axum::Extension(did)) = auth {
|
||||
inner = inner.data(did);
|
||||
}
|
||||
state.graphql_schema.execute(inner).await.into()
|
||||
async fn graphql_handler(State(state): State<AppState>, req: GraphQLRequest) -> GraphQLResponse {
|
||||
state.graphql_schema.execute(req.into_inner()).await.into()
|
||||
}
|
||||
|
||||
async fn graphql_playground() -> impl IntoResponse {
|
||||
@@ -60,10 +49,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let schema = state.graphql_schema.as_ref().clone();
|
||||
let graphql_routes = Router::new()
|
||||
.route("/graphql", get(graphql_playground).post(graphql_handler))
|
||||
// Attach the verified DID to /graphql when a signature is present. The
|
||||
// layer covers only routes added before it, so /graphql/ws (added after,
|
||||
// read-only subscriptions) stays open.
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
@@ -81,19 +66,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 +81,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(
|
||||
@@ -175,10 +147,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
axum::routing::put(visibility::set_visibility)
|
||||
.delete(visibility::remove_visibility)
|
||||
.get(visibility::list_visibility),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/agents/{did}",
|
||||
axum::routing::delete(agents::deregister_agent),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
@@ -188,14 +156,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// 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(
|
||||
@@ -205,19 +165,12 @@ 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
|
||||
// identity and can apply per-repo visibility (#110); anonymous callers stay
|
||||
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
|
||||
// stays unsigned — gating the pin index is tracked separately (#121).
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
|
||||
@@ -261,8 +214,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(
|
||||
@@ -291,41 +243,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 +357,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/withheld-paths",
|
||||
axum::routing::get(visibility::withheld_paths),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/icaptcha-proof",
|
||||
axum::routing::get(repos::get_icaptcha_proof),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/encrypted-blobs",
|
||||
axum::routing::get(crate::api::encrypted::list_encrypted_blobs),
|
||||
@@ -455,7 +377,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));
|
||||
@@ -475,7 +396,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(read_routes)
|
||||
.merge(peer_read_routes)
|
||||
.merge(peer_write_routes)
|
||||
.merge(sync_trigger_routes)
|
||||
.merge(ipfs_routes)
|
||||
.merge(arweave_routes)
|
||||
.merge(meta_routes)
|
||||
@@ -498,26 +418,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!({
|
||||
@@ -532,31 +432,13 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
// Count only the repos an anonymous caller could list, so the aggregate does
|
||||
// not leak the existence of private/mode-A repos (#104 count oracle). Mirror
|
||||
// the listing seam (api/repos.rs): over-fetch the deduped set, batch-load the
|
||||
// visibility rules, and keep rows that pass listable_at_root. The caller is
|
||||
// always None — meta_routes carries no auth layer (see the route group in this
|
||||
// file). Fail closed: any DB error collapses the whole count to 0 (an
|
||||
// under-count never leaks existence), preserving the prior `.unwrap_or(0)`.
|
||||
let repos = async {
|
||||
// stats only needs the count, so use the no-stars deduped list (same
|
||||
// DEDUP_CTE) and skip the repo_stars aggregation the listing path needs.
|
||||
let rows = state.db.list_all_repos_deduped().await?;
|
||||
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
|
||||
let count = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
|
||||
})
|
||||
.count() as i64;
|
||||
Ok::<i64, anyhow::Error>(count)
|
||||
}
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let repos = state
|
||||
.db
|
||||
.list_all_repos()
|
||||
.await
|
||||
.map(|r| r.len() as i64)
|
||||
.unwrap_or(0);
|
||||
let agents = state.db.count_agents().await.unwrap_or(0);
|
||||
let pushes = state.db.count_pushes().await.unwrap_or(0);
|
||||
Json(json!({
|
||||
|
||||
@@ -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:
|
||||
|
||||
+33
-310
@@ -32,50 +32,19 @@ enum MirrorMode {
|
||||
Promisor,
|
||||
}
|
||||
|
||||
/// The on-disk promisor state of an existing mirror, read from
|
||||
/// `remote.origin.promisor`. Three-valued so a git error is not mistaken for a
|
||||
/// definitive "not a promisor": `git config --get` collapses "key absent" and
|
||||
/// "git failed" into the same non-zero exit otherwise, and treating an errored
|
||||
/// probe as `NotPromisor` would let a transient failure downgrade a still-withheld
|
||||
/// mirror (issue #48).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PromisorProbe {
|
||||
/// `remote.origin.promisor` is `"true"`.
|
||||
Promisor,
|
||||
/// The key is absent (git exit 1) or set to a non-`true` value.
|
||||
NotPromisor,
|
||||
/// The probe itself failed (git spawn error or other non-zero exit).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Decide the mirror mode from the origin's `withheld-paths` response plus, when
|
||||
/// the response is unknown, the existing mirror's on-disk promisor state.
|
||||
/// Decide the mirror mode from the origin's `withheld-paths` response.
|
||||
///
|
||||
/// `Some(non-empty)` → the repo has a private subtree → `Promisor`.
|
||||
/// `Some(empty)` → fully public → `Plain`.
|
||||
/// `None` → the lookup 404'd or failed; the answer is *unknown*, which
|
||||
/// is not the same as "public". For a fresh clone this stays
|
||||
/// `Plain` (a mode-A repo also 404s the git read endpoint, so
|
||||
/// the clone fails and nothing is mirrored — fail-closed at the
|
||||
/// git layer — while a public repo on a peer that predates the
|
||||
/// `withheld-paths` route still gets mirrored). For an existing
|
||||
/// mirror it *biases toward preserving* the promisor state: a
|
||||
/// genuine public transition returns `Some(empty)`, so on
|
||||
/// `None` we cannot distinguish "still withheld, unreachable"
|
||||
/// from "newly public, unreachable" and prefer the recoverable
|
||||
/// choice over destroying the partial-clone config. An
|
||||
/// indeterminate probe (`Unknown`) preserves for the same
|
||||
/// reason (defense-in-depth, #48).
|
||||
fn resolve_mirror_mode(
|
||||
withheld: Option<Vec<String>>,
|
||||
exists: bool,
|
||||
promisor: PromisorProbe,
|
||||
) -> MirrorMode {
|
||||
/// `None` → the lookup 404'd or failed. Attempt a `Plain` mirror; a
|
||||
/// mode-A repo also 404s the git read endpoint, so the clone
|
||||
/// fails and nothing is mirrored (fail-closed at the git
|
||||
/// layer), while a public repo on a peer that predates the
|
||||
/// `withheld-paths` route still gets mirrored.
|
||||
fn classify_mirror(withheld: Option<Vec<String>>) -> MirrorMode {
|
||||
match withheld {
|
||||
Some(globs) if !globs.is_empty() => MirrorMode::Promisor,
|
||||
Some(_) => MirrorMode::Plain,
|
||||
None if exists && promisor != PromisorProbe::NotPromisor => MirrorMode::Promisor,
|
||||
None => MirrorMode::Plain,
|
||||
_ => MirrorMode::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,17 +109,10 @@ async fn run(
|
||||
let machine_id = std::env::var("FLY_MACHINE_ID").ok();
|
||||
// Bound each peer HTTP call (withheld-paths lookup + replica registration)
|
||||
// so a stalled peer cannot hang the worker.
|
||||
// No redirects: peer URLs are attacker-influenceable, so a 3xx to a
|
||||
// loopback/private address must not be followed (SSRF guard, matching the
|
||||
// shared http_client and announce-time validation).
|
||||
// Panic rather than fall back to reqwest::Client::new(): the default
|
||||
// builder follows redirects, which would silently reintroduce the SSRF
|
||||
// vector Policy::none() is here to close.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("failed to build no-redirect sync HTTP client");
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
info!("sync worker started (auto_sync=true)");
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
@@ -168,17 +130,6 @@ async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the origin node's base HTTP URL for a sync item, trimming any trailing
|
||||
/// slash so callers can append `/{path}` cleanly. Returns `None` when no peer
|
||||
/// row matches the item's origin DID. Kept pure (no DB) so the per-batch peer
|
||||
/// resolution can be unit-tested without a database.
|
||||
fn resolve_origin_url(peers: &[crate::db::PeerRecord], node_did: &str) -> Option<String> {
|
||||
peers
|
||||
.iter()
|
||||
.find(|p| p.did == node_did)
|
||||
.map(|p| p.http_url.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
async fn process_batch(
|
||||
db: &Db,
|
||||
config: &Config,
|
||||
@@ -194,27 +145,19 @@ async fn process_batch(
|
||||
}
|
||||
};
|
||||
|
||||
// Nothing queued — the common case on the 30s idle poll. Bail before the
|
||||
// peer lookup so an empty batch costs zero extra DB round-trips.
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the peer table once per batch — every item only needs a lookup.
|
||||
let peers = match db.list_peers().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "failed to list peers for sync");
|
||||
for item in &items {
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for item in items {
|
||||
let origin_url = match resolve_origin_url(&peers, &item.node_did) {
|
||||
Some(url) => url,
|
||||
// Resolve origin node HTTP URL from peer table
|
||||
let peers = match db.list_peers().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "failed to list peers for sync");
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let origin_url = match peers.iter().find(|p| p.did == item.node_did) {
|
||||
Some(p) => p.http_url.trim_end_matches('/').to_string(),
|
||||
None => {
|
||||
warn!(node_did = %item.node_did, repo = %item.repo, "no peer URL found for sync origin — skipping");
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
@@ -243,30 +186,9 @@ async fn process_batch(
|
||||
let remote_url = format!("{}/{}", origin_url, item.repo);
|
||||
|
||||
let withheld = fetch_withheld(client, &origin_url, owner_short, repo_name).await;
|
||||
let exists = local_path.exists();
|
||||
let lookup_unknown = withheld.is_none();
|
||||
// Only probe the on-disk promisor state when the lookup is unknown and the
|
||||
// repo already exists — the sole case where it changes the resolved mode.
|
||||
let promisor = if lookup_unknown && exists {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
existing_promisor_state(local_str).await
|
||||
} else {
|
||||
PromisorProbe::NotPromisor
|
||||
};
|
||||
let mode = resolve_mirror_mode(withheld, exists, promisor);
|
||||
// Surface the case where an unknown withheld-paths lookup kept (or, on an
|
||||
// indeterminate probe, defensively applied) promisor mode instead of
|
||||
// downgrading to a full clone. Derived from the resolved mode so it cannot
|
||||
// drift from resolve_mirror_mode's preserve branch.
|
||||
if lookup_unknown && mode == MirrorMode::Promisor {
|
||||
warn!(
|
||||
repo = %item.repo,
|
||||
origin = %origin_url,
|
||||
"withheld-paths lookup unavailable; using promisor mirror mode to avoid an unsafe full-clone downgrade"
|
||||
);
|
||||
}
|
||||
let mode = classify_mirror(withheld);
|
||||
|
||||
let result = if exists {
|
||||
let result = if local_path.exists() {
|
||||
fetch_repo(&local_path, &remote_url, mode).await
|
||||
} else {
|
||||
clone_repo(&remote_url, &local_path, mode).await
|
||||
@@ -275,39 +197,6 @@ async fn process_batch(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!(repo = %item.repo, origin = %origin_url, "synced repo from peer");
|
||||
// iCaptcha propagation gate: on a first-time mirror, re-verify the
|
||||
// proof the repo was created with (fetched from the origin) before
|
||||
// admitting it. A node that enforces iCaptcha quarantines a mirror
|
||||
// it cannot validate — kept on disk but hidden from serve/clone and
|
||||
// listings until an operator releases it. Re-syncs of an already
|
||||
// admitted repo keep their prior decision (upsert preserves it).
|
||||
//
|
||||
// "First-time" is keyed on DB-row absence, NOT on-disk presence: a
|
||||
// prior attempt could have cloned to disk but crashed before the
|
||||
// upsert, leaving no DB row. Disk-keying would skip admission on the
|
||||
// retry and admit it unquarantined. On a DB lookup error, default to
|
||||
// running the gate (fail toward quarantine, never silently admit).
|
||||
let is_new_in_db = db
|
||||
.get_repo(owner_short, repo_name)
|
||||
.await
|
||||
.map(|r| r.is_none())
|
||||
.unwrap_or(true);
|
||||
let quarantined = if is_new_in_db {
|
||||
let proof =
|
||||
fetch_icaptcha_proof(client, &origin_url, owner_short, repo_name).await;
|
||||
match crate::icaptcha::admit_mirror(db, proof.as_deref(), owner_short).await {
|
||||
crate::icaptcha::MirrorAdmission::Admit => false,
|
||||
crate::icaptcha::MirrorAdmission::Quarantine(reason) => {
|
||||
warn!(
|
||||
repo = %item.repo, origin = %origin_url, reason,
|
||||
"quarantining mirrored repo: failed iCaptcha propagation gate"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Register in DB so git smart HTTP can serve the mirrored repo
|
||||
let _ = db
|
||||
.upsert_mirror_repo(
|
||||
@@ -315,7 +204,6 @@ async fn process_batch(
|
||||
repo_name,
|
||||
local_path.to_str().unwrap_or(""),
|
||||
machine_id,
|
||||
quarantined,
|
||||
)
|
||||
.await;
|
||||
// Option B2: carry the encrypted withheld-blob envelopes too, so an
|
||||
@@ -359,7 +247,7 @@ async fn process_batch(
|
||||
|
||||
/// Query the origin's anonymous `withheld-paths` endpoint. Returns the withheld
|
||||
/// glob list on a 2xx, or `None` on any non-success / network / parse error
|
||||
/// (treated as "unknown" by `resolve_mirror_mode`).
|
||||
/// (treated as "unknown" by `classify_mirror`).
|
||||
async fn fetch_withheld(
|
||||
client: &reqwest::Client,
|
||||
origin_url: &str,
|
||||
@@ -381,25 +269,6 @@ async fn fetch_withheld(
|
||||
Some(globs)
|
||||
}
|
||||
|
||||
/// Fetch the iCaptcha proof token the origin recorded for this repo, used by the
|
||||
/// mirror-admission gate. Returns `None` on any non-success / network / parse
|
||||
/// error, or when the origin has no proof for the repo (treated as "no proof" by
|
||||
/// `icaptcha::admit_mirror`, which quarantines in enforce mode).
|
||||
async fn fetch_icaptcha_proof(
|
||||
client: &reqwest::Client,
|
||||
origin_url: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/icaptcha-proof");
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("proof")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
/// Signed request path for replica registration on the origin node.
|
||||
fn replica_registration_path(owner: &str, repo: &str) -> String {
|
||||
format!("/api/v1/repos/{owner}/{repo}/replicas")
|
||||
@@ -573,38 +442,6 @@ async fn git_config_get(repo: &str, key: &str) -> Option<String> {
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
/// Probe an existing mirror's `remote.origin.promisor` config as a three-valued
|
||||
/// state. Unlike [`git_config_get`], this distinguishes a definitively-absent key
|
||||
/// from a probe failure so a transient git error cannot be read as "not a
|
||||
/// promisor" and trigger a downgrade (issue #48).
|
||||
///
|
||||
/// `git config --get` exits 0 when the key is set, 1 when it is absent (or the
|
||||
/// directory is not a git repo) — both definitive `NotPromisor` — and other
|
||||
/// non-zero codes (e.g. 128 for a bad path or unreadable config) on error. A spawn
|
||||
/// failure or any non-{0,1} exit is `Unknown`.
|
||||
async fn existing_promisor_state(repo: &str) -> PromisorProbe {
|
||||
let out = match tokio::process::Command::new("git")
|
||||
.args(["-C", repo, "config", "--get", "remote.origin.promisor"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) => out,
|
||||
Err(_) => return PromisorProbe::Unknown,
|
||||
};
|
||||
match out.status.code() {
|
||||
Some(0) => {
|
||||
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if value == "true" {
|
||||
PromisorProbe::Promisor
|
||||
} else {
|
||||
PromisorProbe::NotPromisor
|
||||
}
|
||||
}
|
||||
Some(1) => PromisorProbe::NotPromisor,
|
||||
_ => PromisorProbe::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror-clone a repo from a remote URL into a local bare repo.
|
||||
/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
|
||||
/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
|
||||
@@ -695,66 +532,23 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn resolve_promisor_when_withheld_nonempty() {
|
||||
let mode = resolve_mirror_mode(
|
||||
Some(vec!["/secret/**".to_string()]),
|
||||
true,
|
||||
PromisorProbe::NotPromisor,
|
||||
);
|
||||
fn classify_promisor_when_withheld_nonempty() {
|
||||
let mode = classify_mirror(Some(vec!["/secret/**".to_string()]));
|
||||
assert!(matches!(mode, MirrorMode::Promisor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_plain_when_withheld_empty() {
|
||||
// A genuine public transition returns Some(empty) and still downgrades,
|
||||
// regardless of whether the mirror exists or was a promisor.
|
||||
for exists in [true, false] {
|
||||
for probe in [
|
||||
PromisorProbe::Promisor,
|
||||
PromisorProbe::NotPromisor,
|
||||
PromisorProbe::Unknown,
|
||||
] {
|
||||
let mode = resolve_mirror_mode(Some(vec![]), exists, probe);
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preserves_promisor_on_unknown_lookup_for_existing_mirror() {
|
||||
// Regression for #48: a transient withheld-paths outage (None) must NOT
|
||||
// downgrade a still-withheld promisor mirror to a full clone.
|
||||
let mode = resolve_mirror_mode(None, true, PromisorProbe::Promisor);
|
||||
assert!(matches!(mode, MirrorMode::Promisor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preserves_promisor_on_indeterminate_probe() {
|
||||
// Defense-in-depth (#48): if the config probe itself fails (Unknown) in the
|
||||
// same cycle as a withheld-paths outage, bias toward preserving rather than
|
||||
// firing the destructive downgrade.
|
||||
let mode = resolve_mirror_mode(None, true, PromisorProbe::Unknown);
|
||||
assert!(matches!(mode, MirrorMode::Promisor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_plain_when_unknown_lookup_for_non_promisor_mirror() {
|
||||
// An existing non-promisor mirror is unaffected by the preserve branch.
|
||||
let mode = resolve_mirror_mode(None, true, PromisorProbe::NotPromisor);
|
||||
fn classify_plain_when_withheld_empty() {
|
||||
let mode = classify_mirror(Some(vec![]));
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_plain_when_unknown_lookup_for_fresh_clone() {
|
||||
// No local mirror yet: None stays Plain (fail-closed at the git layer).
|
||||
for probe in [
|
||||
PromisorProbe::Promisor,
|
||||
PromisorProbe::NotPromisor,
|
||||
PromisorProbe::Unknown,
|
||||
] {
|
||||
let mode = resolve_mirror_mode(None, false, probe);
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
fn classify_plain_when_lookup_failed() {
|
||||
// None == 404 / network error / parse failure: attempt a plain mirror
|
||||
// and let the git read endpoint fail-close a mode-A repo.
|
||||
let mode = classify_mirror(None);
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
|
||||
fn rb(oid: &str, cid: &str) -> ReplicaBlob {
|
||||
@@ -896,38 +690,6 @@ mod tests {
|
||||
assert_eq!(git_config(&dest, "remote.origin.mirror"), "true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_promisor_for_promisor_mirror() {
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
|
||||
let dest = td.path().join("mirror.git");
|
||||
clone_repo(&url, &dest, MirrorMode::Promisor).await.unwrap();
|
||||
|
||||
let probe = existing_promisor_state(dest.to_str().unwrap()).await;
|
||||
assert_eq!(probe, PromisorProbe::Promisor);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_not_promisor_when_key_absent() {
|
||||
// A plain mirror never sets remote.origin.promisor, so `git config --get`
|
||||
// exits 1 (key absent) — the probe must read that as NotPromisor, never
|
||||
// Unknown (which would wrongly preserve and upgrade a plain mirror).
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
|
||||
let dest = td.path().join("mirror.git");
|
||||
clone_repo(&url, &dest, MirrorMode::Plain).await.unwrap();
|
||||
|
||||
let probe = existing_promisor_state(dest.to_str().unwrap()).await;
|
||||
assert_eq!(probe, PromisorProbe::NotPromisor);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_unknown_on_git_error() {
|
||||
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
|
||||
// probe, not a definitive "not a promisor" — it must map to Unknown so the
|
||||
// caller preserves rather than downgrades (#48 defense-in-depth).
|
||||
let probe = existing_promisor_state("/nonexistent/gitlawb-probe-xyz").await;
|
||||
assert_eq!(probe, PromisorProbe::Unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn promisor_fetch_updates_existing_mirror() {
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
|
||||
@@ -993,43 +755,4 @@ mod tests {
|
||||
register_replica_with_origin(&client, &keypair, Some(""), "http://127.0.0.1:1", "o", "r")
|
||||
.await;
|
||||
}
|
||||
|
||||
fn peer(did: &str, http_url: &str) -> crate::db::PeerRecord {
|
||||
crate::db::PeerRecord {
|
||||
did: did.to_string(),
|
||||
http_url: http_url.to_string(),
|
||||
last_seen: None,
|
||||
last_ping_ok: false,
|
||||
announced_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_origin_url_matches_and_trims_trailing_slash() {
|
||||
let peers = vec![
|
||||
peer("did:key:a", "https://a.example/"),
|
||||
peer("did:key:b", "https://b.example"),
|
||||
];
|
||||
// Trailing slash is trimmed so callers can append `/{path}` cleanly.
|
||||
assert_eq!(
|
||||
resolve_origin_url(&peers, "did:key:a").as_deref(),
|
||||
Some("https://a.example")
|
||||
);
|
||||
// Already-trimmed URLs pass through unchanged.
|
||||
assert_eq!(
|
||||
resolve_origin_url(&peers, "did:key:b").as_deref(),
|
||||
Some("https://b.example")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_origin_url_returns_none_for_unknown_did() {
|
||||
let peers = vec![peer("did:key:a", "https://a.example")];
|
||||
assert_eq!(resolve_origin_url(&peers, "did:key:unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_origin_url_returns_none_for_empty_peer_list() {
|
||||
assert_eq!(resolve_origin_url(&[], "did:key:a"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,9 +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 unicode_normalization::UnicodeNormalization;
|
||||
use crate::db::VisibilityRule;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
@@ -14,23 +12,11 @@ pub enum Decision {
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// NFC-normalize a glob prefix or path so the matcher compares canonically
|
||||
/// equivalent strings byte-for-byte. Without this, a deny rule authored NFC
|
||||
/// (`é` = U+00E9) byte-compares unequal to a path committed NFD (`e` + U+0301)
|
||||
/// and the blob slips past the rule on the replication path (#101). NFC, not
|
||||
/// NFKC: compatibility folding (ligatures, full-width forms) would merge paths
|
||||
/// the filesystem treats as distinct and over-withhold. Both sides of every
|
||||
/// comparison must pass through this, or the skew just moves.
|
||||
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".
|
||||
@@ -50,17 +36,12 @@ fn glob_matches(glob: &str, path: &str) -> bool {
|
||||
if prefix == "/" {
|
||||
return true;
|
||||
}
|
||||
// Compare in NFC so an NFC rule matches a canonically-equivalent NFD path
|
||||
// (and vice versa). Both sides normalized here — the single matcher seam.
|
||||
let prefix = nfc(prefix);
|
||||
let path = nfc(path);
|
||||
path == prefix || path.starts_with(&format!("{prefix}/"))
|
||||
}
|
||||
|
||||
/// Specificity = length of the (normalized) match prefix; longer is more
|
||||
/// specific. Normalized so ranking stays consistent with `glob_matches`.
|
||||
/// Specificity = length of the match prefix; longer is more specific.
|
||||
fn specificity(glob: &str) -> usize {
|
||||
nfc(glob_prefix(glob)).len()
|
||||
glob_prefix(glob).len()
|
||||
}
|
||||
|
||||
/// Decide whether `caller` (None = anonymous) may read `path` in a repo.
|
||||
@@ -115,119 +96,6 @@ pub fn visibility_check(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `caller` (None = anonymous) may see a repo in a listing — the `"/"`
|
||||
/// visibility decision, shared by every repo-listing surface (REST list,
|
||||
/// federated list, GraphQL `repos`) so they enforce one rule, not three drifting
|
||||
/// copies. Not a bare `is_public` test: a repo can be `is_public=false` with a
|
||||
/// root rule granting readers, or `is_public=true` with a root deny (#97).
|
||||
pub fn listable_at_root(
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> bool {
|
||||
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 +268,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"])];
|
||||
@@ -557,292 +371,4 @@ mod tests {
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// path and the blob leaks on the replication path.
|
||||
#[test]
|
||||
fn matcher_withholds_across_nfc_nfd_normalization_skew() {
|
||||
// "é" composed (NFC, U+00E9) in the rule; decomposed (NFD, e + U+0301)
|
||||
// in the committed path.
|
||||
let nfc_rule = "/s\u{00e9}cret/**";
|
||||
let nfd_path = "/se\u{0301}cret/key.pem";
|
||||
let rules = [rule(nfc_rule, VisibilityMode::B, &["did:key:z6MkFriend"])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, nfd_path),
|
||||
Decision::Deny,
|
||||
"NFC-authored deny rule must withhold the canonically-equivalent NFD path"
|
||||
);
|
||||
|
||||
// Mirror: rule authored NFD, path committed NFC.
|
||||
let nfd_rule = "/se\u{0301}cret/**";
|
||||
let nfc_path = "/s\u{00e9}cret/key.pem";
|
||||
let rules2 = [rule(nfd_rule, VisibilityMode::B, &["did:key:z6MkFriend"])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules2, true, OWNER, None, nfc_path),
|
||||
Decision::Deny,
|
||||
"NFD-authored deny rule must withhold the canonically-equivalent NFC path"
|
||||
);
|
||||
|
||||
// A genuinely different path is still allowed (no over-withholding).
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, "/public/x.txt"),
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.7.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
+64
-838
@@ -234,22 +234,6 @@ struct WithheldPathsResponse {
|
||||
reinclude: Vec<String>,
|
||||
}
|
||||
|
||||
/// The node's `/encrypted-blobs` 200 body. Same rationale as `WithheldPathsResponse`:
|
||||
/// deserializing into a typed struct makes a missing or mistyped `blobs` field (or a
|
||||
/// blob entry missing its `oid`) a hard error instead of silently becoming "nothing
|
||||
/// to recover", which would mask a server schema regression behind a clone that
|
||||
/// quietly omits authorized files. Unknown server fields (e.g. `size`) are ignored
|
||||
/// by serde, so the response may carry extra keys without breaking.
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedBlobsResponse {
|
||||
blobs: Vec<EncryptedBlobEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedBlobEntry {
|
||||
oid: String,
|
||||
}
|
||||
|
||||
/// After the base clone, recover encrypted blobs the caller is authorized for
|
||||
/// that are missing locally: fetch the envelope, decrypt with the caller's key,
|
||||
/// install as a loose object. Returns the repo-relative paths recovered.
|
||||
@@ -275,8 +259,13 @@ async fn recover_encrypted_blobs(
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Ok(vec![]),
|
||||
};
|
||||
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
|
||||
if body.blobs.is_empty() {
|
||||
let body: serde_json::Value = resp.json().await.context("parsing encrypted-blobs")?;
|
||||
let blobs = body
|
||||
.get("blobs")
|
||||
.and_then(|b| b.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if blobs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
@@ -294,8 +283,10 @@ async fn recover_encrypted_blobs(
|
||||
}
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
for entry in body.blobs {
|
||||
let oid = entry.oid.as_str();
|
||||
for entry in blobs {
|
||||
let Some(oid) = entry.get("oid").and_then(|o| o.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
// Skip if already present locally.
|
||||
let present = Command::new("git")
|
||||
.args(["-C", dest_str, "cat-file", "-e", oid])
|
||||
@@ -363,151 +354,43 @@ struct Manifest {
|
||||
blobs: Vec<ManifestBlob>,
|
||||
}
|
||||
|
||||
/// One discovered manifest transaction: its Arweave id and block height. The
|
||||
/// height is `None` only when the anchor is not yet mined (no `block`), in which
|
||||
/// case it is the most recent and sorts as newest in the merge tie-break. A mined
|
||||
/// anchor is always `Some(_)` (0 if the gateway reported an unreadable height) so
|
||||
/// a malformed height can never masquerade as pending/newest.
|
||||
struct TxRef {
|
||||
id: String,
|
||||
height: Option<u64>,
|
||||
}
|
||||
|
||||
/// Edges requested per Arweave GraphQL page (`first:` in the discovery query) and
|
||||
/// the per-page bound enforced when parsing a response. The `first:N` literal in
|
||||
/// `ARWEAVE_DISCOVERY_QUERY` MUST equal this constant; `query_first_matches_page_size`
|
||||
/// pins that invariant at test time (the query keeps a literal because `format!`-ing
|
||||
/// the brace-heavy GraphQL body would be error-prone).
|
||||
const ARWEAVE_PAGE_SIZE: usize = 100;
|
||||
|
||||
/// Paginated discovery query: manifests tagged for this repo, newest first, with
|
||||
/// `pageInfo` for cursor pagination and `block{height}` for the recency tie-break.
|
||||
const ARWEAVE_DISCOVERY_QUERY: &str = r#"query($repo:String!,$cursor:String){transactions(tags:[{name:"App-Name",values:["gitlawb"]},{name:"Schema",values:["gitlawb/encrypted-manifest/v1"]},{name:"Repo",values:[$repo]}],first:100,after:$cursor){pageInfo{hasNextPage endCursor}edges{cursor node{id block{height}}}}}"#;
|
||||
|
||||
/// A parsed page of an Arweave GraphQL `transactions` response: the edge refs
|
||||
/// plus pagination state. `has_next` is `None` when the response omitted
|
||||
/// `pageInfo` entirely (an older or partial gateway), which the discovery loop
|
||||
/// treats as terminal but flags when the page was full (possible truncation).
|
||||
struct TxPage {
|
||||
refs: Vec<TxRef>,
|
||||
has_next: Option<bool>,
|
||||
end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse one Arweave GraphQL `transactions` page: each edge's tx id and block
|
||||
/// height, plus `pageInfo` for cursor pagination. Hand-walks the `Value` (the
|
||||
/// envelope nests `data.transactions.{edges,pageInfo}`) rather than deriving a
|
||||
/// wrapper struct, matching the surrounding style.
|
||||
///
|
||||
/// At most `ARWEAVE_PAGE_SIZE` edges are taken from a single page: the query asks
|
||||
/// for `first:100`, so a response with more edges is a misbehaving (or hostile)
|
||||
/// gateway. Bounding here caps the per-page `TxRef` vec and the downstream per-id
|
||||
/// fetch loop so the `MAX_TX_IDS` budget can't be defeated by one oversized page.
|
||||
/// (It does not bound the response body itself: `reqwest` buffers and deserializes
|
||||
/// the whole body before this runs — an unbounded-body concern shared by every
|
||||
/// gateway read in this file, addressed separately if it ever matters.)
|
||||
fn parse_tx_page(v: &serde_json::Value) -> TxPage {
|
||||
let txns = v.get("data").and_then(|d| d.get("transactions"));
|
||||
let refs: Vec<TxRef> = txns
|
||||
/// Extract transaction ids from an Arweave GraphQL `transactions` response.
|
||||
fn parse_tx_ids(v: &serde_json::Value) -> Vec<String> {
|
||||
v.get("data")
|
||||
.and_then(|d| d.get("transactions"))
|
||||
.and_then(|t| t.get("edges"))
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|edges| {
|
||||
edges
|
||||
.iter()
|
||||
.take(ARWEAVE_PAGE_SIZE)
|
||||
.filter_map(|edge| {
|
||||
let node = edge.get("node")?;
|
||||
let id = node.get("id").and_then(|i| i.as_str())?.to_string();
|
||||
// Distinguish "not yet mined" from "mined but height unreadable".
|
||||
// Only a genuinely absent/null `block` is pending (`None`,
|
||||
// ranked newest). A present `block` means the anchor IS mined, so
|
||||
// it must not be promoted to newest: parse the height permissively
|
||||
// (number or numeric string, guarding a gateway that stringifies
|
||||
// large ints), and an unreadable height ranks lowest (0) rather
|
||||
// than collapsing to `None`/newest.
|
||||
let height = match node.get("block") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(b) => Some(
|
||||
b.get("height")
|
||||
.and_then(|h| {
|
||||
h.as_u64()
|
||||
.or_else(|| h.as_str().and_then(|s| s.parse::<u64>().ok()))
|
||||
})
|
||||
.unwrap_or(0),
|
||||
),
|
||||
};
|
||||
Some(TxRef { id, height })
|
||||
edge.get("node")
|
||||
.and_then(|n| n.get("id"))
|
||||
.and_then(|i| i.as_str())
|
||||
.map(String::from)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let page_info = txns.and_then(|t| t.get("pageInfo"));
|
||||
let has_next = page_info
|
||||
.and_then(|p| p.get("hasNextPage"))
|
||||
.and_then(|h| h.as_bool());
|
||||
let end_cursor = page_info
|
||||
.and_then(|p| p.get("endCursor"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(String::from);
|
||||
TxPage {
|
||||
refs,
|
||||
has_next,
|
||||
end_cursor,
|
||||
}
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Order a block height so that a not-yet-mined anchor (`None`) sorts above any
|
||||
/// mined one, and mined anchors order by ascending height. Higher rank == newer.
|
||||
fn height_rank(h: Option<u64>) -> (u8, u64) {
|
||||
match h {
|
||||
None => (1, 0), // pending anchor: newest
|
||||
Some(v) => (0, v), // mined: by block height
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge per-push manifests into a single `oid -> cid` map. A later seal of an
|
||||
/// oid overrides an earlier one, ranked by the composite key
|
||||
/// `(timestamp, block height, cid)`:
|
||||
/// - manifest `timestamp` (RFC3339, lexicographic) is primary — the unchanged
|
||||
/// latest-wins behavior;
|
||||
/// - Arweave block height breaks equal-timestamp ties by anchor recency (a
|
||||
/// pending anchor counts as newest), preserving the "newest wins" that
|
||||
/// single-page `HEIGHT_DESC` discovery used to provide by insertion order.
|
||||
/// The height is gateway-reported (chain-derived but not independently
|
||||
/// verified here), so it removes order-dependence and honest-node clock skew,
|
||||
/// not trust in the gateway — discovery already assumes an honest gateway;
|
||||
/// - cid is a final lexicographic tiebreak so two anchors in the *same* block
|
||||
/// still resolve deterministically (arbitrary, not recency-ordered, within a
|
||||
/// block), independent of discovery order.
|
||||
fn merge_manifests(
|
||||
manifests: Vec<(Manifest, Option<u64>)>,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
// oid -> (cid, timestamp, height_rank)
|
||||
let mut best: std::collections::HashMap<String, (String, String, (u8, u64))> =
|
||||
std::collections::HashMap::new();
|
||||
for (m, height) in manifests {
|
||||
let cand_rank = height_rank(height);
|
||||
/// Merge per-push manifests into a single `oid -> cid` map, latest-wins by the
|
||||
/// manifest `timestamp` (RFC3339, compared lexicographically; a later push that
|
||||
/// re-sealed a blob overrides the earlier entry).
|
||||
fn merge_manifests(manifests: Vec<Manifest>) -> std::collections::HashMap<String, String> {
|
||||
let mut best: std::collections::HashMap<String, (String, String)> =
|
||||
std::collections::HashMap::new(); // oid -> (cid, timestamp)
|
||||
for m in manifests {
|
||||
for b in m.blobs {
|
||||
let keep_incumbent = match best.get(&b.oid) {
|
||||
// Incumbent wins iff its composite key is >= the candidate's. The
|
||||
// `>=` (not `>`) keeps the incumbent only on a full tie, where the
|
||||
// entries are identical anyway. A strictly-greater candidate key
|
||||
// (newer timestamp, or equal timestamp + higher block, or equal
|
||||
// both + larger cid) replaces it.
|
||||
Some((cur_cid, cur_ts, cur_rank)) => {
|
||||
(cur_ts.as_str(), cur_rank, cur_cid.as_str())
|
||||
>= (m.timestamp.as_str(), &cand_rank, b.cid.as_str())
|
||||
match best.get(&b.oid) {
|
||||
Some((_, ts)) if ts.as_str() >= m.timestamp.as_str() => {}
|
||||
_ => {
|
||||
best.insert(b.oid, (b.cid, m.timestamp.clone()));
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !keep_incumbent {
|
||||
best.insert(b.oid, (b.cid, m.timestamp.clone(), cand_rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.into_iter()
|
||||
.map(|(oid, (cid, _, _))| (oid, cid))
|
||||
.collect()
|
||||
best.into_iter().map(|(oid, (cid, _))| (oid, cid)).collect()
|
||||
}
|
||||
|
||||
/// Option B3 fallback recovery, with no dependency on a gitlawb node API. Query
|
||||
@@ -541,111 +424,36 @@ async fn recover_from_arweave(
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
// 1. Discover manifest transactions via paginated Arweave GraphQL. Collect
|
||||
// (tx id, block height) refs across every page, deduping by id and bounding
|
||||
// the loop so a hostile or buggy gateway cannot grow the set, the page
|
||||
// count, or the downstream per-id fetch loop without limit. Best-effort: a
|
||||
// page-1 failure (gateway down/unconfigured) stays silent as before; a
|
||||
// failure or cap reached *after* discovery began warns, since the recovery
|
||||
// is then knowingly partial.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
const MAX_TX_IDS: usize = 10_000;
|
||||
let query = ARWEAVE_DISCOVERY_QUERY;
|
||||
let mut after: Option<String> = None;
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut refs: Vec<TxRef> = Vec::new();
|
||||
let mut prev_cursor: Option<String> = None;
|
||||
let mut page_num = 0usize;
|
||||
loop {
|
||||
if page_num >= MAX_PAGES {
|
||||
eprintln!(
|
||||
"warning: Arweave manifest discovery hit the {MAX_PAGES}-page cap; \
|
||||
some authorized files may not be recovered"
|
||||
);
|
||||
break;
|
||||
}
|
||||
page_num += 1;
|
||||
let gql_body =
|
||||
serde_json::json!({ "query": query, "variables": { "repo": slug, "cursor": after } });
|
||||
// Any gateway failure (non-2xx, send error, unparseable body) ends the loop.
|
||||
let response = match client
|
||||
.post(format!("{ag}/graphql"))
|
||||
.json(&gql_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r.json::<serde_json::Value>().await.ok(),
|
||||
_ => None,
|
||||
};
|
||||
let value = match response {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// A failure *after* discovery began means recovery is knowingly
|
||||
// partial, so warn. A page-1 failure is the benign unconfigured/
|
||||
// unreachable-gateway case and stays silent, exactly as before.
|
||||
if !refs.is_empty() {
|
||||
eprintln!(
|
||||
"warning: Arweave manifest discovery was interrupted by a gateway \
|
||||
error; some authorized files may not be recovered"
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
let page = parse_tx_page(&value);
|
||||
let full = page.refs.len() >= ARWEAVE_PAGE_SIZE;
|
||||
for r in page.refs {
|
||||
if seen.insert(r.id.clone()) {
|
||||
refs.push(r);
|
||||
}
|
||||
}
|
||||
// Cap checked at the page boundary so a partial page is never half-merged.
|
||||
if refs.len() >= MAX_TX_IDS {
|
||||
eprintln!(
|
||||
"warning: Arweave manifest discovery hit the {MAX_TX_IDS}-transaction cap; \
|
||||
some authorized files may not be recovered"
|
||||
);
|
||||
break;
|
||||
}
|
||||
match page.has_next {
|
||||
Some(true) => match page.end_cursor {
|
||||
// Advance only on a fresh cursor; a missing or repeating cursor is
|
||||
// a degenerate gateway response and must not loop forever.
|
||||
Some(c) if Some(&c) != prev_cursor.as_ref() => {
|
||||
prev_cursor = Some(c.clone());
|
||||
after = Some(c);
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
Some(false) => break,
|
||||
// No pageInfo: terminal. A *full* page without it may be a silently
|
||||
// truncated gateway, so flag that; a short page is genuinely the end.
|
||||
None => {
|
||||
if full {
|
||||
eprintln!(
|
||||
"warning: Arweave gateway returned a full page without pagination \
|
||||
metadata; discovery may be incomplete and some authorized files may \
|
||||
not be recovered"
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if refs.is_empty() {
|
||||
// 1. Discover manifest transaction ids via Arweave GraphQL.
|
||||
let query = r#"query($repo:String!){transactions(tags:[{name:"App-Name",values:["gitlawb"]},{name:"Schema",values:["gitlawb/encrypted-manifest/v1"]},{name:"Repo",values:[$repo]}],first:100){edges{node{id}}}}"#;
|
||||
let gql_body = serde_json::json!({ "query": query, "variables": { "repo": slug } });
|
||||
let resp = match client
|
||||
.post(format!("{ag}/graphql"))
|
||||
.json(&gql_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Ok(vec![]),
|
||||
};
|
||||
let gql: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let tx_ids = parse_tx_ids(&gql);
|
||||
if tx_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// 2. Fetch each manifest body, pair it with its anchor's block height, then
|
||||
// merge (latest-wins by timestamp, tie-broken by block height then cid).
|
||||
let mut manifests: Vec<(Manifest, Option<u64>)> = Vec::new();
|
||||
for r in refs {
|
||||
let m = match client.get(format!("{ag}/{}", r.id)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => resp,
|
||||
// 2. Fetch and parse each manifest body, then merge latest-wins per oid.
|
||||
let mut manifests = Vec::new();
|
||||
for tx in tx_ids {
|
||||
let m = match client.get(format!("{ag}/{tx}")).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => continue,
|
||||
};
|
||||
if let Ok(parsed) = m.json::<Manifest>().await {
|
||||
manifests.push((parsed, r.height));
|
||||
manifests.push(parsed);
|
||||
}
|
||||
}
|
||||
let oid_cid = merge_manifests(manifests);
|
||||
@@ -739,14 +547,7 @@ pub async fn run(args: CloneArgs) -> Result<()> {
|
||||
// fallback for any authorized blobs the node could not supply.
|
||||
let mut paths = recover_encrypted_blobs(&args.node, &owner, &name, &dest, &keypair)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
// A node-recovery error must not abort the clone (the Arweave
|
||||
// fallback still runs), but the strict /encrypted-blobs parse now
|
||||
// fails closed on schema drift, so surface it rather than letting
|
||||
// `.unwrap_or_default()` silently swallow it into "no paths".
|
||||
eprintln!("warning: encrypted-blobs recovery failed: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
.unwrap_or_default();
|
||||
let from_arweave = recover_from_arweave(
|
||||
&args.arweave_gateway,
|
||||
&args.ipfs_gateway,
|
||||
@@ -987,113 +788,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_blobs_response_is_schema_strict() {
|
||||
// Happy path: a well-formed body parses and exposes the oids.
|
||||
let ok: EncryptedBlobsResponse =
|
||||
serde_json::from_str(r#"{"blobs":[{"oid":"abc"}]}"#).unwrap();
|
||||
assert_eq!(ok.blobs.len(), 1);
|
||||
assert_eq!(ok.blobs[0].oid, "abc");
|
||||
|
||||
// Unknown server fields on an entry are tolerated (no deny_unknown_fields).
|
||||
let extra: EncryptedBlobsResponse =
|
||||
serde_json::from_str(r#"{"blobs":[{"oid":"abc","size":42}]}"#).unwrap();
|
||||
assert_eq!(extra.blobs[0].oid, "abc");
|
||||
|
||||
// Empty list is valid and distinct from a missing field.
|
||||
let empty: EncryptedBlobsResponse = serde_json::from_str(r#"{"blobs":[]}"#).unwrap();
|
||||
assert!(empty.blobs.is_empty());
|
||||
|
||||
// Schema drift is a hard error, not a silent "nothing to recover":
|
||||
// missing `blobs`, wrong-typed `blobs`, and an entry missing `oid`.
|
||||
assert!(serde_json::from_str::<EncryptedBlobsResponse>(r#"{"items":[]}"#).is_err());
|
||||
assert!(serde_json::from_str::<EncryptedBlobsResponse>(r#"{"blobs":"nope"}"#).is_err());
|
||||
assert!(
|
||||
serde_json::from_str::<EncryptedBlobsResponse>(r#"{"blobs":[{"size":1}]}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_extracts_node_ids_and_heights() {
|
||||
fn parse_tx_ids_extracts_node_ids() {
|
||||
let v: serde_json::Value = serde_json::from_str(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TX1","block":{"height":100}}},{"node":{"id":"TX2","block":null}}]}}}"#,
|
||||
r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}},{"node":{"id":"TX2"}}]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
let ids: Vec<&str> = page.refs.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["TX1", "TX2"]);
|
||||
assert_eq!(page.refs[0].height, Some(100));
|
||||
assert_eq!(page.refs[1].height, None); // null block -> pending -> None
|
||||
assert_eq!(page.has_next, Some(true));
|
||||
assert_eq!(page.end_cursor.as_deref(), Some("C1"));
|
||||
assert_eq!(parse_tx_ids(&v), vec!["TX1".to_string(), "TX2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_empty_on_no_edges() {
|
||||
fn parse_tx_ids_empty_on_no_edges() {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(r#"{"data":{"transactions":{"edges":[]}}}"#).unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
assert!(page.refs.is_empty());
|
||||
// No pageInfo present -> has_next None (the loop treats it as terminal).
|
||||
assert_eq!(page.has_next, None);
|
||||
assert_eq!(page.end_cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_missing_block_is_none() {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}}]}}}"#)
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
assert_eq!(page.refs[0].height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_height_parsing_is_robust() {
|
||||
// A gateway that serializes height as a JSON string (a habit to dodge
|
||||
// 53-bit float precision) must still parse to the real height, not
|
||||
// collapse to None and masquerade as a pending/newest anchor.
|
||||
let v: serde_json::Value = serde_json::from_str(
|
||||
r#"{"data":{"transactions":{"edges":[
|
||||
{"node":{"id":"TXSTR","block":{"height":"12345"}}},
|
||||
{"node":{"id":"TXBAD","block":{"height":"not-a-number"}}},
|
||||
{"node":{"id":"TXNOH","block":{}}}
|
||||
]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
// Numeric string parses to the real height.
|
||||
assert_eq!(page.refs[0].height, Some(12345));
|
||||
// A present block with an unreadable/absent height is mined-but-unknown:
|
||||
// it ranks lowest (Some(0)), NOT None/newest, so it can't steal a tie.
|
||||
assert_eq!(page.refs[1].height, Some(0));
|
||||
assert_eq!(page.refs[2].height, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_bounds_edges_per_page() {
|
||||
// A hostile gateway returning more than ARWEAVE_PAGE_SIZE edges on one
|
||||
// page must not defeat the MAX_TX_IDS budget: parse caps per-page refs so
|
||||
// the downstream fetch loop stays bounded.
|
||||
let edges: String = (0..ARWEAVE_PAGE_SIZE + 50)
|
||||
.map(|i| format!(r#"{{"node":{{"id":"TX{i}"}}}}"#))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let v: serde_json::Value = serde_json::from_str(&format!(
|
||||
r#"{{"data":{{"transactions":{{"edges":[{edges}]}}}}}}"#
|
||||
))
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
assert_eq!(page.refs.len(), ARWEAVE_PAGE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_first_matches_page_size() {
|
||||
// The `first:N` the discovery query requests must equal the per-page bound
|
||||
// enforced in parse_tx_page, or full-page detection and the cap drift.
|
||||
assert!(
|
||||
ARWEAVE_DISCOVERY_QUERY.contains(&format!("first:{ARWEAVE_PAGE_SIZE}")),
|
||||
"discovery query `first:` must equal ARWEAVE_PAGE_SIZE ({ARWEAVE_PAGE_SIZE})"
|
||||
);
|
||||
assert!(parse_tx_ids(&v).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1130,7 +837,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
};
|
||||
let merged = merge_manifests(vec![(older, Some(1)), (newer, Some(2))]);
|
||||
let merged = merge_manifests(vec![older, newer]);
|
||||
assert_eq!(merged.get("o1").map(String::as_str), Some("cidNEW"));
|
||||
assert_eq!(merged.get("o2").map(String::as_str), Some("cid2"));
|
||||
}
|
||||
@@ -1152,109 +859,10 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
// Newer first, older second: newer must still win.
|
||||
let merged = merge_manifests(vec![(newer, Some(2)), (older, Some(1))]);
|
||||
let merged = merge_manifests(vec![newer, older]);
|
||||
assert_eq!(merged.get("o1").map(String::as_str), Some("cidNEW"));
|
||||
}
|
||||
|
||||
// Helper: a single-blob manifest for the merge tie-break tests.
|
||||
fn manifest_with(ts: &str, oid: &str, cid: &str) -> Manifest {
|
||||
Manifest {
|
||||
timestamp: ts.to_string(),
|
||||
blobs: vec![ManifestBlob {
|
||||
oid: oid.to_string(),
|
||||
cid: cid.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_equal_timestamp_breaks_by_block_height() {
|
||||
// Same timestamp, different block heights: the higher block (newer
|
||||
// anchor) must win, regardless of input order.
|
||||
let lo = manifest_with("2026-06-11T00:00:00Z", "o1", "cidLO");
|
||||
let hi = manifest_with("2026-06-11T00:00:00Z", "o1", "cidHI");
|
||||
let a = merge_manifests(vec![
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidLO"),
|
||||
Some(10),
|
||||
),
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidHI"),
|
||||
Some(20),
|
||||
),
|
||||
]);
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidHI"));
|
||||
// Reversed order, same result.
|
||||
let b = merge_manifests(vec![(hi, Some(20)), (lo, Some(10))]);
|
||||
assert_eq!(b.get("o1").map(String::as_str), Some("cidHI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_pending_anchor_is_newest_on_tie() {
|
||||
// Same timestamp; one mined anchor, one pending (None height). The
|
||||
// pending one is the most recent and must win, order-independently.
|
||||
let mined = manifest_with("2026-06-11T00:00:00Z", "o1", "cidMINED");
|
||||
let pending = manifest_with("2026-06-11T00:00:00Z", "o1", "cidPENDING");
|
||||
let a = merge_manifests(vec![(mined, Some(99)), (pending, None)]);
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidPENDING"));
|
||||
let mined2 = manifest_with("2026-06-11T00:00:00Z", "o1", "cidMINED");
|
||||
let pending2 = manifest_with("2026-06-11T00:00:00Z", "o1", "cidPENDING");
|
||||
let b = merge_manifests(vec![(pending2, None), (mined2, Some(99))]);
|
||||
assert_eq!(b.get("o1").map(String::as_str), Some("cidPENDING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_same_block_breaks_by_cid_deterministically() {
|
||||
// Equal timestamp AND equal height: the cid tiebreak makes the result
|
||||
// deterministic and order-independent (lexicographically-larger cid wins).
|
||||
let a = merge_manifests(vec![
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidAAA"),
|
||||
Some(5),
|
||||
),
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidBBB"),
|
||||
Some(5),
|
||||
),
|
||||
]);
|
||||
let b = merge_manifests(vec![
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidBBB"),
|
||||
Some(5),
|
||||
),
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidAAA"),
|
||||
Some(5),
|
||||
),
|
||||
]);
|
||||
assert_eq!(a.get("o1"), b.get("o1"));
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidBBB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_newer_timestamp_wins_regardless_of_height() {
|
||||
// Timestamp is primary: a newer timestamp wins even with a lower block
|
||||
// height than the older-timestamp manifest.
|
||||
let older_hi_block = manifest_with("2026-06-10T00:00:00Z", "o1", "cidOLD");
|
||||
let newer_lo_block = manifest_with("2026-06-11T00:00:00Z", "o1", "cidNEW");
|
||||
let merged = merge_manifests(vec![
|
||||
(older_hi_block, Some(9999)),
|
||||
(newer_lo_block, Some(1)),
|
||||
]);
|
||||
assert_eq!(merged.get("o1").map(String::as_str), Some("cidNEW"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_empty_timestamp_loses_to_timestamped() {
|
||||
// A manifest with a missing/empty timestamp (serde default) sorts lowest
|
||||
// and loses to any timestamped manifest for the same oid. Pins the
|
||||
// empty-sorts-lowest precondition as a deliberate decision.
|
||||
let untimestamped = manifest_with("", "o1", "cidEMPTY");
|
||||
let timestamped = manifest_with("2026-06-11T00:00:00Z", "o1", "cidTS");
|
||||
let a = merge_manifests(vec![(untimestamped, Some(50)), (timestamped, Some(1))]);
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidTS"));
|
||||
}
|
||||
|
||||
/// Read-path end-to-end over a mocked Arweave + IPFS gateway: discover the
|
||||
/// manifest via GraphQL, fetch it, fetch the envelope, decrypt with the
|
||||
/// caller's key, and install the previously-withheld blob.
|
||||
@@ -1353,388 +961,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Discovery must follow cursor pagination: a blob whose manifest is anchored
|
||||
/// only on the SECOND page is still recovered. If the loop stopped at page 1
|
||||
/// (the old `first:100` behavior), this blob would be lost.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_paginates_to_later_page() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Page 1 (cursor null): hasNextPage=true, endCursor C1, an empty manifest
|
||||
// anchor that does NOT carry the withheld blob.
|
||||
let _gql_p1 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":null}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TXP1","block":{"height":10}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
// Page 2 (cursor "C1"): hasNextPage=false, the anchor with the blob.
|
||||
let _gql_p2 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":"C1"}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":false,"endCursor":"C2"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _tx_p1 = server
|
||||
.mock("GET", "/TXP1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"timestamp":"2026-06-10T00:00:00Z","blobs":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let _tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["secret/b.txt".to_string()],
|
||||
"blob anchored only on page 2 must be recovered via pagination"
|
||||
);
|
||||
}
|
||||
|
||||
/// Best-effort on mid-pagination failure: page 1 already yielded a usable
|
||||
/// anchor; page 2 fails. Recovery still installs page 1's blob (partial Ok),
|
||||
/// never aborts the clone.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_partial_on_midpagination_failure() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Page 1 (cursor null): the blob anchor, hasNextPage=true.
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let _gql_p1 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":null}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
// Page 2 (cursor "C1"): gateway error.
|
||||
let _gql_p2 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":"C1"}}"#.into(),
|
||||
))
|
||||
.with_status(500)
|
||||
.create_async()
|
||||
.await;
|
||||
let _tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.expect("mid-pagination failure must not abort recovery");
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["secret/b.txt".to_string()],
|
||||
"page-1 blob must still be recovered when page 2 fails"
|
||||
);
|
||||
}
|
||||
|
||||
/// A gateway that reports `hasNextPage=true` but never advances the cursor
|
||||
/// (returns the same `endCursor` every page) must not loop forever: the
|
||||
/// degenerate-cursor guard terminates the loop and recovery returns based on
|
||||
/// what was seen. The test completing at all proves no hang.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_terminates_on_nonadvancing_cursor() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Every GraphQL POST returns hasNextPage=true with the SAME endCursor.
|
||||
// The guard fires on the second page (cursor repeats prev_cursor) and
|
||||
// breaks, so discovery makes exactly two POSTs: page 1 (cursor null) records
|
||||
// STUCK, page 2 (cursor STUCK) sees the repeat and stops. We assert that
|
||||
// exact count below — a regression in the advance logic that loops extra
|
||||
// times in the happy path fails the expect(2). (MAX_PAGES is the hard
|
||||
// infinite-loop backstop independent of this guard.)
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let gql = server
|
||||
.mock("POST", "/graphql")
|
||||
.expect(2)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"STUCK"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.expect("non-advancing cursor must terminate, not hang or error");
|
||||
assert_eq!(paths, vec!["secret/b.txt".to_string()]);
|
||||
gql.assert_async().await; // exactly two discovery POSTs, then the guard breaks
|
||||
}
|
||||
|
||||
/// A tx id repeated across pages must be fetched only once: the cross-page
|
||||
/// dedup (R8) bounds the downstream per-id manifest fetch loop.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_dedups_tx_ids_across_pages() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Page 1 and page 2 both list TX1 (cursor advances); the dedup must fetch
|
||||
// the /TX1 manifest exactly once.
|
||||
let _gql_p1 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":null}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _gql_p2 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":"C1"}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":false,"endCursor":"C2"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.expect(1) // dedup: fetched once despite appearing on both pages
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(paths, vec!["secret/b.txt".to_string()]);
|
||||
tx1.assert_async().await; // exactly one /TX1 fetch
|
||||
}
|
||||
|
||||
/// A caller who is not a recipient cannot decrypt the envelope, so nothing is
|
||||
/// recovered even though the manifest and envelope are reachable.
|
||||
#[tokio::test]
|
||||
|
||||
+11
-276
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
@@ -1,798 +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
|
||||
}
|
||||
|
||||
/// Fixed warning when `GITLAWB_ICAPTCHA_INSECURE` is present but not an
|
||||
/// explicit truthy value. Does not include the raw env value (arbitrary
|
||||
/// deployment input — secrets / terminal controls).
|
||||
fn warn_icaptcha_insecure_non_truthy() {
|
||||
tracing::warn!(
|
||||
"GITLAWB_ICAPTCHA_INSECURE is set but not truthy (expected 1 or true); \
|
||||
loopback HTTP trust relaxation remains disabled"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a possibly-present `GITLAWB_ICAPTCHA_INSECURE` string.
|
||||
///
|
||||
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
|
||||
/// enables the loopback-HTTP trust relaxation. Presence alone is not enough:
|
||||
/// `=0`, `=false`, or an empty value leave it off. A non-empty non-truthy
|
||||
/// value emits [`warn_icaptcha_insecure_non_truthy`].
|
||||
fn icaptcha_insecure_from_str(raw: Option<&str>) -> bool {
|
||||
let Some(v) = raw else {
|
||||
return false;
|
||||
};
|
||||
let t = v.trim();
|
||||
if t.eq_ignore_ascii_case("1") || t.eq_ignore_ascii_case("true") {
|
||||
true
|
||||
} else {
|
||||
if !t.is_empty() {
|
||||
warn_icaptcha_insecure_non_truthy();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret `std::env::var("GITLAWB_ICAPTCHA_INSECURE")` without reading the
|
||||
/// environment again — used by production and by unit tests.
|
||||
///
|
||||
/// `NotPresent` → disabled. `NotUnicode` → disabled + the same fixed warning
|
||||
/// as a present non-truthy value (fail closed; do not treat as unset).
|
||||
fn icaptcha_insecure_from_var(result: Result<String, std::env::VarError>) -> bool {
|
||||
match result {
|
||||
Ok(v) => icaptcha_insecure_from_str(Some(&v)),
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
warn_icaptcha_insecure_non_truthy();
|
||||
false
|
||||
}
|
||||
Err(std::env::VarError::NotPresent) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `GITLAWB_ICAPTCHA_INSECURE` explicitly enables the loopback-HTTP
|
||||
/// trust relaxation used by integration tests.
|
||||
///
|
||||
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
|
||||
/// enables it. Presence alone is not enough: `=0`, `=false`, or an empty
|
||||
/// value leave the relaxation off. A non-empty non-truthy value (including a
|
||||
/// present non-Unicode value) emits a `tracing::warn!` (visible when the
|
||||
/// `icaptcha_client` target is at WARN+, e.g. `RUST_LOG=icaptcha_client=warn`).
|
||||
fn icaptcha_insecure_enabled() -> bool {
|
||||
icaptcha_insecure_from_var(std::env::var("GITLAWB_ICAPTCHA_INSECURE"))
|
||||
}
|
||||
|
||||
/// Whether `u` parses as a trusted URL, given a pre-parsed insecure flag.
|
||||
///
|
||||
/// Production trusts only `https`. When `insecure` is true the function also
|
||||
/// trusts `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry
|
||||
/// path can be exercised against a local mockito server.
|
||||
fn is_https_with(u: &str, insecure: bool) -> bool {
|
||||
let Ok(parsed) = reqwest::Url::parse(u) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() == "https" {
|
||||
return true;
|
||||
}
|
||||
if insecure
|
||||
&& 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) {
|
||||
resolve_solver_url_with(advertised, operator, icaptcha_insecure_enabled())
|
||||
}
|
||||
|
||||
fn resolve_solver_url_with(
|
||||
advertised: Option<&str>,
|
||||
operator: Option<&str>,
|
||||
insecure: bool,
|
||||
) -> (String, bool) {
|
||||
let operator = operator.filter(|u| is_https_with(u, insecure));
|
||||
let operator_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_with(a, insecure) && 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 the real-env reader test so it never races with other
|
||||
/// process-global env mutations (and recovers from a poisoned lock).
|
||||
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
prev: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn lock() -> MutexGuard<'static, ()> {
|
||||
ICAPTCHA_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_value(val: &str) -> Self {
|
||||
let lock = Self::lock();
|
||||
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", val);
|
||||
EnvGuard { _lock: lock, prev }
|
||||
}
|
||||
|
||||
fn unset() -> Self {
|
||||
let lock = Self::lock();
|
||||
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE");
|
||||
EnvGuard { _lock: lock, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #227 / #246: cover the production env read in `icaptcha_insecure_enabled`.
|
||||
/// Pure-helper tests alone leave mutations of this function green.
|
||||
#[test]
|
||||
fn icaptcha_insecure_enabled_reads_real_env() {
|
||||
{
|
||||
let _g = EnvGuard::unset();
|
||||
assert!(!icaptcha_insecure_enabled(), "unset must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("0");
|
||||
assert!(!icaptcha_insecure_enabled(), "=0 must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("false");
|
||||
assert!(!icaptcha_insecure_enabled(), "=false must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("");
|
||||
assert!(!icaptcha_insecure_enabled(), "empty must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("1");
|
||||
assert!(icaptcha_insecure_enabled(), "=1 must enable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("true");
|
||||
assert!(icaptcha_insecure_enabled(), "=true must enable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("TRUE");
|
||||
assert!(icaptcha_insecure_enabled(), "=TRUE must enable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value(" 1 ");
|
||||
assert!(icaptcha_insecure_enabled(), "\" 1 \" (trimmed) must enable");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cover the seam in `resolve_solver_url`, which is the only place the
|
||||
/// parsed flag reaches the trust decision it governs.
|
||||
///
|
||||
/// A loopback operator URL is load-bearing here: it is the only input whose
|
||||
/// verdict changes with the flag, so it is the only input that can detect
|
||||
/// the seam being hardcoded. Every other resolver test deliberately passes
|
||||
/// the flag explicitly (`resolve_solver_url_with(.., false)`) and therefore
|
||||
/// cannot see this. Without this test, replacing the seam's argument with
|
||||
/// `true` — restoring the #227 bug, loopback HTTP trusted unconditionally
|
||||
/// in release — leaves both this crate's suite and `gl`'s green.
|
||||
#[test]
|
||||
fn resolve_solver_url_honors_the_real_env_flag() {
|
||||
let op = "http://127.0.0.1:9000";
|
||||
|
||||
{
|
||||
// Flag on: the loopback operator origin is trusted and keeps the key.
|
||||
let _g = EnvGuard::with_value("1");
|
||||
let (url, key_trusted) = resolve_solver_url(None, Some(op));
|
||||
assert_eq!(url, op, "=1 must honor the loopback operator origin");
|
||||
assert!(key_trusted, "=1 must keep the key with the operator origin");
|
||||
}
|
||||
{
|
||||
// Flag absent: plaintext loopback is not a valid key destination.
|
||||
let _g = EnvGuard::unset();
|
||||
let (url, key_trusted) = resolve_solver_url(None, Some(op));
|
||||
assert_eq!(url, DEFAULT_URL, "unset must reject the loopback operator");
|
||||
assert!(!key_trusted, "unset must withhold the key");
|
||||
}
|
||||
{
|
||||
// The #227 footgun: presence is not enough, the value must be truthy.
|
||||
let _g = EnvGuard::with_value("0");
|
||||
let (url, key_trusted) = resolve_solver_url(None, Some(op));
|
||||
assert_eq!(url, DEFAULT_URL, "=0 must reject the loopback operator");
|
||||
assert!(!key_trusted, "=0 must withhold the key");
|
||||
}
|
||||
{
|
||||
// The advertised argument must reach the resolver, not just the
|
||||
// operator one. This is the only assertion driving a non-None
|
||||
// advertised URL through the env-reading wrapper, so without it a
|
||||
// wrapper that forwards `None` in its place stays green everywhere.
|
||||
let _g = EnvGuard::with_value("1");
|
||||
let advertised = "http://127.0.0.1:9000/v2";
|
||||
let (url, key_trusted) = resolve_solver_url(Some(advertised), Some(op));
|
||||
assert_eq!(
|
||||
url, advertised,
|
||||
"an advertised URL on the operator's own origin must be honored verbatim"
|
||||
);
|
||||
assert!(key_trusted, "the operator origin still owns the key");
|
||||
}
|
||||
}
|
||||
|
||||
/// #227: `=0` / `=false` / empty / unset must NOT enable loopback HTTP;
|
||||
/// only explicit truthy `1` / `true` may. Uses pure helpers, so it covers
|
||||
/// the parsing rules without touching process-global environment; the
|
||||
/// env-reading path itself is covered by the two tests above, which hold
|
||||
/// `ICAPTCHA_ENV_LOCK` for the duration.
|
||||
#[test]
|
||||
fn insecure_env_only_truthy_enables_loopback_http() {
|
||||
assert!(
|
||||
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("0"))),
|
||||
"=0 must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(Some("0")));
|
||||
|
||||
assert!(
|
||||
!is_https_with(
|
||||
"http://127.0.0.1",
|
||||
icaptcha_insecure_from_str(Some("false"))
|
||||
),
|
||||
"=false must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(Some("false")));
|
||||
|
||||
assert!(
|
||||
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(""))),
|
||||
"empty value must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(Some("")));
|
||||
|
||||
assert!(
|
||||
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(None)),
|
||||
"unset must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(None));
|
||||
assert!(!icaptcha_insecure_from_var(Err(
|
||||
std::env::VarError::NotPresent
|
||||
)));
|
||||
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("1"))),
|
||||
"=1 must enable loopback HTTP"
|
||||
);
|
||||
assert!(is_https_with(
|
||||
"http://localhost",
|
||||
icaptcha_insecure_from_str(Some("1"))
|
||||
));
|
||||
assert!(icaptcha_insecure_from_str(Some("1")));
|
||||
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("true"))),
|
||||
"=true must enable loopback HTTP"
|
||||
);
|
||||
assert!(icaptcha_insecure_from_str(Some("true")));
|
||||
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("TRUE"))),
|
||||
"=TRUE (case-insensitive) must enable loopback HTTP"
|
||||
);
|
||||
|
||||
// Trim is load-bearing: whitespace around a truthy token must still enable.
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(" 1 "))),
|
||||
"\" 1 \" (trimmed) must enable loopback HTTP"
|
||||
);
|
||||
assert!(icaptcha_insecure_from_str(Some(" 1 ")));
|
||||
|
||||
// Present non-Unicode: fail closed + same fixed warning path as non-truthy.
|
||||
assert!(!icaptcha_insecure_from_var(Err(
|
||||
std::env::VarError::NotUnicode(OsString::from("x"))
|
||||
)));
|
||||
|
||||
// https remains trusted regardless of the insecure flag.
|
||||
assert!(is_https_with("https://icaptcha.gitlawb.com", false));
|
||||
assert!(is_https_with("https://icaptcha.gitlawb.com", true));
|
||||
}
|
||||
|
||||
// ── P1: hostile error bodies must not reach the terminal raw ──────────
|
||||
|
||||
#[test]
|
||||
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_with(Some("http://evil.example/x"), None, false);
|
||||
assert_eq!(url, DEFAULT_URL);
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_https_url_with_non_allowlisted_host() {
|
||||
let (url, key_trusted) = resolve_solver_url_with(Some("https://evil.example"), None, false);
|
||||
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_with(Some("https://icaptcha.gitlawb.com/v2"), None, false);
|
||||
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_with(Some("https://icap.mynode.example/v1"), Some(op), false);
|
||||
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_with(Some("https://evil.example"), Some(op), false);
|
||||
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_with(None, Some(op), false),
|
||||
(op.to_string(), true)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_solver_url_with(None, None, false),
|
||||
(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_with(None, Some("http://icap.mynode.example"), false);
|
||||
assert_eq!(url, DEFAULT_URL);
|
||||
assert!(!key_trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_advertised_url_on_different_port() {
|
||||
// With insecure mode enabled, 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 op = "http://localhost:3000";
|
||||
let (url, key_trusted) =
|
||||
resolve_solver_url_with(Some("http://localhost:9000"), Some(op), true);
|
||||
assert_eq!(
|
||||
url, op,
|
||||
"must fall back to operator origin, not use different port"
|
||||
);
|
||||
assert!(key_trusted, "key stays trusted with operator origin");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -19,14 +19,6 @@ services:
|
||||
|
||||
node:
|
||||
build: .
|
||||
# Reap reparented orphan processes so git's grandchildren (e.g. pack-objects
|
||||
# left behind when a served clone disconnects) can't accumulate as zombies
|
||||
# and exhaust the PID table (#53). The image also runs under tini, so this is
|
||||
# belt-and-suspenders for the quickstart.
|
||||
init: true
|
||||
# Cap the process table so a regression fails loudly instead of taking the
|
||||
# host down. Sits well above normal concurrent git fan-out.
|
||||
pids_limit: 1024
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -141,34 +141,6 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
|
||||
|
||||
---
|
||||
|
||||
## Hardening: owner-only push
|
||||
|
||||
By default the node authenticates every `git-receive-pack` push (a valid RFC 9421
|
||||
`did:key` signature) but does **not** check that the pusher owns the repo, except
|
||||
on branches that are explicitly protected. Because `did:key` is self-certifying,
|
||||
any party can generate a key, derive its DID, sign, and push to an unprotected
|
||||
branch — authentication is not authorization.
|
||||
|
||||
To require the authenticated pusher to be the repo owner on **every** branch, set:
|
||||
|
||||
```bash
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
```
|
||||
|
||||
- **Default `false`** — preserves current behavior so live nodes are unaffected by
|
||||
an upgrade. Turn it on once you're ready for owner-only writes.
|
||||
- **When `true`** — a push whose authenticated DID is not the repo owner is
|
||||
rejected (HTTP 403) before any ref update is applied. The owner is matched in
|
||||
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
- **Caution: this blocks every non-owner pusher, including your own delegated and
|
||||
CI agents.** Push authorization is owner-only today — a UCAN `git/push`
|
||||
capability is verified but not yet honored for authorization, so delegated keys
|
||||
cannot push while this is on. Don't enable it until every identity that pushes
|
||||
to your repos is the owner, or you'll lock out your own automation. Scoped
|
||||
collaborator / UCAN-delegated push rights are a planned follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Operational checklist
|
||||
|
||||
| Concern | Recommendation |
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user