mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a46e8cbb7 | ||
|
|
e4f5e87e0b | ||
|
|
38e654a289 | ||
|
|
4f814459cc | ||
|
|
d45318281c |
No files matched your search
-210
@@ -21,26 +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. Each concurrent write pins one
|
||||
# connection for its whole duration (the connection-affine advisory lock), so the
|
||||
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
|
||||
# headroom — keep this comfortably above that (default 48 for pushes 32).
|
||||
GITLAWB_DB_MAX_CONNECTIONS=48
|
||||
# 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=
|
||||
@@ -92,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=
|
||||
@@ -110,190 +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. Also bounds both info/refs
|
||||
# advertisements, the withheld-blob pack build, and the push-side candidate
|
||||
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
|
||||
# On the path-scoped upload-pack path the withheld-blob classification walk and
|
||||
# the pack serve share ONE deadline, so this value bounds their COMBINED duration
|
||||
# rather than giving each stage a full budget: a walk that consumes it leaves the
|
||||
# serve nothing and the clone gets a 504. Serving large path-scoped repos may
|
||||
# need a higher value here than when each stage was budgeted separately.
|
||||
# Must be 1..=3153600000 (100 years): the node derives deadlines from this value,
|
||||
# and a larger one cannot be represented. Default 600.
|
||||
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
|
||||
|
||||
# Max seconds the storage-ACQUISITION phase of a served git op may run before the
|
||||
# request is shed with a 503, separate from the git-run timeout above. A
|
||||
# concurrency permit is taken before this phase and GITLAWB_GIT_SERVICE_TIMEOUT_SECS
|
||||
# only starts once git spawns, so without this a stalled backend (a hung Tigris
|
||||
# HEAD/GET, or a hung pg advisory-lock iteration on push) pins the permit and drains
|
||||
# the pool until every later request 503s. On expiry the permit is released
|
||||
# (fail-closed). Kept separate because acquisition and git execution are distinct
|
||||
# cost centers. Must be positive; set very large to effectively disable. Default 30.
|
||||
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS=30
|
||||
|
||||
# Max concurrent git READ ops (upload-pack + the upload-pack info/refs
|
||||
# advertisement) served at once, a global pool separate from the push pool below.
|
||||
# The anon receive-pack info/refs advertisement has its OWN pool (see below), not
|
||||
# this one. Over-cap sheds a clean 503 + Retry-After. Anonymous reads draw from
|
||||
# here, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER (below) so one
|
||||
# caller cannot monopolize the pool. Default 128.
|
||||
GITLAWB_MAX_CONCURRENT_GIT_OPS=128
|
||||
|
||||
# Max concurrent git-receive-pack (push) POST operations, in a pool separate from
|
||||
# the read pool (GITLAWB_MAX_CONCURRENT_GIT_OPS) so anonymous reads cannot shed an
|
||||
# authenticated push at admission. The anon receive-pack info/refs advertisement
|
||||
# runs in a SEPARATE pool of the same size (disjoint from this one), so an
|
||||
# advertisement flood cannot shed a push either. Over-cap sheds a 503 +
|
||||
# Retry-After. Default 32.
|
||||
GITLAWB_MAX_CONCURRENT_GIT_PUSHES=32
|
||||
|
||||
# Max pushes PARKED at once waiting for the per-repo write lease of the SAME repo.
|
||||
# A parked push holds its already-buffered pack body in memory, so this bounds that
|
||||
# memory for a hot repo. Past the cap the newest push sheds a clean 503 +
|
||||
# Retry-After ("repo is busy with another push") instead of joining the queue; a
|
||||
# push to a DIFFERENT repo is never affected. The lease holder is not counted.
|
||||
# Raising GITLAWB_MAX_CONCURRENT_GIT_PUSHES does not raise this; set it explicitly.
|
||||
# Default 8.
|
||||
GITLAWB_REPO_LEASE_MAX_WAITERS=8
|
||||
|
||||
# Max concurrent post-push pin loops (IPFS + Pinata pin_new_objects) across all
|
||||
# repos. The per-repo encrypt-task set caps the pin-task COUNT; this caps how many
|
||||
# of those loops RUN CONCURRENTLY, and so how much MB-scale object-id list memory is
|
||||
# held WHILE BEING PINNED. It does not bound retained memory in general: on the local
|
||||
# IPFS path the caller builds its list before this pool is entered, so a loop parked
|
||||
# waiting for a permit still holds its full list, and how many park is capped only
|
||||
# per repo. Do not size memory from this knob alone. A loop DEFERS (waits) when the
|
||||
# pool is full, never drops a pin. Default 8.
|
||||
GITLAWB_MAX_CONCURRENT_PIN_TASKS=8
|
||||
|
||||
# Max concurrent read ops (upload-pack + the upload-pack info/refs advertisement)
|
||||
# a single caller may hold, so one caller cannot monopolize the read pool. Keyed
|
||||
# on the resolved SOURCE IP, never the DID: a signature does not move a caller off
|
||||
# this cap. The source-IP key is only as granular as GITLAWB_TRUSTED_PROXY below:
|
||||
# left unset, a node behind an edge/NAT keys all callers on the edge IP and this
|
||||
# collapses to one global cap. Set GITLAWB_TRUSTED_PROXY for per-client keying; a
|
||||
# high-fanout caller (CI behind one NAT) then needs the operator to raise this.
|
||||
# Default 16.
|
||||
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
|
||||
|
||||
# Two further per-source concurrency caps exist on the PUSH side but have NO
|
||||
# environment variable of their own: both are DERIVED as
|
||||
# GITLAWB_MAX_CONCURRENT_GIT_PUSHES / 8, with a floor of 1. They are not settable
|
||||
# independently, so raising the push pool raises both proportionally, and the
|
||||
# default push pool of 32 gives each a cap of 4.
|
||||
# * The anon receive-pack info/refs advertisement cap: one source IP may hold at
|
||||
# most this many slots in the dedicated advert pool, so saturating that pool
|
||||
# takes ~8 distinct source IPs.
|
||||
# * The authenticated receive-pack POST cap: one source IP may hold at most this
|
||||
# many WRITE-pool slots. This one is load-bearing for push availability, since
|
||||
# it is acquired before the global write permit: without it, one host minting
|
||||
# disposable did:key identities could open enough slow pushes to monopolize the
|
||||
# write pool and 503 every other source (owner enforcement defaults off, and
|
||||
# the push rate limiter caps arrival rate, not in-flight concurrency).
|
||||
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
|
||||
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
|
||||
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
|
||||
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
|
||||
# so it is bounded to keep a permissionless caller from fanning out unbounded
|
||||
# concurrent walks and exhausting blocking-pool threads + PIDs.
|
||||
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
|
||||
# the served-git pools). Over-cap sheds a 503. Default 32.
|
||||
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
|
||||
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
|
||||
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
|
||||
# Default 4.
|
||||
GITLAWB_IPFS_WALK_PER_SOURCE=4
|
||||
# Max legacy (NULL-provenance) repos probed per single /ipfs request, bounding the
|
||||
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
|
||||
# truncated scan sheds a retryable 503, never a false 404. Default 256.
|
||||
GITLAWB_IPFS_MAX_LEGACY_PROBES=256
|
||||
# Max EXPENSIVE path-scope visibility walks per single /ipfs request (only a
|
||||
# blob in a path-scoped repo costs a full-history walk). Over-cap repos are
|
||||
# skipped without a verdict and the scan continues; if the object is then found
|
||||
# nowhere the request sheds a retryable 503 instead of a false 404. The effective
|
||||
# cap is the tighter of this value and the node's internal per-request ceiling of
|
||||
# 17, so values above 17 have no effect; lower values do tighten it. Default 64.
|
||||
GITLAWB_IPFS_MAX_REPOS_WALKED=64
|
||||
# Ceiling on repos one /ipfs request may VISIT past the visibility gate. Each
|
||||
# visit costs a repo acquire — on a Tigris cache miss a full archive download,
|
||||
# so this is also the worst-case object-store fetch count per request — plus a
|
||||
# cat-file probe. On exhaustion the scan stops and sheds a retryable 503.
|
||||
# Default 1024.
|
||||
GITLAWB_IPFS_MAX_REPO_VISITS=1024
|
||||
# Absolute wall-clock budget for one admitted /ipfs request's acquire+walk
|
||||
# lifetime (all stages of the whole scan). Per-stage clamps bound the acquire
|
||||
# and walk stages to the remaining budget, and no stage starts once it is
|
||||
# exhausted (the scan then sheds a retryable 503). The object-type probe and
|
||||
# content-read cat-file subprocesses are budget-checked before starting AND each
|
||||
# run under their own deadline (the lesser of GITLAWB_GIT_SERVICE_TIMEOUT_SECS and
|
||||
# the remaining budget), reaped via process-group teardown, so a hung cat-file
|
||||
# cannot hold the request's walk slot past it. One hang path is still unbounded:
|
||||
# the probe's object-store readability check is a plain filesystem sweep with
|
||||
# nothing to reap, so a wedged filesystem can hold the slot past the deadline.
|
||||
# Must be 1..=3153600000 (100 years): the node derives an Instant deadline from
|
||||
# this value, and a larger one cannot be represented. Default 600.
|
||||
GITLAWB_IPFS_REQUEST_BUDGET_SECS=600
|
||||
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
|
||||
# from the concurrency caps above). 0 disables. Default 600.
|
||||
GITLAWB_IPFS_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,17 +2,6 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Run in the merge queue so a PR is re-validated against the true merged
|
||||
# result before it lands. Guards against semantic merge races where two
|
||||
# individually-green PRs break main in combination (#113 + #145 → #229).
|
||||
merge_group:
|
||||
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
|
||||
# and the lock-sync job), and GitHub's recursion guard means those pushes
|
||||
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
|
||||
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
|
||||
# the release branch to attach check runs to its head. Read-only workflow
|
||||
# (contents: read), so the dispatch surface adds no privilege.
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -53,20 +42,7 @@ jobs:
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
|
||||
release-tag-validation:
|
||||
name: release tag validation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test release tag resolver
|
||||
run: scripts/test-resolve-release-tag.sh
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.toolchain }})
|
||||
@@ -78,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
|
||||
@@ -112,9 +71,7 @@ jobs:
|
||||
key: ${{ matrix.toolchain }}
|
||||
|
||||
- name: cargo test
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
|
||||
run: cargo test --locked --workspace
|
||||
run: cargo test --workspace
|
||||
|
||||
build-release:
|
||||
name: build --release
|
||||
@@ -137,7 +94,7 @@ jobs:
|
||||
key: release
|
||||
|
||||
- name: cargo build --release
|
||||
run: cargo build --locked --release --workspace
|
||||
run: cargo build --release --workspace
|
||||
|
||||
audit:
|
||||
name: cargo audit
|
||||
@@ -157,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
|
||||
@@ -219,7 +156,7 @@ jobs:
|
||||
key: msrv
|
||||
|
||||
- name: cargo check on MSRV
|
||||
run: cargo check --locked --workspace --all-targets
|
||||
run: cargo check --workspace --all-targets
|
||||
|
||||
docker-build:
|
||||
name: Docker build smoke test
|
||||
@@ -236,95 +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 --locked -p gl -p git-remote-gitlawb
|
||||
|
||||
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
|
||||
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
|
||||
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
|
||||
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
|
||||
# dependency reds CI whether or not anyone thought to ban it, which a denylist
|
||||
# cannot do. Regen instructions live in the allowlist header.
|
||||
core-deps-purity:
|
||||
name: gitlawb-core dependency purity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: core-deps-purity
|
||||
|
||||
- name: Check gitlawb-core dependency allowlist
|
||||
run: bash scripts/check-gitlawb-core-deps.sh
|
||||
|
||||
# release-please only rewrites a crate's version when the line carries the
|
||||
# `# x-release-please-version` annotation AND the manifest is listed under
|
||||
# extra-files in release-please-config.json. Miss either half and the crate
|
||||
# is skipped silently at release time, freezing at its creation version:
|
||||
# icaptcha-client was added as a workspace member after the config was last
|
||||
# written and sat at 0.4.0 through four releases before anyone noticed.
|
||||
# Exhaustive-by-construction, like the allowlist gate above: the required set
|
||||
# is derived from `cargo metadata`, so a newly added crate reds CI whether or
|
||||
# not its author knew this gate existed.
|
||||
release-versioning:
|
||||
name: release versioning coverage
|
||||
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: release-versioning
|
||||
|
||||
- name: Check every workspace crate is release-managed
|
||||
run: bash scripts/check-release-versioning.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,17 +64,11 @@ 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.
|
||||
// Async tests use a namespaced attribute (#[tokio::test], #[async_std::test],
|
||||
// possibly with args like #[tokio::test(flavor = ...)]) rather than the bare
|
||||
// #[test]. Match only on added lines, with the attribute at the start of the
|
||||
// line after horizontal whitespace, covering bare, cfg(test), namespaced,
|
||||
// optional leading ::, and raw-identifier (r#test) forms. No head-file fetch
|
||||
// or lexer: needs-tests is advisory, not a merge gate.
|
||||
// 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 &&
|
||||
/^\+[ \t]*#\[[ \t]*(?:cfg[ \t]*\([ \t]*test[ \t]*\)|(?:::[ \t]*)?(?:[\w-]+[ \t]*::[ \t]*)*(?:r#)?test\b)/m.test(f.patch));
|
||||
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
|
||||
const touchedTests =
|
||||
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
|
||||
if (changedRust && !touchedTests) want.add("needs-tests");
|
||||
@@ -114,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
-731
@@ -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:
|
||||
@@ -41,204 +21,29 @@ jobs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
pr: ${{ steps.release.outputs.pr }}
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
||||
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.3.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# The release PR bumps the workspace crate versions in the Cargo.tomls but
|
||||
# release-please cannot update Cargo.lock, so without this job every release
|
||||
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
|
||||
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
|
||||
# the release branch so the tagged tree is internally consistent.
|
||||
#
|
||||
# Runs on every push-triggered release run, not only when release-please
|
||||
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
|
||||
# the release PR, so gating on it would leave an already-open release PR
|
||||
# (opened before this job existed, or untouched by a non-releasable push)
|
||||
# permanently without the sync and without check runs. The resolve step
|
||||
# discovers any open release-please PR itself and no-ops when there is none.
|
||||
sync-release-lock:
|
||||
name: Sync Cargo.lock on the release PR
|
||||
needs: release-please
|
||||
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
# actions: write lets the final step dispatch pr-checks on the release
|
||||
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
|
||||
# scope it to one workflow); acceptable here because reaching this job's
|
||||
# steps already requires write to main. Needed because every push to the
|
||||
# release branch (release-please's and the lock sync below) is made with
|
||||
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
|
||||
# dispatch the release PR's head carries no check runs and the --locked
|
||||
# gate never validates it.
|
||||
actions: write
|
||||
steps:
|
||||
# Prefer the fresh `pr` output when this run just created/updated the
|
||||
# release PR; otherwise fall back to querying for an open release-please
|
||||
# branch (prefix match: the branch carries `--components--` suffixes).
|
||||
- name: Resolve the open release PR branch
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
|
||||
run: |
|
||||
branch="$BRANCH_FROM_OUTPUT"
|
||||
if [ -z "$branch" ]; then
|
||||
# The branch name alone is not a trust signal. `gh pr list` includes
|
||||
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
|
||||
# prefix match by itself lets any fork nominate the branch this job
|
||||
# checks out, commits to, and dispatches checks on. Require the head
|
||||
# repo to be THIS repository and the author to be the release-please
|
||||
# app, so neither a fork nor a same-repo human branch named
|
||||
# `release-please--*` can be selected. Then take the highest PR
|
||||
# number: `[0]` depended on gh's default ordering, which is not a
|
||||
# documented guarantee, and picking a stable one keeps a second
|
||||
# (component) release PR from making the choice flap between runs.
|
||||
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--json headRefName,headRepositoryOwner,number,author \
|
||||
--jq '[ .[]
|
||||
| select(.headRepositoryOwner.login == env.OWNER)
|
||||
| select(.author.login == "app/github-actions")
|
||||
| select(.headRefName | startswith("release-please--"))
|
||||
] | max_by(.number).headRefName // empty')
|
||||
fi
|
||||
if [ -z "$branch" ]; then
|
||||
echo "no open release PR; nothing to sync"
|
||||
fi
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release branch
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.branch }}
|
||||
|
||||
# Resolve on a declared toolchain instead of whatever Rust the runner
|
||||
# image happens to ship, so the lock this job writes comes off the same
|
||||
# stable channel the other cargo jobs use. Not a claim of identical
|
||||
# resolution: `stable` still moves between runs, and the MSRV gate
|
||||
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
|
||||
# runner image as an undeclared input.
|
||||
- name: Install Rust toolchain
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Sync workspace crate versions into Cargo.lock
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
run: cargo update --workspace
|
||||
|
||||
- name: Commit and push when the lock changed
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
run: |
|
||||
if git diff --quiet Cargo.lock; then
|
||||
echo "Cargo.lock already in sync"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Cargo.lock
|
||||
git commit -m "chore: sync Cargo.lock with the release version bump"
|
||||
git push
|
||||
|
||||
# Every push to the release branch is made with GITHUB_TOKEN (release-
|
||||
# please's own pushes and the lock sync above), and events created by
|
||||
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
|
||||
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
|
||||
# branch to attach check runs to its current head. Unconditional (also on
|
||||
# the already-in-sync path) because the branch's ORIGINAL head has the
|
||||
# same no-runs problem. Loud on failure by design: a silent fallback
|
||||
# would recreate the checkless-head problem, and a job rerun is safe
|
||||
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
|
||||
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
|
||||
# is the sync commit. Merge-queue note: this validates the branch tip;
|
||||
# the enforced merge queue's merge_group run still re-validates the true
|
||||
# merged result before landing.
|
||||
- name: Dispatch PR checks on the release branch
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ steps.resolve.outputs.branch }}
|
||||
run: |
|
||||
# Every dispatch failure fails the job, including the 422 a release
|
||||
# branch cut before pr-checks gained its workflow_dispatch trigger
|
||||
# returns ("does not have a workflow_dispatch trigger", reported
|
||||
# against the dispatched ref's copy of the file). An earlier revision
|
||||
# tolerated exactly that 422 on the theory that release-please would
|
||||
# rewrite the branch and heal it, but a rewrite only happens when a
|
||||
# later releasable commit lands: an open release PR nobody touches is
|
||||
# never rewritten. Tolerating it therefore left the lock-sync commit
|
||||
# this job had just pushed sitting on the release head with no
|
||||
# --locked validation, which is the precise state this workflow exists
|
||||
# to prevent. The tolerance is also no longer needed: once this lands,
|
||||
# every release branch is cut from a main that already carries the
|
||||
# trigger, so the only branch that could 422 is one open at merge time
|
||||
# (there is none), and a job rerun after a manual rebase is safe (the
|
||||
# sync no-ops, the dispatch retries).
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
|
||||
-f "ref=$BRANCH"
|
||||
|
||||
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
|
||||
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
|
||||
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
|
||||
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
|
||||
# workflow concurrency group, blocked every queued release run behind it.
|
||||
docker:
|
||||
name: Build & Push Docker Image (${{ matrix.arch }})
|
||||
name: Build & Push Docker Image
|
||||
needs: release-please
|
||||
# Runs on a fresh release, or on manual dispatch for an existing tag
|
||||
# (release-please is skipped on dispatch, hence !cancelled()).
|
||||
if: >-
|
||||
${{ !cancelled() && (
|
||||
needs.release-please.outputs.release_created == 'true' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
|
||||
) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
- arch: arm64
|
||||
os: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing — 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
|
||||
@@ -250,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:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
|
||||
- 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:
|
||||
@@ -381,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:
|
||||
@@ -392,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
|
||||
@@ -417,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 --locked --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" || {
|
||||
@@ -470,400 +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: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
|
||||
- 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.1"
|
||||
".": "0.3.8"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Contributor guide for coding agents
|
||||
|
||||
This file carries the rules a patch author cannot recover from the code alone, for humans and coding agents alike. For build, test, and PR basics, read `CONTRIBUTING.md` first.
|
||||
|
||||
## Authorization invariants (the rules PRs most often break)
|
||||
|
||||
Every repo-scoped endpoint must bind the caller to an authorization decision before serving or mutating anything. The `known_ungated` allowlist in `authz_guard::every_repo_scoped_handler_is_gated` is empty on main: `list_webhooks`, `list_replicas`, and `list_protected_branches` are gated (`list_webhooks` with read-visibility then owner; the other two with `authorize_repo_read`). Do not re-open those surfaces as exceptions.
|
||||
|
||||
- Owner-only mutations (visibility, webhooks, protected branches, merges) are gated against the repo owner. Three gate forms are in use and all are recognized: webhooks and merges call `require_repo_owner` (`crates/gitlawb-node/src/api/mod.rs`), visibility uses its module-local `require_owner`, and protected branches compare inline with `did_matches`. Use the form the surrounding module already uses.
|
||||
- Not every write is owner-only, and that is by design: `star_repo`/`unstar_repo` bind to the signer (they still require repo read access), `register_replica`/`unregister_replica` bind to the replica's own DID, the bounty actions (claim, submit, approve, cancel, dispute) have their own multi-party rules, and closing a PR or issue is owner-or-author. Do not add owner gates to these.
|
||||
- Content-serving reads (blobs, trees, raw files) gate through `authorize_repo_read` with the specific path being served, so a withheld subtree is denied even on an otherwise-public repo; repo-level reads (listings) pass `"/"` by the helper's own contract. The source scans below cannot check this argument's value, so review will.
|
||||
- A route that reaches repo data through a global id, with no repo in its path, or through an extractor shape the scans do not recognize still needs the same gate; the scans cannot see those.
|
||||
- Read-surface denials must not reveal existence: a caller who may not read a repo (or a withheld subtree) gets the same 404 as a missing repo, never a 403. (Owner-gated mutations currently return 403 after the repo lookup; do not widen that shape to read paths.)
|
||||
- Two tests in module `authz_guard` (`crates/gitlawb-node/src/api/mod.rs`) scan handler sources and fail on a repo-scoped handler without a recognized gate: `every_in_scope_mutation_has_its_gate` and `every_repo_scoped_handler_is_gated`. If they fail on your handler, add the gate. The second test's `known_ungated` allowlist is empty; never add an entry to it without an open issue and review.
|
||||
|
||||
## Adding or changing routes
|
||||
|
||||
- A new gated handler needs tests proving its route-specific gate fires: exercise an unauthorized authenticated caller and an anonymous caller where applicable, assert the exact denial status each way, and assert the body leaks nothing. The non-owner tests in `crates/gitlawb-node/src/test_support.rs` show the shape for owner-gated routes.
|
||||
- A new mutation handler also gets a row in `every_in_scope_mutation_has_its_gate` naming its gate type; the per-handler row is the point of that test.
|
||||
- A table-driven deny suite covering every deny-bearing route is in review (#194, #195). If it is present in your checkout, add a probe row there for any new deny-bearing route.
|
||||
|
||||
## Verifying signatures and certificates
|
||||
|
||||
- Derive the verifying key from something outside the artifact you are checking: the node's own DID, a pinned or configured value, a peers lookup. A key read from a field of the object being verified (`Did::from_str(&cert.node_did)`) only proves the artifact is self-consistent, and identities here are permissionless, so anyone can mint a keypair, name it in the artifact, self-sign, and be told it is valid. A mismatch against that anchor has to fail the result, not log a warning.
|
||||
- A handler whose correct answer is "valid" needs a positive case driven by a trusted, independently anchored artifact, and a rejection case for a well-formed, fully populated forged artifact. Invalid-only suites miss an implementation that rejects everything; a forged artifact that still returns valid is the regression that matters.
|
||||
- The fields a signature covers are a versioned format. Adding one invalidates every artifact already signed, so give the payload a version, keep a path that verifies the older form, and add a test that verifies an artifact signed before your change.
|
||||
|
||||
## Removing a serving path
|
||||
|
||||
When a fix removes a path that used to serve content, the same PR must add a test asserting the removed path now denies. The deletion is what turns that test green; without it the path can quietly come back.
|
||||
|
||||
## Database migrations
|
||||
|
||||
Schema changes are code-defined in `MIGRATIONS` (`crates/gitlawb-node/src/db/mod.rs`). Always append a new versioned entry; never edit an entry that has merged (operators deploy from main). Test runs build the schema from scratch, so editing an applied version stays green in CI while breaking every existing deployment.
|
||||
|
||||
## Client behavior (`gl`, `git-remote-gitlawb`)
|
||||
|
||||
When a node denies a request, surface the denial to the user. Never render a denial as an empty list or a silent success; that turns an authorization boundary into invisible data loss.
|
||||
|
||||
## Toolchain and CI reality
|
||||
|
||||
- MSRV is Rust 1.91 (`rust-version` in the workspace `Cargo.toml`). The MSRV CI job runs `cargo check --workspace --all-targets` on exactly 1.91, so check that an API you rely on is stable there; the full test suite runs on stable.
|
||||
- The PR workflow runs these jobs on every PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
|
||||
- Conventional commit titles (`feat:`, `fix:`, `docs:`, ...) are required by convention and drive releases, but no CI job checks them; a bad title fails review, not the pipeline.
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
|
||||
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
|
||||
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
|
||||
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
|
||||
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
|
||||
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
|
||||
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
|
||||
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
|
||||
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
|
||||
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
|
||||
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
|
||||
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
|
||||
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
|
||||
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
|
||||
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
|
||||
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
|
||||
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
|
||||
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
|
||||
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
|
||||
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
|
||||
|
||||
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
|
||||
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
|
||||
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
|
||||
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
|
||||
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
|
||||
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
|
||||
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
|
||||
|
||||
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
|
||||
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
|
||||
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
|
||||
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
|
||||
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
|
||||
|
||||
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bounties:** add tests for claim_bounty repo-read gate ([#160](https://github.com/Gitlawb/node/issues/160)) ([#169](https://github.com/Gitlawb/node/issues/169)) ([6bafaa6](https://github.com/Gitlawb/node/commit/6bafaa6dc5f05c0dcd61c708397afddbcf8c2e3f))
|
||||
* **deps:** bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#162](https://github.com/Gitlawb/node/issues/162)) ([#163](https://github.com/Gitlawb/node/issues/163)) ([67ad2b8](https://github.com/Gitlawb/node/commit/67ad2b876c8d9a336219d1016968de7a88fc4e75))
|
||||
* **node,git:** bound a hung served git with a total-duration timeout ([#62](https://github.com/Gitlawb/node/issues/62)) ([#165](https://github.com/Gitlawb/node/issues/165)) ([cd67718](https://github.com/Gitlawb/node/commit/cd67718f49ec38726a40f6bcf36f539ccdb42969))
|
||||
* **node:** bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth ([#147](https://github.com/Gitlawb/node/issues/147)) ([#149](https://github.com/Gitlawb/node/issues/149)) ([6b5e5bc](https://github.com/Gitlawb/node/commit/6b5e5bc7aee00a2d03295d3620df1ee4d8c024a2))
|
||||
* **node:** gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes ([#82](https://github.com/Gitlawb/node/issues/82)) ([#161](https://github.com/Gitlawb/node/issues/161)) ([d00d89a](https://github.com/Gitlawb/node/commit/d00d89ae5be992d1f63e95b714ae1bd3735e8457))
|
||||
* **node:** rate-limit repo/agent creation per client IP to stop DID-farm spam floods ([#180](https://github.com/Gitlawb/node/issues/180)) ([dfcaa22](https://github.com/Gitlawb/node/commit/dfcaa22b23ec91be4c75926956aa994ca89de8d5))
|
||||
* **release:** build aarch64-musl natively on arm64 runners, replace retired macos-13 ([#155](https://github.com/Gitlawb/node/issues/155)) ([6cff528](https://github.com/Gitlawb/node/commit/6cff5286b436fdee44ad881999e2a5f4bdba18f9))
|
||||
* **visibility:** gate repo-scoped read surfaces on visibility ([#120](https://github.com/Gitlawb/node/issues/120)) ([#157](https://github.com/Gitlawb/node/issues/157)) ([26bc3f6](https://github.com/Gitlawb/node/commit/26bc3f69870aa77e43c0d92115a5aa59555b7d88))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gl:** sanctioned iCaptcha client flow + secure git lifecycle ([#138](https://github.com/Gitlawb/node/issues/138)) ([06388ec](https://github.com/Gitlawb/node/commit/06388ec26aa29d356ae311276fdb91be054e9ecc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gl:** sign the CLI's /ipfs/pins reads under the [#134](https://github.com/Gitlawb/node/issues/134) auth gate ([#146](https://github.com/Gitlawb/node/issues/146)) ([20d6848](https://github.com/Gitlawb/node/commit/20d6848846b3a988d604208833167a528b7d8820))
|
||||
* **node,git-remote:** gate receive-pack advertisement, sign client fetch/push ([#119](https://github.com/Gitlawb/node/issues/119)) ([6f36fc0](https://github.com/Gitlawb/node/commit/6f36fc07b8e10a650c5948b269feac1cb25cae2a))
|
||||
* **node,gossip:** route gossip HTTP through the no-redirect client ([#93](https://github.com/Gitlawb/node/issues/93)) ([#140](https://github.com/Gitlawb/node/issues/140)) ([563c456](https://github.com/Gitlawb/node/commit/563c456803bf3e958d63869db424b3940472bc3d))
|
||||
* **node:** close two spam-vector root causes (trust upsert + ungated push) ([#152](https://github.com/Gitlawb/node/issues/152)) ([2df6ff9](https://github.com/Gitlawb/node/commit/2df6ff9d30de62f754fa41473e85db316021718e))
|
||||
* **node:** gate GET /ipfs/{cid} on reachable allowed-set, not deny-set ([#126](https://github.com/Gitlawb/node/issues/126)) ([#133](https://github.com/Gitlawb/node/issues/133)) ([466a550](https://github.com/Gitlawb/node/commit/466a550915edd711856ef32035f9f474e2577c4f))
|
||||
* **node:** gate the ref-updates feeds on read visibility ([#112](https://github.com/Gitlawb/node/issues/112), [#114](https://github.com/Gitlawb/node/issues/114)) ([#143](https://github.com/Gitlawb/node/issues/143)) ([4891db3](https://github.com/Gitlawb/node/commit/4891db38892663326ee0c1417a2db931988be4b5))
|
||||
* **node:** prefer canonical repo row over mirror row in get_repo ([#124](https://github.com/Gitlawb/node/issues/124)) ([#141](https://github.com/Gitlawb/node/issues/141)) ([6c95592](https://github.com/Gitlawb/node/commit/6c95592d188222ac3446dc23ef8d9befbf82ad6f))
|
||||
* **remote:** include HTTP error response body ([#137](https://github.com/Gitlawb/node/issues/137)) ([09a0cb2](https://github.com/Gitlawb/node/commit/09a0cb23b9f284ccbd69aca6958b70671f3bfb46))
|
||||
* **repos:** log the cause when repo create fails ([#103](https://github.com/Gitlawb/node/issues/103)) ([2620e97](https://github.com/Gitlawb/node/commit/2620e973e3cd4835ed42ddd4adcd8183b5b3080e))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/node/compare/v0.3.9...v0.4.0) (2026-06-30)
|
||||
|
||||
|
||||
### 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
-5
@@ -54,7 +54,6 @@ Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://
|
||||
3. **No breaking changes without discussion.** Open an issue first for protocol-level changes.
|
||||
4. **Conventional commits.** Use `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Releases are automated by [release-please](https://github.com/googleapis/release-please) — your commit prefixes drive the next version bump.
|
||||
5. **Format and lint.** Run `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` before submitting. CI will reject anything that fails these.
|
||||
6. **Security-surface rules.** [`AGENTS.md`](AGENTS.md) carries the authorization invariants PRs most often break; it is written for coding agents and humans alike, so read it before touching endpoints, visibility, or migrations.
|
||||
|
||||
## What gets merged, what gets closed
|
||||
|
||||
@@ -70,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
-125
@@ -329,7 +329,7 @@ dependencies = [
|
||||
"keccak-asm",
|
||||
"paste",
|
||||
"proptest",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rapidhash",
|
||||
"ruint",
|
||||
"rustc-hash",
|
||||
@@ -770,23 +770,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
|
||||
dependencies = [
|
||||
"ark-ff-asm 0.6.0",
|
||||
"ark-ff-macros 0.6.0",
|
||||
"ark-serialize 0.6.0",
|
||||
"ark-std 0.6.0",
|
||||
"digest 0.10.7",
|
||||
"educe",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-asm"
|
||||
version = "0.3.0"
|
||||
@@ -817,16 +800,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-asm"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-macros"
|
||||
version = "0.3.0"
|
||||
@@ -865,19 +838,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-macros"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize"
|
||||
version = "0.3.0"
|
||||
@@ -911,30 +871,6 @@ dependencies = [
|
||||
"num-bigint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
|
||||
dependencies = [
|
||||
"ark-serialize-derive",
|
||||
"ark-std 0.6.0",
|
||||
"digest 0.10.7",
|
||||
"num-bigint",
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-std"
|
||||
version = "0.3.0"
|
||||
@@ -965,16 +901,6 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-std"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -2181,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]]
|
||||
@@ -2336,7 +2262,7 @@ checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"digest 0.10.7",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"rustversion",
|
||||
]
|
||||
@@ -2367,9 +2293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -2630,7 +2556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 2.0.117",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3374,22 +3300,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.7.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.7.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3406,7 +3328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.7.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3433,7 +3355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.7.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3443,7 +3365,6 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -3455,7 +3376,6 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"libc",
|
||||
"libp2p-core",
|
||||
"libp2p-dns",
|
||||
"libp2p-gossipsub",
|
||||
@@ -3482,23 +3402,20 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.7.1"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3681,7 +3598,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
@@ -3703,7 +3620,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
@@ -3893,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"
|
||||
@@ -4633,7 +4535,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -5380,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",
|
||||
@@ -5440,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",
|
||||
@@ -5514,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",
|
||||
@@ -5818,15 +5720,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruint"
|
||||
version = "1.20.0"
|
||||
version = "1.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
|
||||
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
|
||||
dependencies = [
|
||||
"alloy-rlp",
|
||||
"ark-ff 0.3.0",
|
||||
"ark-ff 0.4.2",
|
||||
"ark-ff 0.5.0",
|
||||
"ark-ff 0.6.0",
|
||||
"bytes",
|
||||
"fastrlp 0.3.1",
|
||||
"fastrlp 0.4.0",
|
||||
@@ -5837,7 +5738,7 @@ dependencies = [
|
||||
"primitive-types",
|
||||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rlp",
|
||||
"ruint-macro",
|
||||
"serde_core",
|
||||
@@ -6775,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",
|
||||
@@ -7157,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"
|
||||
+6
-27
@@ -8,33 +8,20 @@ FROM rust:1.91-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies first for faster rebuilds.
|
||||
# EVERY workspace member's manifest must be copied here, not just the ones this
|
||||
# image ships: cargo loads the whole workspace before it resolves anything, so a
|
||||
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
|
||||
# layer before a single crate is fetched.
|
||||
# Cache dependencies first for faster rebuilds
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
|
||||
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
|
||||
COPY crates/gl/Cargo.toml crates/gl/
|
||||
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
|
||||
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
|
||||
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
|
||||
|
||||
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
|
||||
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
|
||||
# is what let it sit dead from the moment gl gained a path dependency on
|
||||
# icaptcha-client. A failure here should stop the build, not silently turn every
|
||||
# image build into a cold one.
|
||||
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
|
||||
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
|
||||
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
|
||||
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
|
||||
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
|
||||
echo 'fn main() {}' > crates/gl/src/main.rs && \
|
||||
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
|
||||
echo '' > crates/gitlawb-core/src/lib.rs && \
|
||||
echo '' > crates/gitlawb-attest/src/lib.rs && \
|
||||
echo '' > crates/icaptcha-client/src/lib.rs && \
|
||||
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
|
||||
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
|
||||
|
||||
# Now copy real sources and build for real.
|
||||
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
|
||||
@@ -47,7 +34,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \
|
||||
rm -rf target/release/.fingerprint/gitlawb-node-* \
|
||||
target/release/.fingerprint/gl-* \
|
||||
target/release/.fingerprint/git-remote-gitlawb-* && \
|
||||
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
|
||||
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
|
||||
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
|
||||
|
||||
# ── Runtime stage ───────────────────────────────────────────────────────────
|
||||
@@ -57,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
|
||||
@@ -86,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"]
|
||||
+2
-5
@@ -3,10 +3,7 @@
|
||||
# Used by scripts/build-bins.sh — not used for node deployment.
|
||||
ARG TARGET=x86_64-unknown-linux-musl
|
||||
|
||||
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
|
||||
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
|
||||
# base image here fails only when someone runs scripts/build-bins.sh.
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
ARG TARGET
|
||||
|
||||
WORKDIR /build
|
||||
@@ -26,7 +23,7 @@ COPY crates/ crates/
|
||||
# Configure cross-linker for aarch64
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
||||
|
||||
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
|
||||
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
ARG TARGET
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Gitlawb Node is the open-source node software behind the Gitlawb network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
|
||||
|
||||
Gitlawb is not trying to be only "another git host." The long-term direction is:
|
||||
Gitlawb is not trying to be only “another git host.” The long-term direction is:
|
||||
|
||||
```txt
|
||||
Decentralized GitHub
|
||||
@@ -34,12 +34,12 @@ This is a Rust workspace with four crates:
|
||||
|
||||
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
|
||||
|
||||
- **Own your identity**: every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
|
||||
- **Signed writes by default**: write requests use RFC 9421 HTTP Signatures instead of passwords.
|
||||
- **Git-native transport**: repositories are still real git repositories served over smart HTTP.
|
||||
- **Agent-native workflows**: the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
|
||||
- **Peer-aware delivery**: nodes can announce, discover, gossip, and sync with each other.
|
||||
- **App CDN direction**: the network can evolve from decentralized code storage into code + asset + app delivery.
|
||||
- **Own your identity** — every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
|
||||
- **Signed writes by default** — write requests use RFC 9421 HTTP Signatures instead of passwords.
|
||||
- **Git-native transport** — repositories are still real git repositories served over smart HTTP.
|
||||
- **Agent-native workflows** — the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
|
||||
- **Peer-aware delivery** — nodes can announce, discover, gossip, and sync with each other.
|
||||
- **App CDN direction** — the network can evolve from decentralized code storage into code + asset + app delivery.
|
||||
|
||||
---
|
||||
|
||||
@@ -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. Staged rollout relaxes who may announce, not who owns a peer row. An unsigned announce may register a previously unseen peer, and it may refresh an existing row whose `http_url` is unchanged, but changing an existing peer's `http_url` requires an RFC 9421 signature from that peer's own DID and is refused with 403 otherwise. An unsigned announce can only register a `did:key`, since that is the only method whose verifying key can be resolved and therefore the only kind of row anyone could ever correct through the signed path; any other DID is refused with 400. 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,28 +295,12 @@ 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, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
|
||||
| `GITLAWB_MAX_CONCURRENT_GIT_OPS` | Max concurrent served git READ ops (upload-pack and its `info/refs` advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER`. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
|
||||
| `GITLAWB_MAX_CONCURRENT_GIT_PUSHES` | Max concurrent `git-receive-pack` POST operations, in a pool separate from the read pool. The anon receive-pack `info/refs` advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (`/8`, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
|
||||
| `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER` | Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as `GITLAWB_TRUSTED_PROXY`: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
|
||||
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Default 8. |
|
||||
| `GITLAWB_REPO_LEASE_MAX_WAITERS` | Max pushes parked at once waiting for the same repo's write lease. Each waiter pins its buffered pack body, so this bounds that memory for a hot repo; past the cap the newest push sheds a 503 + Retry-After instead of queueing. Pushes to other repos are unaffected, and the lease holder is not counted. Default 8. |
|
||||
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
|
||||
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
|
||||
| `GITLAWB_IPFS_MAX_LEGACY_PROBES` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
|
||||
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks per `/ipfs/{cid}` request; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal per-request history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. Default 64. |
|
||||
| `GITLAWB_IPFS_MAX_REPO_VISITS` | Ceiling on repos one `/ipfs/{cid}` request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
|
||||
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `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. |
|
||||
|
||||
Production note: change the default Postgres password before exposing a node publicly.
|
||||
|
||||
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
|
||||
|
||||
---
|
||||
|
||||
## Optional node staking
|
||||
|
||||
+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.1" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -16,23 +16,3 @@ anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
# Job Object bindings for the real_git_fetch harness: the Windows analog of the
|
||||
# unix process-group teardown, so the whole `git fetch` tree can be killed
|
||||
# together on both the timeout and clean-exit paths (INV-22). Win32_Security is
|
||||
# required by CreateJobObjectW, and Win32_System_Threading by
|
||||
# JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! by exact match.
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
@@ -111,7 +111,7 @@ impl Attestation {
|
||||
.try_into()
|
||||
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
|
||||
let sig = Signature::from_bytes(&sig_bytes);
|
||||
vk.verify_strict(&bytes, &sig)
|
||||
vk.verify(&bytes, &sig)
|
||||
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
|
||||
|
||||
Ok(vk)
|
||||
@@ -127,7 +127,7 @@ impl Attestation {
|
||||
self.type_
|
||||
)));
|
||||
}
|
||||
Ok(P::deserialize(&self.payload)?)
|
||||
Ok(serde_json::from_value(self.payload.clone())?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,39 +554,4 @@ mod tests {
|
||||
let err = tampered.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
|
||||
/// Regression guard for strict verification: a signature forged under a
|
||||
/// weak (small-order) public key satisfies the verification equation
|
||||
/// but must be rejected. The identity point is such a key: with R the
|
||||
/// identity and S = 0, [S]B - [k]A is the identity for any message.
|
||||
#[test]
|
||||
fn verify_rejects_weak_key_signature() {
|
||||
let mut weak_key_bytes = [0u8; 32];
|
||||
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
|
||||
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
|
||||
assert!(weak_vk.is_weak(), "identity point must be a weak key");
|
||||
|
||||
let cert_hash = sample_cert_hash();
|
||||
let mut forged = [0u8; 64];
|
||||
forged[0] = 1; // R = identity point, S = 0
|
||||
let forged_sig = B64U.encode(forged);
|
||||
|
||||
// Build an attestation with the weak key as signer and the forged
|
||||
// signature. The weak-key check happens at verify time.
|
||||
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
|
||||
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&weak_key_bytes);
|
||||
att.signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
att.sig = forged_sig;
|
||||
|
||||
let err = att.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_)),
|
||||
"signature under a weak (small-order) public key must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -64,19 +64,6 @@ impl Cid {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `s` parses as a CIDv1 with the raw codec — the exact shape
|
||||
/// [`Cid::from_git_object_bytes`] produces and the `/ipfs` resolver looks up.
|
||||
/// A legacy provider CID (Kubo dag-pb, Pinata CIDv0) parses to a different
|
||||
/// version or codec and returns `false`, marking it an opportunistic-repair
|
||||
/// candidate. Decidable from the string alone (no object bytes), so the pin path
|
||||
/// can gate the byte-read/recompute cost on it and leave non-legacy rows at the
|
||||
/// existing DB-only skip cost. An unparseable string is non-canonical (`false`).
|
||||
pub fn is_raw_cidv1(s: &str) -> bool {
|
||||
s.parse::<CidGeneric<64>>()
|
||||
.map(|c| c.version() == cid::Version::V1 && c.codec() == RAW)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
impl fmt::Display for Cid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
@@ -190,38 +177,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_raw_cidv1_classifies_codec_from_string() {
|
||||
// The canonical resolver key: CIDv1 + raw codec → not a repair candidate.
|
||||
let raw = Cid::from_git_object_bytes(b"blob 5\0hello");
|
||||
assert!(
|
||||
is_raw_cidv1(raw.as_str()),
|
||||
"from_git_object_bytes output is CIDv1/raw"
|
||||
);
|
||||
|
||||
// A CIDv0 (Pinata dag-pb legacy shape) → repair candidate.
|
||||
assert!(
|
||||
!is_raw_cidv1("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"),
|
||||
"a CIDv0 dag-pb value is a legacy-repair candidate"
|
||||
);
|
||||
|
||||
// A CIDv1 with the dag-pb codec (the Kubo above-block-size root) over the
|
||||
// same multihash → still a repair candidate (codec, not just version).
|
||||
let parsed = raw.as_str().parse::<CidGeneric<64>>().unwrap();
|
||||
const DAG_PB: u64 = 0x70;
|
||||
let dagpb = CidGeneric::<64>::new_v1(DAG_PB, *parsed.hash()).to_string();
|
||||
assert!(
|
||||
!is_raw_cidv1(&dagpb),
|
||||
"a CIDv1 dag-pb value is a legacy-repair candidate"
|
||||
);
|
||||
|
||||
// Garbage is non-canonical.
|
||||
assert!(
|
||||
!is_raw_cidv1("not-a-cid"),
|
||||
"an unparseable string is non-canonical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_of_empty_input_is_well_known() {
|
||||
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
|
||||
|
||||
@@ -80,19 +80,6 @@ impl Did {
|
||||
)));
|
||||
}
|
||||
|
||||
// Refuse an oversized id before decoding. base58 decoding is quadratic
|
||||
// in its input, and both callers of this function take the string from
|
||||
// an untrusted request: the unauthenticated peer-announce gate, and the
|
||||
// HTTP-signature keyid. An ed25519 did:key method-id is a fixed 48
|
||||
// characters, so this bound is slack rather than a behavior change, and
|
||||
// it has to sit ahead of the decode to be worth anything.
|
||||
const MAX_METHOD_ID_LEN: usize = 64;
|
||||
if self.method_id().len() > MAX_METHOD_ID_LEN {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key method-specific id too long".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (_, bytes) =
|
||||
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
@@ -321,52 +308,4 @@ mod tests {
|
||||
assert_eq!(Did::web("example.com").method_id(), "example.com");
|
||||
assert_eq!(Did::gitlawb("z6MkSomeKey").method_id(), "z6MkSomeKey");
|
||||
}
|
||||
|
||||
/// An oversized method-specific id is refused on its length, before the
|
||||
/// base58 decode runs.
|
||||
///
|
||||
/// base58 decoding is quadratic in the input, and `to_verifying_key` is
|
||||
/// reached from two places that take an attacker-supplied string: the
|
||||
/// unauthenticated peer-announce gate, and the HTTP-signature keyid. A
|
||||
/// 64k-character DID measured 8s through the router without this guard
|
||||
/// against 4.6ms with it, on a tokio worker with no spawn_blocking, so a
|
||||
/// single caller could buy CPU-seconds by the thousand.
|
||||
///
|
||||
/// Asserting the MESSAGE is what makes this load-bearing: an oversized id
|
||||
/// errors either way, so a test that only checked `is_err()` would pass
|
||||
/// with the guard removed, having merely paid the cost it exists to avoid.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_an_oversized_method_id_before_decoding() {
|
||||
let oversized = format!("did:key:z{}", "z".repeat(64_000));
|
||||
let did: Did = oversized.parse().expect("method is still `key`");
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let err = did.to_verifying_key().expect_err("must be refused");
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("too long"),
|
||||
"must be refused on length, not after the decode: {err}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(1),
|
||||
"refusal must not pay the quadratic decode: took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bound must not reject a real key. Every `did:key` this project
|
||||
/// mints is a fixed 48 characters, so the limit is slack, not a behavior
|
||||
/// change.
|
||||
#[test]
|
||||
fn to_verifying_key_still_accepts_a_real_did_key() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let did = Did::from_verifying_key(&signing.verifying_key());
|
||||
assert_eq!(
|
||||
did.method_id().len(),
|
||||
48,
|
||||
"did:key method-id is fixed-width"
|
||||
);
|
||||
did.to_verifying_key().expect("a real did:key must resolve");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -78,9 +78,10 @@ impl Keypair {
|
||||
|
||||
/// Verify an Ed25519 signature.
|
||||
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
|
||||
use ed25519_dalek::Verifier;
|
||||
let sig = Signature::from_bytes(sig_bytes);
|
||||
verifying_key
|
||||
.verify_strict(msg, &sig)
|
||||
.verify(msg, &sig)
|
||||
.map_err(|_| Error::SignatureInvalid)
|
||||
}
|
||||
|
||||
@@ -208,28 +209,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_weak_key_signature() {
|
||||
// Regression guard for strict verification: a signature forged under a
|
||||
// weak (small-order) public key satisfies the verification equation
|
||||
// but must be rejected. The identity point is such a key: with R the
|
||||
// identity and S = 0, [S]B - [k]A is the identity for any message.
|
||||
let mut weak_key_bytes = [0u8; 32];
|
||||
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
|
||||
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
|
||||
assert!(weak_vk.is_weak(), "identity point must be a weak key");
|
||||
|
||||
let msg = b"forged message";
|
||||
let mut forged = [0u8; 64];
|
||||
forged[0] = 1; // R = identity point, S = 0
|
||||
|
||||
let result = verify(&weak_vk, msg, &forged);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"signature under a weak (small-order) public key must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_payload_round_trip() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -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.1" # 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"
|
||||
@@ -77,6 +72,3 @@ libp2p-dns = { version = "0.44.0", features = ["tokio"] }
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tempfile = "3"
|
||||
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
|
||||
# on which timer fired instead of on wall clock.
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -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() {
|
||||
|
||||
@@ -26,61 +26,14 @@ pub async fn list_anchors(
|
||||
Query(q): Query<ListAnchorsQuery>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let limit = q.limit.min(200);
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let anchors = state
|
||||
.db
|
||||
.list_arweave_anchors(q.repo.as_deref(), limit)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(crate::error::AppError::Internal)?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
"count": anchors.len(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod closed_pool_tests {
|
||||
use super::*;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable.
|
||||
#[sqlx::test]
|
||||
async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
pool.close().await;
|
||||
|
||||
let resp = Router::new()
|
||||
.route("/api/v1/arweave/anchors", axum::routing::get(list_anchors))
|
||||
.with_state(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/arweave/anchors")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"closed-pool outage must be retryable 503, not 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
serde_json::json!({
|
||||
"error": crate::error::DB_UNAVAILABLE_CODE,
|
||||
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
+51
-4151
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
@@ -61,14 +61,11 @@ pub async fn create_issue(
|
||||
let json_str = serde_json::to_string(&issue)
|
||||
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). This path holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
@@ -93,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
|
||||
@@ -122,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
|
||||
@@ -162,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
|
||||
@@ -192,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
|
||||
@@ -232,42 +216,13 @@ pub async fn close_issue(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// Same capacity shed as create_issue above (#173 F1): an exhausted write-lock
|
||||
// pool is a 503 + Retry-After, not a 500 git error.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
.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.
|
||||
@@ -284,168 +239,3 @@ pub async fn close_issue(
|
||||
|
||||
Ok(Json(issue))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: the two issue write paths reach `acquire_write` holding NO
|
||||
/// admission permit (unlike the push handler, which is capped by the git-push
|
||||
/// semaphore), so they are the callers most likely to meet an exhausted write-lock
|
||||
/// POOL under load. An exhausted pool is a capacity signal, so both must shed
|
||||
/// 503 + Retry-After (`AppError::Overloaded`) the way the push handler does, not
|
||||
/// report the generic 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
let now = Utc::now();
|
||||
crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/tmp/{name}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// State whose repo store draws write locks from a ONE-connection pool with a
|
||||
/// short checkout timeout, so a single held guard exhausts it promptly rather
|
||||
/// than at the pool default.
|
||||
async fn one_connection_lock_pool_state(pool: &PgPool) -> AppState {
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-issues-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(pool, 1, std::time::Duration::from_secs(1)),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The shed must be a real 503 carrying Retry-After, not just an internal enum
|
||||
/// variant: assert on the rendered response so a remapping of `Overloaded` is
|
||||
/// caught here too.
|
||||
fn assert_sheds_503_with_retry_after(err: AppError, what: &str) {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"{what}: an exhausted write-lock pool must shed 503, not a 500 git error"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("1"),
|
||||
"{what}: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `create_issue`. Both directions: the shed while the
|
||||
/// only lock-pool connection is held by a guard on a DIFFERENT repo (so this is
|
||||
/// pool capacity, not advisory-lock contention on this repo), and the must-not
|
||||
/// case once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECREATELOCKPOOLAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = one_connection_lock_pool_state(&pool).await;
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, "lp-create"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-create".to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "create_issue");
|
||||
|
||||
// MUST-NOT: with the pool free again the call is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await;
|
||||
let admitted = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-create".to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(admitted, Err(AppError::Overloaded(_))),
|
||||
"with the lock pool free, create_issue must not be shed as capacity; got {:?}",
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `close_issue`, same two directions.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
|
||||
let state = one_connection_lock_pool_state(&pool).await;
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, "lp-close"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((
|
||||
owner.to_string(),
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "close_issue");
|
||||
|
||||
held.release(false).await;
|
||||
let admitted = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((
|
||||
owner.to_string(),
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(admitted, Err(AppError::Overloaded(_))),
|
||||
"with the lock pool free, close_issue must not be shed as capacity; got {:?}",
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,643 +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 resolves each candidate (provenance path + legacy scan) through
|
||||
// the shared `gate_and_serve`; the gate markers themselves are asserted
|
||||
// below. This row proves the delegation is real, so the gate is actually
|
||||
// reached rather than dead code. The delegated gate still calls
|
||||
// `visibility_check` directly and never `authorize_repo_read`, so it keeps
|
||||
// the property the pre-merge marker enforced: no fuzzy re-resolve.
|
||||
(ipfs, "get_by_cid", "gate_and_serve("),
|
||||
// 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"
|
||||
);
|
||||
|
||||
// The CID read surface (#173) enforces its gate inside the shared
|
||||
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
|
||||
// the markers must live there (the get_by_cid row above only proves delegation).
|
||||
// The repo's own "/" visibility check (KTD2a — never authorize_repo_read's fuzzy
|
||||
// re-resolve) and the quarantine hard-drop BEFORE visibility (INV-11) are both
|
||||
// load-bearing: removing either re-opens a leak on the provenance path.
|
||||
let gate_body = fn_body(ipfs, "gate_and_serve");
|
||||
assert!(
|
||||
gate_body.contains("visibility_check("),
|
||||
"gate_and_serve must gate the CID read surface via visibility_check (KTD2a)"
|
||||
);
|
||||
assert!(
|
||||
gate_body.contains("if quarantined"),
|
||||
"gate_and_serve must hard-drop a quarantined repo before the visibility gate (INV-11)"
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -209,14 +204,11 @@ pub async fn merge_pr(
|
||||
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
|
||||
}
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
let merger_did = auth.0;
|
||||
let merge_result = store::merge_branch(
|
||||
@@ -260,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
|
||||
@@ -275,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(
|
||||
@@ -308,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
|
||||
@@ -385,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
|
||||
@@ -427,116 +413,3 @@ pub async fn list_comments(
|
||||
let comments = state.db.list_pr_comments(&pr.id).await?;
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
|
||||
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
|
||||
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
|
||||
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
|
||||
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
|
||||
/// 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
use axum::response::IntoResponse;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
let now = Utc::now();
|
||||
crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/tmp/{name}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
|
||||
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
|
||||
/// capacity, not advisory-lock contention on this repo), and the must-not case
|
||||
/// once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
// One lock-pool connection with a short checkout timeout, so a single held
|
||||
// guard exhausts it promptly rather than at the pool default.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
|
||||
);
|
||||
|
||||
let repo = seed_repo(owner, "lp-merge");
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
state
|
||||
.db
|
||||
.create_pr(&PullRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo_id.clone(),
|
||||
number: 1,
|
||||
title: "lp".to_string(),
|
||||
body: None,
|
||||
author_did: owner.to_string(),
|
||||
source_branch: "feature".to_string(),
|
||||
target_branch: "main".to_string(),
|
||||
status: "open".to_string(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.expect("seed open PR");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, "other-repo")
|
||||
.await
|
||||
.expect("the first write takes the only lock-pool connection");
|
||||
|
||||
let shed = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-merge".to_string(), 1)),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"merge_pr: an exhausted write-lock pool must shed 503, not a 500 git error"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("1"),
|
||||
"merge_pr: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
|
||||
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await;
|
||||
let admitted = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), "lp-merge".to_string(), 1)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(admitted, Err(AppError::Overloaded(_))),
|
||||
"with the lock pool free, merge_pr must not be shed as capacity; got {:?}",
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
|
||||
+423
-9575
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,32 +502,7 @@ 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)),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
|
||||
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
|
||||
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,
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
encrypt_inflight: crate::state::EncryptInflight::new(),
|
||||
repo_write_leases: crate::state::RepoWriteLeases::new(8),
|
||||
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
git_push_advert_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_ipfs_walk_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
git_bin: "git".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+184
-5084
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,
|
||||
@@ -170,16 +85,9 @@ pub async fn encrypt_and_pin(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
|
||||
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,121 +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),
|
||||
|
||||
/// A DID was well-formed enough to carry to a resolver but no verifying key
|
||||
/// could be derived from it. Its own code rather than plain `bad_request`
|
||||
/// because the auth middleware already answers `unresolvable_did` for the
|
||||
/// same failure on a request's keyid, and a client should not have to
|
||||
/// substring-match a message to tell this apart from the other validation
|
||||
/// failures on the same route.
|
||||
#[error("unresolvable did: {0}")]
|
||||
UnresolvableDid(String),
|
||||
|
||||
#[error("too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
#[error("incomplete: {0}")]
|
||||
Incomplete(String),
|
||||
|
||||
#[error("search incomplete: {0}")]
|
||||
SearchIncomplete(String),
|
||||
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
|
||||
#[error("git service timed out: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("server overloaded: {0}")]
|
||||
Overloaded(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";
|
||||
|
||||
/// Generic client-facing message for `AppError::Internal`. The real error is
|
||||
/// logged server-side; never put sqlx/anyhow detail in the HTTP body (#226).
|
||||
pub const INTERNAL_ERROR_MESSAGE: &str = "an internal error occurred";
|
||||
|
||||
/// Generic client-facing message for non-unavailable `AppError::Db`. Query /
|
||||
/// schema errors stay in logs; the HTTP body must not leak them (#226).
|
||||
pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
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,
|
||||
@@ -152,65 +52,14 @@ 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::UnresolvableDid(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "unresolvable_did", msg.clone())
|
||||
}
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
|
||||
}
|
||||
AppError::Incomplete(msg) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
|
||||
}
|
||||
// A bounded search that could not complete (the CID resolver hit its
|
||||
// legacy-probe or walk ceiling), distinct from the 404 that asserts a
|
||||
// definitive not-found: absence was NOT proven, so the caller should
|
||||
// retry rather than treat it as gone (#173, F2). 503, retryable.
|
||||
AppError::SearchIncomplete(msg) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"search_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,
|
||||
"internal_error",
|
||||
e.to_string(),
|
||||
),
|
||||
// 503 with a Retry-After (attached after this match — the shared tail
|
||||
// can't carry per-variant headers). This is the single place Overloaded
|
||||
// becomes a response, so it can never ship a 503 without the retry hint.
|
||||
AppError::Overloaded(msg) => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", msg.clone())
|
||||
}
|
||||
// Opaque body + server log: bare `?` on sqlx paths becomes `AppError::Db`
|
||||
// via `From`, so this arm (not `Internal`) is the common leak for open
|
||||
// routes like GET /api/v1/repos and GET /api/v1/peers (#226).
|
||||
AppError::Db(e) => {
|
||||
tracing::error!(error = %e, "database error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"db_error",
|
||||
DB_ERROR_MESSAGE.into(),
|
||||
)
|
||||
}
|
||||
// Opaque body: handlers that map with `.map_err(AppError::Internal)`
|
||||
// (e.g. GET /ipfs/{cid}) land here; other DB failures usually hit `Db`.
|
||||
// Log `{e:#}` so context-wrapped anyhow chains keep the leaf cause
|
||||
// (Display alone is only the outermost layer; see api/repos.rs).
|
||||
AppError::Internal(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "internal error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
INTERNAL_ERROR_MESSAGE.into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
@@ -218,129 +67,8 @@ impl IntoResponse for AppError {
|
||||
"message": message,
|
||||
}));
|
||||
|
||||
let mut resp = (status, body).into_response();
|
||||
// Both retryable 503s advertise when to retry: Overloaded (capacity shed) and
|
||||
// SearchIncomplete (a bounded CID search cut short by a cap — retry may complete
|
||||
// it). They ride the shared tail above for body/status, so the header is attached
|
||||
// here rather than in bespoke early returns, keeping each variant handled once.
|
||||
if matches!(
|
||||
self,
|
||||
AppError::Overloaded(_) | AppError::SearchIncomplete(_)
|
||||
) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
axum::http::HeaderValue::from_static("1"),
|
||||
);
|
||||
}
|
||||
resp
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overloaded_maps_to_503_with_retry_after() {
|
||||
let resp = AppError::Overloaded("x".into()).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
/// #226: raw sqlx/DB detail must never appear in the Internal 500 body.
|
||||
#[tokio::test]
|
||||
async fn internal_error_body_is_opaque() {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let leak = "error returned from database: relation \"repos\" does not exist";
|
||||
let resp = AppError::Internal(anyhow::anyhow!("{leak}")).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
// Exact object: a new `detail` field with different sensitive text must
|
||||
// also fail, not only a repeat of the original error string.
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"error": "internal_error",
|
||||
"message": INTERNAL_ERROR_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// #226: `AppError::Db` query errors (the common `?` path) must also be opaque.
|
||||
#[tokio::test]
|
||||
async fn db_error_body_is_opaque() {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let resp = AppError::Db(sqlx::Error::Protocol(
|
||||
"error returned from database: column \"is_public\" does not exist".into(),
|
||||
))
|
||||
.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"error": "db_error",
|
||||
"message": DB_ERROR_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Connection-level failures must stay 503 `db_unavailable`, not collapse
|
||||
/// into the opaque 500 `db_error` arm if `db_unavailable` loses a variant.
|
||||
#[tokio::test]
|
||||
async fn db_pool_timeout_stays_503_unavailable() {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let resp = AppError::Db(sqlx::Error::PoolTimedOut).into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let v: Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"error": DB_UNAVAILABLE_CODE,
|
||||
"message": DB_UNAVAILABLE_MESSAGE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// #251: bare `?` on anyhow-wrapped sqlx relies on this downcast so a
|
||||
/// closed pool becomes 503 `db_unavailable`, not 500 `internal_error`.
|
||||
#[test]
|
||||
fn pool_closed_via_anyhow_from_is_503_db_unavailable() {
|
||||
let err: AppError = anyhow::Error::from(sqlx::Error::PoolClosed).into();
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -31,25 +29,6 @@ impl TigrisClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only constructor with an explicit S3 endpoint, region, and static
|
||||
/// credentials — no env-var reads, so parallel tests cannot race each other's
|
||||
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
|
||||
/// the client at a non-routable endpoint to exercise acquire-stall paths.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(aws_config::Region::new("auto"))
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
Self {
|
||||
s3: S3Client::new(&config),
|
||||
bucket: bucket.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
|
||||
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
|
||||
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
|
||||
@@ -131,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({
|
||||
@@ -176,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
|
||||
@@ -208,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
|
||||
@@ -231,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
@@ -14,68 +14,6 @@ use subscription::SubscriptionRoot;
|
||||
|
||||
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||
|
||||
/// Client-facing message for GraphQL resolver failures that wrap a real
|
||||
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
|
||||
/// detail in the GraphQL `errors` array (#250).
|
||||
///
|
||||
/// Kept as its own constant on this PR's base (main still renders
|
||||
/// `AppError::Db` with `e.to_string()`). If/when #247's `DB_ERROR_MESSAGE`
|
||||
/// lands, fold this into that shared constant.
|
||||
pub const GRAPHQL_DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
fn anyhow_has_sqlx(e: &anyhow::Error) -> bool {
|
||||
e.chain().any(|c| c.downcast_ref::<sqlx::Error>().is_some())
|
||||
}
|
||||
|
||||
/// Map an `anyhow` failure from the db layer to a GraphQL error.
|
||||
///
|
||||
/// - Real DB faults (`sqlx::Error` anywhere in the chain) → opaque client
|
||||
/// message + `error!` log with the full `{e:#}` cause chain.
|
||||
/// - Application/business errors (e.g. claim race, not-in-claimed-state) →
|
||||
/// keep the actionable message; log at `warn!` so they are not mistaken for
|
||||
/// infrastructure failures (#250 review).
|
||||
pub(crate) fn graphql_db_err(e: anyhow::Error) -> async_graphql::Error {
|
||||
if anyhow_has_sqlx(&e) {
|
||||
tracing::error!(error = %format!("{e:#}"), "graphql database error");
|
||||
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
|
||||
} else {
|
||||
tracing::warn!(error = %format!("{e:#}"), "graphql application error");
|
||||
async_graphql::Error::new(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an `AppError` from a shared collector (e.g. ref-update feed) to a
|
||||
/// GraphQL error.
|
||||
///
|
||||
/// Fail closed: only explicitly curated variants surface their `Display`
|
||||
/// text. Unnamed variants (including `Git`, which may embed on-disk paths)
|
||||
/// render opaque so a future addition cannot leak by default (#255 review).
|
||||
pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error {
|
||||
match e {
|
||||
crate::error::AppError::Db(sql) => graphql_db_err(sql.into()),
|
||||
crate::error::AppError::Internal(err) => {
|
||||
tracing::error!(error = %format!("{err:#}"), "graphql internal error");
|
||||
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
|
||||
}
|
||||
// Curated client-safe variants — `Display` is intentional API text.
|
||||
safe @ (crate::error::AppError::RepoNotFound(_)
|
||||
| crate::error::AppError::RepoExists(_)
|
||||
| crate::error::AppError::NotFound(_)
|
||||
| crate::error::AppError::Unauthorized(_)
|
||||
| crate::error::AppError::Forbidden(_)
|
||||
| crate::error::AppError::BadRequest(_)
|
||||
| crate::error::AppError::TooManyRequests(_)
|
||||
| crate::error::AppError::Incomplete(_)) => {
|
||||
tracing::warn!(error = %safe, "graphql application error");
|
||||
async_graphql::Error::new(safe.to_string())
|
||||
}
|
||||
other => {
|
||||
tracing::error!(error = %other, "graphql unclassified AppError (opaque)");
|
||||
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_schema(
|
||||
db: Arc<Db>,
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
@@ -87,105 +25,3 @@ pub fn build_schema(
|
||||
.data(task_event_tx)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_opaques_sqlx_chain() {
|
||||
let leak = "error returned from database: column \"is_public\" does not exist";
|
||||
// Context layer must not hide sqlx from the chain walk (db helpers
|
||||
// wrap with `.context(...)` in several places).
|
||||
let err = graphql_db_err(
|
||||
anyhow::Error::from(sqlx::Error::Protocol(leak.into())).context("loading repos"),
|
||||
);
|
||||
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!err.message.contains("is_public"));
|
||||
assert!(!err.message.contains(leak));
|
||||
assert!(!err.message.contains("loading repos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_keeps_business_message() {
|
||||
let msg = "task not claimable: not found or already claimed";
|
||||
let err = graphql_db_err(anyhow::anyhow!("{msg}"));
|
||||
assert_eq!(err.message, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_app_err_opaques_db_and_internal() {
|
||||
let leak = "column \"is_public\" does not exist";
|
||||
let db_err = graphql_app_err(crate::error::AppError::Db(sqlx::Error::Protocol(
|
||||
leak.into(),
|
||||
)));
|
||||
assert_eq!(db_err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!db_err.message.contains("is_public"));
|
||||
|
||||
let internal = graphql_app_err(crate::error::AppError::Internal(anyhow::anyhow!(
|
||||
"loading repo: {leak}"
|
||||
)));
|
||||
assert_eq!(internal.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!internal.message.contains("is_public"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_app_err_keeps_safe_variant_messages() {
|
||||
let err = graphql_app_err(crate::error::AppError::NotFound("widget".into()));
|
||||
assert!(
|
||||
err.message.contains("widget"),
|
||||
"safe NotFound message must reach the client: {}",
|
||||
err.message
|
||||
);
|
||||
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
|
||||
let err = graphql_app_err(crate::error::AppError::BadRequest("bad cid".into()));
|
||||
assert!(
|
||||
err.message.contains("bad cid"),
|
||||
"safe BadRequest message must reach the client: {}",
|
||||
err.message
|
||||
);
|
||||
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_app_err_opaques_unclassified_variants() {
|
||||
// `Git` may embed on-disk paths from libgit2; fail closed.
|
||||
let path = "/var/lib/gitlawb/repos/owner/secret.git";
|
||||
let err = graphql_app_err(crate::error::AppError::Git(format!(
|
||||
"failed to open '{path}'"
|
||||
)));
|
||||
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
|
||||
assert!(!err.message.contains(path));
|
||||
assert!(!err.message.contains("failed to open"));
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
#[test]
|
||||
fn every_graphql_map_err_uses_opaque_helpers() {
|
||||
for (file, src) in [
|
||||
("query.rs", include_str!("query.rs")),
|
||||
("mutation.rs", include_str!("mutation.rs")),
|
||||
] {
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or(line);
|
||||
let Some(idx) = code.find(".map_err(") else {
|
||||
continue;
|
||||
};
|
||||
let after = code[idx + ".map_err(".len()..].trim_start();
|
||||
let ok = after.starts_with("crate::graphql::graphql_db_err")
|
||||
|| after.starts_with("crate::graphql::graphql_app_err")
|
||||
|| after.starts_with("|_|")
|
||||
|| after.starts_with("|_ ");
|
||||
assert!(
|
||||
ok,
|
||||
"{file}:{}: `.map_err(` must use graphql_db_err / graphql_app_err \
|
||||
or discard (`|_|`): {line}",
|
||||
lineno + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -54,7 +37,7 @@ impl MutationRoot {
|
||||
};
|
||||
db.create_task(&task)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
@@ -64,19 +47,12 @@ 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
|
||||
.claim_task(&id, &assignee_did)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
@@ -94,31 +70,12 @@ 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(crate::graphql::graphql_db_err)?
|
||||
.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
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -136,31 +93,13 @@ 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(crate::graphql::graphql_db_err)?
|
||||
.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))
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
@@ -171,165 +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. The missing
|
||||
// task is a business error from claim_task, not a sqlx fault, so the
|
||||
// actionable message must survive (not the opaque DB string) (#250).
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
assert!(
|
||||
errs.contains("task not claimable"),
|
||||
"claim race / missing task must keep its business message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"business error must not be rewritten as opaque DB error: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #250: mutation DB faults must be opaque; create_task hits agent_tasks.
|
||||
#[sqlx::test]
|
||||
async fn create_task_db_error_message_is_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let delegator = "did:key:zGQLDELEGATORAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let q = format!(
|
||||
r#"mutation {{
|
||||
createTask(
|
||||
delegatorDid: "{delegator}",
|
||||
input: {{ kind: "build", capability: "repo:write" }}
|
||||
) {{ id }}
|
||||
}}"#
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(delegator.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"sqlx fault must be opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("column") && !errs.contains("status"),
|
||||
"schema text leaked: {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(crate::graphql::graphql_db_err)?;
|
||||
|
||||
// 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(crate::graphql::graphql_db_err)?;
|
||||
|
||||
.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(crate::graphql::graphql_app_err)?;
|
||||
|
||||
// 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(crate::graphql::graphql_db_err)?;
|
||||
|
||||
let resolved: Vec<RefUpdateType> = updates
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
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(
|
||||
@@ -110,21 +57,13 @@ impl QueryRoot {
|
||||
ctx: &Context<'_>,
|
||||
status: Option<String>,
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
|
||||
)]
|
||||
limit: i64,
|
||||
#[graphql(default = 50)] limit: i64,
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
|
||||
// rejects with 2201W, which would otherwise trip the opaque DB path
|
||||
// and write an error-level log on every probe (#250 review).
|
||||
let limit = limit.clamp(0, 200);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
}
|
||||
|
||||
@@ -133,385 +72,7 @@ impl QueryRoot {
|
||||
let t = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
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);
|
||||
}
|
||||
|
||||
/// #250: anonymous GraphQL query DB failures must not leak sqlx/schema text.
|
||||
#[sqlx::test]
|
||||
async fn repos_query_db_error_message_is_opaque(pool: PgPool) {
|
||||
let db = db(pool.clone()).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", true))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN is_public")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ repos { name ownerDid } }").await;
|
||||
assert!(
|
||||
!resp.errors.is_empty(),
|
||||
"DB failure must surface as a GraphQL error"
|
||||
);
|
||||
for err in &resp.errors {
|
||||
assert_eq!(
|
||||
err.message,
|
||||
crate::graphql::GRAPHQL_DB_ERROR_MESSAGE,
|
||||
"raw DB detail leaked into GraphQL error: {}",
|
||||
err.message
|
||||
);
|
||||
assert!(
|
||||
!err.message.contains("is_public") && !err.message.contains("column"),
|
||||
"schema text leaked: {}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #250: negative tasks(limit) must not hit Postgres (and must not 500-log).
|
||||
#[sqlx::test]
|
||||
async fn tasks_negative_limit_clamped(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
}
|
||||
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
|
||||
#[sqlx::test]
|
||||
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for i in 0..201 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-ceil-{i}"),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: OWNER.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
|
||||
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
|
||||
}
|
||||
|
||||
fn count_tasks(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("tasks").expect("tasks key") else {
|
||||
panic!("tasks not a list");
|
||||
};
|
||||
rows.len()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+61
-2731
File diff suppressed because it is too large.
Load diff
+96
-1303
File diff suppressed because it is too large.
Load diff
@@ -57,15 +57,10 @@ static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
/// more than once is a silent no-op. MUST be called from `main()` after
|
||||
/// the node DID is known.
|
||||
pub fn init(version: &str, node_did: &str) {
|
||||
// Guard with Once so two concurrent callers cannot both pass an unsynchronized
|
||||
// `REGISTRY.get()` check and then race on `OnceLock::set(...).expect(...)`,
|
||||
// panicking the loser (#192 F4). Once runs the body exactly once, so the
|
||||
// `.expect("set X once")` calls below can never observe an already-set slot.
|
||||
static INIT: std::sync::Once = std::sync::Once::new();
|
||||
INIT.call_once(|| init_inner(version, node_did));
|
||||
}
|
||||
if REGISTRY.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
fn init_inner(version: &str, node_did: &str) {
|
||||
let registry = Registry::new();
|
||||
|
||||
let info = IntGaugeVec::new(
|
||||
@@ -223,17 +218,6 @@ pub fn record_fetch(repo: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: current `gitlawb_fetches_total` value for `repo` (0 if the registry
|
||||
/// is not initialized). Lets api-layer tests assert the completed-fetch count with
|
||||
/// a unique label instead of scraping the encoded text.
|
||||
#[cfg(test)]
|
||||
pub fn fetch_count_for_test(repo: &str) -> u64 {
|
||||
FETCHES
|
||||
.get()
|
||||
.map(|c| c.with_label_values(&[repo]).get())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Record one HTTP signature check that passed.
|
||||
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
|
||||
pub fn record_auth_success(route: &str) {
|
||||
@@ -251,11 +235,7 @@ pub fn record_auth_failure(route: &str, reason: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped,
|
||||
/// rejected, deferred}. `rejected` is terminal and caused by a guard (a
|
||||
/// malformed slug, or a path resolving outside repos_dir); `deferred` leaves
|
||||
/// the row pending for a later pass, so a queue stalled on an operator
|
||||
/// condition is visible rather than looking idle.
|
||||
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped}.
|
||||
pub fn record_sync_processed(status: &str) {
|
||||
if let Some(c) = SYNC_PROCESSED.get() {
|
||||
c.with_label_values(&[status]).inc();
|
||||
@@ -302,11 +282,11 @@ pub fn encode() -> Result<String, prometheus::Error> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: cargo runs tests in a binary in parallel via threads, and several
|
||||
// tests here call init(). init() is guarded by std::sync::Once, so concurrent
|
||||
// callers run the body exactly once and never race on the OnceLock setters
|
||||
// (#192 F4). The encode test below is structured so it's safe to run alongside
|
||||
// other tests in the same binary regardless of ordering.
|
||||
// Note: these tests are not run in parallel by default (cargo runs
|
||||
// tests in a binary in parallel via threads but the OnceLock guard
|
||||
// means only the first init() call succeeds; subsequent ones are
|
||||
// no-ops). The encode test below is structured so it's safe to run
|
||||
// alongside other tests in the same binary.
|
||||
|
||||
#[test]
|
||||
fn encode_after_init_returns_prometheus_text() {
|
||||
@@ -337,24 +317,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #192 F4: `init` is idempotent and safe to call repeatedly. The panic that
|
||||
/// hit concurrent callers (both passing the old unsynchronized `REGISTRY.get()`
|
||||
/// check, then racing on `OnceLock::set(...).expect(...)`) is fixed by the
|
||||
/// `Once` guard. That race is inherently non-deterministic to assert in the
|
||||
/// shared-binary suite — a 32-thread barrier stress-test reproduces it only in
|
||||
/// isolation (verified: RED before the `Once` fix, GREEN after). This guard
|
||||
/// covers the deterministic half of the contract: a repeat call is a no-op,
|
||||
/// never a panic, and the registry stays usable.
|
||||
#[test]
|
||||
fn init_is_idempotent_no_panic_on_repeat() {
|
||||
init("0.0.0-a", "did:key:a");
|
||||
init("0.0.0-b", "did:key:b");
|
||||
assert!(
|
||||
encode().is_ok(),
|
||||
"registry must stay usable after a repeated init"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_helpers_are_noops_before_init() {
|
||||
// These don't panic. They also don't show up in encode() output
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
-1474
File diff suppressed because it is too large.
Load diff
@@ -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,121 +20,31 @@ 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;
|
||||
}
|
||||
|
||||
// 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],
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Non-consuming check: is this key ALREADY at its limit for the current window?
|
||||
/// Unlike [`check`], it records nothing and never inserts a new key — used to shed
|
||||
/// expensive preparatory work (e.g. the `/ipfs/{cid}` legacy scan's O(repos) DB
|
||||
/// preload) BEFORE it runs, without perturbing the per-unit budget the consuming
|
||||
/// `check` maintains (#173, F3). An unknown key or a disabled limiter is not
|
||||
/// throttled. Prunes the key's expired timestamps as a side effect (keeps state
|
||||
/// tidy) but adds none, so it cannot itself fill or grow the map.
|
||||
pub(crate) async fn is_throttled(&self, key: &str) -> bool {
|
||||
if self.max_requests == 0 {
|
||||
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;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
if let Some(window) = state.get_mut(key) {
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
return window.timestamps.len() >= self.max_requests;
|
||||
}
|
||||
false
|
||||
window.timestamps.push(now);
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) {
|
||||
@@ -152,118 +56,6 @@ impl RateLimiter {
|
||||
!w.timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of distinct keys currently tracked. Test-only introspection so a
|
||||
/// cross-module test can assert that a sweep actually evicted expired entries
|
||||
/// and observe what it reclaimed. There is no production reader.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-source concurrency cap derived from the write-pool size: one resolved client
|
||||
/// key (see [`client_key`]) may hold at most an eighth of the pool, so saturating it
|
||||
/// takes ~8 distinct keys. Real for an IPv4 or single-address caller; a caller with a
|
||||
/// routed IPv6 /64 has 2^64 keys, because `client_key` returns the full address with
|
||||
/// no prefix folding. Narrowing that keying is a deferred decision, so this cap is a
|
||||
/// bound per key, not a bound per operator.
|
||||
///
|
||||
/// Floored at 1 because the value feeds [`PerCallerConcurrency`], where a cap of 0
|
||||
/// would shed EVERY receive-pack advertisement and break all pushes. The floor is
|
||||
/// load-bearing at the minimum write-pool size (1), which integer-divides to 0.
|
||||
pub(crate) fn per_source_push_cap(max_concurrent_git_pushes: usize) -> usize {
|
||||
(max_concurrent_git_pushes / 8).max(1)
|
||||
}
|
||||
|
||||
/// A bounded per-caller CONCURRENCY limiter — distinct from [`RateLimiter`], which
|
||||
/// caps request RATE. Each caller key may hold at most `per_caller` in-flight
|
||||
/// permits at once; beyond that [`try_acquire`](Self::try_acquire) returns `None`
|
||||
/// and the caller sheds. Used to stop one caller (a single anonymous source-IP or
|
||||
/// DID) monopolizing the served-git read pool (#174).
|
||||
///
|
||||
/// The key map is self-bounding: a key is removed the instant its in-flight count
|
||||
/// reaches zero, so it never holds more keys than there are concurrently-active
|
||||
/// callers (itself bounded by the read semaphore). A `max_keys` reject-before-insert
|
||||
/// backstop guarantees a key farm can never grow the map even if that invariant
|
||||
/// weakened — a NEW key at the cap is rejected WITHOUT allocating an entry (INV-15).
|
||||
///
|
||||
/// Uses a `std::sync::Mutex` (not the file's `tokio::sync::Mutex`) because the
|
||||
/// permit's `Drop` must release synchronously; the critical section holds no await.
|
||||
#[derive(Clone)]
|
||||
pub struct PerCallerConcurrency {
|
||||
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
|
||||
per_caller: usize,
|
||||
max_keys: usize,
|
||||
}
|
||||
|
||||
/// RAII permit from [`PerCallerConcurrency::try_acquire`]. On drop it decrements
|
||||
/// the caller's in-flight count and removes the key when it reaches zero.
|
||||
pub struct PerCallerPermit {
|
||||
state: Arc<std::sync::Mutex<HashMap<String, usize>>>,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl PerCallerConcurrency {
|
||||
pub fn new(per_caller: usize, max_keys: usize) -> Self {
|
||||
Self {
|
||||
state: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
per_caller: per_caller.max(1),
|
||||
max_keys: max_keys.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor with the default key bound.
|
||||
pub fn with_default_max_keys(per_caller: usize) -> Self {
|
||||
Self::new(per_caller, DEFAULT_MAX_KEYS)
|
||||
}
|
||||
|
||||
/// `Some(permit)` when the caller is under its cap and the map has room;
|
||||
/// `None` (shed) otherwise. Reject-before-insert: a new key at `max_keys` is
|
||||
/// rejected without allocating.
|
||||
pub fn try_acquire(&self, key: &str) -> Option<PerCallerPermit> {
|
||||
// Recover from a poisoned lock rather than panicking: the critical section
|
||||
// is pure counter arithmetic and cannot itself panic, so a poisoned mutex
|
||||
// would only ever come from an unrelated abort, and a slightly-off count
|
||||
// self-heals as permits drop. A panic here would instead brick the limiter
|
||||
// for every caller (each subsequent lock re-panics).
|
||||
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match map.get_mut(key) {
|
||||
Some(count) => {
|
||||
if *count >= self.per_caller {
|
||||
return None;
|
||||
}
|
||||
*count += 1;
|
||||
}
|
||||
None => {
|
||||
if map.len() >= self.max_keys {
|
||||
return None;
|
||||
}
|
||||
map.insert(key.to_string(), 1);
|
||||
}
|
||||
}
|
||||
Some(PerCallerPermit {
|
||||
state: self.state.clone(),
|
||||
key: key.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PerCallerPermit {
|
||||
fn drop(&mut self) {
|
||||
let mut map = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(count) = map.get_mut(&self.key) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
map.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
@@ -288,197 +80,10 @@ 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::*;
|
||||
|
||||
#[test]
|
||||
fn per_caller_concurrency_caps_one_caller_and_frees_on_drop() {
|
||||
let lim = PerCallerConcurrency::new(2, 100);
|
||||
let p1 = lim.try_acquire("did:key:zA").expect("first under cap");
|
||||
let p2 = lim.try_acquire("did:key:zA").expect("second under cap");
|
||||
assert!(
|
||||
lim.try_acquire("did:key:zA").is_none(),
|
||||
"a third in-flight op for the same caller sheds (over the per-caller cap)"
|
||||
);
|
||||
// A DIFFERENT caller is unaffected — the cap is per-caller, not global.
|
||||
let _other = lim
|
||||
.try_acquire("did:key:zB")
|
||||
.expect("a different caller has its own budget");
|
||||
drop(p1);
|
||||
assert!(
|
||||
lim.try_acquire("did:key:zA").is_some(),
|
||||
"freeing one in-flight slot lets the same caller back in"
|
||||
);
|
||||
drop(p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_caller_concurrency_map_is_self_bounding_and_reject_before_insert() {
|
||||
// Self-bounding: acquire+drop many distinct keys — the map never grows
|
||||
// because a key is removed the instant its in-flight count hits zero.
|
||||
let lim = PerCallerConcurrency::new(4, 3);
|
||||
for i in 0..50 {
|
||||
let _p = lim.try_acquire(&format!("k{i}"));
|
||||
}
|
||||
assert_eq!(
|
||||
lim.tracked_keys(),
|
||||
0,
|
||||
"keys with zero in-flight ops are removed, so an acquire+drop flood leaves the map empty"
|
||||
);
|
||||
// Reject-before-insert: HOLD max_keys distinct keys, then a new key sheds
|
||||
// WITHOUT growing the map past the cap (INV-15 — a rejected request never
|
||||
// allocates an entry).
|
||||
let held: Vec<_> = (0..3)
|
||||
.map(|i| lim.try_acquire(&format!("h{i}")).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lim.tracked_keys(),
|
||||
3,
|
||||
"three distinct callers held concurrently"
|
||||
);
|
||||
assert!(
|
||||
lim.try_acquire("h3").is_none(),
|
||||
"a new key at max_keys is rejected"
|
||||
);
|
||||
assert_eq!(
|
||||
lim.tracked_keys(),
|
||||
3,
|
||||
"the rejected new key did not allocate an entry (reject-before-insert)"
|
||||
);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_within_limit() {
|
||||
let limiter = RateLimiter::new(3, Duration::from_secs(60));
|
||||
@@ -521,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,30 +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).
|
||||
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
|
||||
// request can drive a full-history git walk, so the per-IP rate limiter is the
|
||||
// outermost layer (rejects a flood before the walk-admission work), mirroring the
|
||||
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
|
||||
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
|
||||
let ipfs_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.ipfs_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(ipfs_limiter))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
.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));
|
||||
@@ -272,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(
|
||||
@@ -302,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()
|
||||
@@ -442,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),
|
||||
@@ -466,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));
|
||||
@@ -486,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)
|
||||
@@ -509,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!({
|
||||
@@ -543,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!({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+42
-1299
File diff suppressed because it is too large.
Load diff
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,85 +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)"
|
||||
);
|
||||
}
|
||||
|
||||
// #135 T1: a Mode-B rule on `/secret/**` must DENY the withheld directory's
|
||||
// OWN path `/secret` (the `path == prefix` arm), not just strict descendants —
|
||||
// otherwise get_by_cid's tree gate would serve the /secret tree object and leak
|
||||
// its children. Pins parity with get_tree, which denies the /secret path.
|
||||
#[test]
|
||||
fn subtree_rule_denies_the_withheld_directory_itself() {
|
||||
let reader = "did:key:z6MkReader";
|
||||
let rules = [rule("/secret/**", VisibilityMode::B, &[reader])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, "/secret"),
|
||||
Decision::Deny,
|
||||
"anon denied at the withheld directory's OWN path /secret"
|
||||
);
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, "/public"),
|
||||
Decision::Allow,
|
||||
"anon allowed at a sibling path outside the withheld subtree"
|
||||
);
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, Some(reader), "/secret"),
|
||||
Decision::Allow,
|
||||
"listed reader allowed at the withheld directory (caller-aware)"
|
||||
);
|
||||
}
|
||||
|
||||
// #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"])];
|
||||
@@ -582,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,573 +0,0 @@
|
||||
//! #174 U6 — INV-22 completeness guard (rung-raising).
|
||||
//!
|
||||
//! INV-22: a permit held per op recovers only if every path that holds it is also
|
||||
//! duration-bounded and reaps the process group before releasing admission, and every
|
||||
//! detached git/blocking task carries its own admission. PR #174 fixed five paths
|
||||
//! (U1-U5) that violated this. Each fix has a per-unit RED/GREEN regression; together
|
||||
//! those form the five-revert matrix. This guard adds the missing piece: a source-scan
|
||||
//! tripwire that fails when a NEW site reintroduces the class, or when one of the five
|
||||
//! gates is removed.
|
||||
//!
|
||||
//! It lives in `tests/` (a separate crate) on purpose: a guard that scanned the same
|
||||
//! file it lives in would match its own identifier literals and pass vacuously. Here
|
||||
//! the scanned `src/` files never contain this file's literals, so each check is
|
||||
//! load-bearing — reverting the named gate turns the assertion red.
|
||||
//!
|
||||
//! These are deliberately coarse structural checks, not a parser. They cannot prove a
|
||||
//! gate is *correct* (the per-unit tests do that); they prove a gate is *present and
|
||||
//! not bypassed*, which is what stops the class from silently regressing.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn src(rel: &str) -> String {
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(rel);
|
||||
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inv22_concurrency_gates_present_and_not_bypassed() {
|
||||
let repos = src("api/repos.rs");
|
||||
let smart_http = src("git/smart_http.rs");
|
||||
let vis = src("git/visibility_pack.rs");
|
||||
let ipfs = src("api/ipfs.rs");
|
||||
|
||||
// U1 / P1-a: run_bounded_git stands the watchdog down only after confirming the
|
||||
// child actually terminated (WNOWAIT), not on the raw stdout-drain EOF — otherwise
|
||||
// a child that closes stdout then hangs pins the permit past the deadline. The
|
||||
// probe is defined and called, so >= 2 occurrences; reverting the fix removes both.
|
||||
assert!(
|
||||
vis.matches("child_terminated_without_reaping").count() >= 2,
|
||||
"U1/P1-a gate missing: run_bounded_git must confirm child exit via \
|
||||
child_terminated_without_reaping before signalling the watchdog"
|
||||
);
|
||||
|
||||
// U2 / P1-c: on client disconnect KillGroupOnDrop must launch a detached reaper
|
||||
// that runs the full TERM/grace/KILL/reap, not a lone SIGTERM. The reaper is spawned
|
||||
// via a runtime handle in Drop; `Handle::try_current` is unique to that launch (the
|
||||
// timeout path already has an async context and never calls it).
|
||||
assert!(
|
||||
smart_http.contains("Handle::try_current"),
|
||||
"U2/P1-c gate missing: KillGroupOnDrop::drop must launch the full \
|
||||
TERM/grace/KILL reaper on disconnect (Handle::try_current), not a lone SIGTERM"
|
||||
);
|
||||
|
||||
// U4 / P1-d: git_receive_pack must acquire the per-source write sub-cap before the
|
||||
// global write permit. The acquire reads `state.git_write_per_caller`; comments name
|
||||
// the field without the `state.` prefix, so this targets the real acquire site.
|
||||
assert!(
|
||||
repos.contains("state.git_write_per_caller"),
|
||||
"U4/P1-d gate missing: git_receive_pack must acquire the per-source write cap \
|
||||
(state.git_write_per_caller) before the global write permit"
|
||||
);
|
||||
|
||||
// U5 / P1-e: the detached post-push encryption walk must run through the
|
||||
// admission-gated helper, which is wired to the shared encrypt pool.
|
||||
assert!(
|
||||
repos.contains("fn withheld_recipients_gated")
|
||||
&& repos.contains("state.git_encrypt_semaphore"),
|
||||
"U5/P1-e gate missing: the encryption walk must run through \
|
||||
withheld_recipients_gated, which acquires git_encrypt_semaphore"
|
||||
);
|
||||
|
||||
// U1 / R2 (#173 round-10): the path-scoped filtered-pack serve must thread the
|
||||
// caller's AdmissionGuard through BOTH git stages so read + per-caller admission is
|
||||
// held until the pack-objects group is reaped on disconnect, closing the cap bypass
|
||||
// the plain upload_pack path already fixed. Two load-bearing markers: rev-list hands
|
||||
// the disarmed guard back (its tuple return type), and build_filtered_pack forwards
|
||||
// that guard into the pack-objects stage (the `admission` arg after the
|
||||
// "pack-objects" label). Reverting either — dropping the guard between stages, or
|
||||
// passing `None` to pack-objects — trips this.
|
||||
assert!(
|
||||
smart_http.contains("Result<(Vec<String>, Option<AdmissionGuard>)>")
|
||||
&& smart_http.contains("\"pack-objects\",\n admission,"),
|
||||
"U1/R2 gate missing: build_filtered_pack must thread the AdmissionGuard through \
|
||||
rev-list -> pack-objects so the permits are held until the pack-objects group \
|
||||
is reaped on disconnect (the path-scoped half of #174 P1-a)"
|
||||
);
|
||||
|
||||
// U2 / R1: a cancelled or timed-out `GET /ipfs/{cid}` must release admission only
|
||||
// after the blocking work it admitted has finished, not the instant the handler
|
||||
// future drops (the /ipfs half of #174 P1-a). The mechanism is the shared
|
||||
// `Arc<WalkAdmission>`: both walk permits are moved into it once per request and a
|
||||
// clone rides every `spawn_blocking`, so the permits release when the LAST holder
|
||||
// drops. Reverting to handler-local permits trips this; the per-site clones are
|
||||
// bound separately by `inv22_ipfs_walk_admission_reaches_every_blocking_site`.
|
||||
//
|
||||
// This previously required the permits to be owned by a detached `tokio::spawn`
|
||||
// running the whole pipeline. That closed the same bypass but kept an abandoned
|
||||
// request's full legacy scan running against a held slot, so the merge of #173 and
|
||||
// #174 kept the Arc and dropped the detached task.
|
||||
assert!(
|
||||
ipfs.contains("struct WalkAdmission")
|
||||
&& ipfs.contains("let admission = std::sync::Arc::new(WalkAdmission {"),
|
||||
"U2/R1 gate missing: get_by_cid must move both /ipfs admission permits into a \
|
||||
shared Arc<WalkAdmission> whose clones ride the blocking work, so admission is \
|
||||
released only once that work completes (the /ipfs half of #174 P1-a)"
|
||||
);
|
||||
|
||||
// U2 / KTD2 (#173 round-10): the probe/read children on the /ipfs path must be the
|
||||
// duration-bounded twins (process-group teardown via run_bounded_git), not the bare
|
||||
// `store::object_type` / `read_object_content` (or an unbounded `cat-file -s`) a tokio
|
||||
// timeout cannot cancel — otherwise a wedged cat-file lingers and pins the held
|
||||
// admission past the deadline. Reverting any twin call site back to a bare read trips
|
||||
// this.
|
||||
assert!(
|
||||
ipfs.contains("object_type_bounded(")
|
||||
&& ipfs.contains("object_size_bounded(")
|
||||
&& ipfs.contains("read_object_content_bounded("),
|
||||
"U2/KTD2 gate missing: the /ipfs probe+read must call the run_bounded_git-backed \
|
||||
*_bounded twins so a wedged cat-file is reaped at the deadline, not left to pin \
|
||||
the held /ipfs walk admission"
|
||||
);
|
||||
|
||||
// P1-e non-bypass tripwire: the bounded recipients walk is spawn_blocking'd nowhere
|
||||
// but inside withheld_recipients_gated. A second call site (count > 1) is a new
|
||||
// detached git walk that skips the admission gate — exactly the class U5 closed.
|
||||
assert_eq!(
|
||||
repos.matches("withheld_blob_recipients_bounded").count(),
|
||||
1,
|
||||
"P1-e bypass: the bounded recipients walk must be invoked only inside \
|
||||
withheld_recipients_gated; a new call site bypasses the encrypt-walk admission cap"
|
||||
);
|
||||
|
||||
// U4 / P2-2: the detached post-push encryption task must be gated by the per-repo
|
||||
// coalescing set (`encrypt_inflight.try_begin`) so the OUTSTANDING parked-task set is
|
||||
// bounded to <=1 per repo. Removing the gate lets N rapid pushes spawn N parked
|
||||
// waiters (the unbounded set U4 closed); the semaphore only caps active walks.
|
||||
assert!(
|
||||
repos.contains("state.encrypt_inflight.try_begin"),
|
||||
"U4/P2-2 gate missing: the detached post-push encryption spawn must consult \
|
||||
encrypt_inflight.try_begin to coalesce per repo (bound the outstanding-task set)"
|
||||
);
|
||||
|
||||
// F5: coalescing must REQUEUE, not shed. The in-flight task pins only its own
|
||||
// pre-spawn snapshot, so a coalesced push's tip pairs are recorded on its key and
|
||||
// the task must loop-drain them (`finish_or_take_pending`) before releasing it.
|
||||
// Removing the drain reverts to the silent loss: a coalesced push's pins and
|
||||
// recovery copies are absent until an unrelated later push. Scan only the
|
||||
// production half of the file — the u5 tests in its `mod tests` also name the
|
||||
// drain call, and matching them would make this check vacuous.
|
||||
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
|
||||
// test-only items (the drain fault seam, the task entry point) ABOVE the production
|
||||
// code these gates scan for, so splitting on the attribute truncated the production
|
||||
// half above every line being checked and the gate went vacuously blind.
|
||||
let repos_production = repos
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
assert!(
|
||||
repos_production.contains("finish_or_take_pending"),
|
||||
"F5 gate missing: the post-push encryption task must loop-drain coalesced \
|
||||
pushes via finish_or_take_pending before releasing its repo key"
|
||||
);
|
||||
|
||||
// F4: every post-receive scan helper admits itself to the shared scan pool via
|
||||
// `crate::state::acquire_scan_permit` BEFORE its spawn_blocking git walk, so a
|
||||
// push burst cannot accumulate unbounded concurrent scans once the write permit
|
||||
// is released. Two halves, both load-bearing: the helper body must actually
|
||||
// acquire the pool (state.rs sits the helper at the end of the file, so the
|
||||
// definition tail contains no other `acquire_owned` to match vacuously), and
|
||||
// within each scan helper the first qualified call precedes the first
|
||||
// `spawn_blocking`. Severing a call site pushes the next occurrence past the
|
||||
// helper's own `spawn_blocking` (or off the end of the file), turning the
|
||||
// assertion red; comments name the helper without the `crate::state::` prefix,
|
||||
// so this targets the real call sites.
|
||||
let state_rs = src("state.rs");
|
||||
let helper_def = state_rs
|
||||
.find("fn acquire_scan_permit")
|
||||
.expect("F4 gate missing: state.rs no longer defines acquire_scan_permit");
|
||||
assert!(
|
||||
state_rs[helper_def..].contains("acquire_owned"),
|
||||
"F4 gate gutted: acquire_scan_permit must acquire the scan pool via acquire_owned"
|
||||
);
|
||||
let push_delta = src("git/push_delta.rs");
|
||||
for (file_src, file, helper) in [
|
||||
(&repos, "api/repos.rs", "fn replication_withheld_set"),
|
||||
(&repos, "api/repos.rs", "fn fail_closed_full_scan_objects"),
|
||||
(
|
||||
&push_delta,
|
||||
"git/push_delta.rs",
|
||||
"fn resolve_candidates_for_push",
|
||||
),
|
||||
] {
|
||||
let start = file_src
|
||||
.find(helper)
|
||||
.unwrap_or_else(|| panic!("{file}: `{helper}` not found"));
|
||||
let tail = &file_src[start..];
|
||||
let acquire = tail
|
||||
.find("crate::state::acquire_scan_permit(")
|
||||
.unwrap_or_else(|| {
|
||||
panic!("F4 gate missing: {file} `{helper}` no longer acquires a scan permit")
|
||||
});
|
||||
let spawn = tail.find("spawn_blocking").unwrap_or_else(|| {
|
||||
panic!("{file}: `{helper}` lost its spawn_blocking walk — update this guard")
|
||||
});
|
||||
assert!(
|
||||
acquire < spawn,
|
||||
"F4 gate bypassed: {file} `{helper}` must acquire its git_encrypt_semaphore \
|
||||
permit BEFORE dispatching the blocking git scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// F4 (repo_store advisory-unlock cancellation safety): `RepoWriteGuard::release`
|
||||
/// must await `pg_advisory_unlock` while `self` still owns the pooled connection,
|
||||
/// and must not mark itself `released` until that await resolves. Either shape,
|
||||
/// reintroduced, re-opens the mid-unlock cancellation leak: taking the connection
|
||||
/// early leaves `Drop` with `conn == None`, and setting `released = true` early
|
||||
/// leaves the `Drop` backstop inert — both strand the session lock on cancellation.
|
||||
///
|
||||
/// Scoped to the `release` fn body: the `Drop` impl legitimately takes the
|
||||
/// connection and unlocks, so a whole-file scan would match it and read as a false
|
||||
/// pass. Reverting either ordering turns this red (proven load-bearing).
|
||||
#[test]
|
||||
fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
let repo_store = src("git/repo_store.rs");
|
||||
|
||||
let rel_start = repo_store
|
||||
.find("pub async fn release(mut self")
|
||||
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
|
||||
let rel_end = repo_store[rel_start..]
|
||||
.find("impl Drop for RepoWriteGuard")
|
||||
.map(|off| rel_start + off)
|
||||
.expect("F4 gate: release fn / Drop impl markers moved — update this guard");
|
||||
let release_body = &repo_store[rel_start..rel_end];
|
||||
|
||||
let unlock = release_body
|
||||
.find("pg_advisory_unlock")
|
||||
.expect("F4 gate: release must still issue pg_advisory_unlock");
|
||||
let before_unlock = &release_body[..unlock];
|
||||
|
||||
// (a) the connection must still be owned by `self` at the unlock await.
|
||||
assert!(
|
||||
!before_unlock.contains("self.conn.take()"),
|
||||
"F4 regression: RepoWriteGuard::release takes self.conn BEFORE awaiting \
|
||||
pg_advisory_unlock. A cancellation during the unlock await then strands the \
|
||||
session advisory lock (Drop sees conn == None and skips its backstop). \
|
||||
Unlock through the still-owned connection instead."
|
||||
);
|
||||
// (b) `released` must not be set before the unlock await — the other
|
||||
// reintroduction shape a single-reorder check on (a) alone is blind to.
|
||||
assert!(
|
||||
!before_unlock.contains("released = true"),
|
||||
"F4 regression: RepoWriteGuard::release sets `released = true` BEFORE awaiting \
|
||||
pg_advisory_unlock. A cancellation during the await then leaves the Drop \
|
||||
backstop inert (it early-returns on released). Set released only AFTER the \
|
||||
unlock await resolves."
|
||||
);
|
||||
}
|
||||
|
||||
/// F6/KTD-5 (initial IPFS metadata queries deadline-wrapped): `get_by_cid` acquires
|
||||
/// the scarce walk permits (RAII, held for the whole request) BEFORE its two initial
|
||||
/// metadata queries, and the per-repo loop's first budget gate runs only later. So
|
||||
/// both `list_all_repos` and `list_visibility_rules_for_repos` must be clamped to the
|
||||
/// remaining request budget — otherwise a query blocked in Postgres pins the walk slot
|
||||
/// for the whole stall, past the budget. This scans the PRODUCTION half of `api/ipfs.rs`
|
||||
/// (the `mod tests` half names the same calls in its own harness and would make the
|
||||
/// check vacuous) and asserts each query call is immediately preceded by a
|
||||
/// `tokio::time::timeout(` wrapper. Removing either wrapper turns this red (proven
|
||||
/// load-bearing).
|
||||
#[test]
|
||||
fn f6_ipfs_metadata_queries_are_deadline_wrapped() {
|
||||
let ipfs = src("api/ipfs.rs");
|
||||
// Split at the TEST MODULE, not at the first `#[cfg(test)]`: the handler carries
|
||||
// in-line `#[cfg(test)]` query counters, so splitting on the attribute cut the
|
||||
// production half off above the calls this guard exists to check and the scan went
|
||||
// vacuously quiet (found 0 of each rather than failing on a missing wrapper).
|
||||
let production = ipfs
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// EVERY occurrence must be wrapped, not just the first. The handler now reaches
|
||||
// these queries from two places (the provenance fast path, one repo at a time, and
|
||||
// the legacy scan's preload), and an exact-count check would have to be relaxed
|
||||
// every time a call site is added, which is how a guard quietly stops covering the
|
||||
// site that matters. Requiring all of them scales with the code instead.
|
||||
for call in [
|
||||
".list_all_repos()",
|
||||
".list_visibility_rules_for_repos(",
|
||||
".get_repo_by_id(",
|
||||
".is_repo_quarantined(",
|
||||
] {
|
||||
let occurrences = production.matches(call).count();
|
||||
assert!(
|
||||
occurrences >= 1,
|
||||
"F6 guard stale: api/ipfs.rs no longer calls `{call}` — update this guard"
|
||||
);
|
||||
for (n, (idx, _)) in production.match_indices(call).enumerate() {
|
||||
// The wrapper opens a few lines above the call (match tokio::time::timeout(
|
||||
// ... remaining budget ..., <query> )); a 240-char lookback covers it
|
||||
// without reaching the previous statement.
|
||||
let window = &production[idx.saturating_sub(240)..idx];
|
||||
assert!(
|
||||
window.contains("tokio::time::timeout("),
|
||||
"F6 gate missing: occurrence {n} of `{call}` (of {occurrences}) is not \
|
||||
wrapped in tokio::time::timeout(...) clamped to the remaining request \
|
||||
budget. An unwrapped await pins the held walk permit for the whole DB \
|
||||
stall, past GITLAWB_IPFS_REQUEST_BUDGET_SECS."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #174 F2 / KTD-3: the detached post-receive Pinata replication task must enqueue
|
||||
/// only the push's `(ref, old, new)` tuples and RE-DERIVE its object set inside the
|
||||
/// worker once a pin slot frees — it must NOT move the full per-push object list into
|
||||
/// the closure and hold it across the `pin_semaphore` acquire. Retaining the list makes
|
||||
/// every parked task (under a slow Pinata backend) hold an MB-scale OID list, so
|
||||
/// outstanding memory grows O(pushes x object-list). Coalescing/shedding the task is
|
||||
/// forbidden (its per-ref effects are non-idempotent), so the fix bounds the retained
|
||||
/// data, not the task count.
|
||||
///
|
||||
/// Two load-bearing checks, both against the PRODUCTION half of `api/repos.rs` (the
|
||||
/// `mod tests` half names the same identifiers in its own harness and would make the
|
||||
/// scan vacuous): (a) the closure-local `object_list_pinata` binding — the retain form —
|
||||
/// must be gone; reintroducing `let object_list_pinata = object_list;` turns this red.
|
||||
/// (b) the re-derivation (`pinata_object_list_for_refs`) must appear AFTER the Pinata
|
||||
/// pin permit is acquired, so the object list is materialized only inside the pin-bounded
|
||||
/// section and a parked task holds O(ref tuples).
|
||||
#[test]
|
||||
fn f2_pinata_enqueues_refs_not_retained_object_lists() {
|
||||
let repos = src("api/repos.rs");
|
||||
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
|
||||
// test-only items (the drain fault seam, the task entry point) ABOVE the production
|
||||
// code these gates scan for, so splitting on the attribute truncated the production
|
||||
// half above every line being checked and the gate went vacuously blind.
|
||||
let production = repos
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// (a) the retained-list form must be gone from production.
|
||||
assert!(
|
||||
!production.contains("object_list_pinata"),
|
||||
"F2/KTD-3 regression: the Pinata task retains a full per-push object list \
|
||||
(`object_list_pinata`) across the pin-permit acquire. Enqueue only the ref \
|
||||
tuples and re-derive the object set inside the worker (pinata_object_list_for_refs)."
|
||||
);
|
||||
|
||||
// (b) the re-derivation runs AFTER the pin permit is acquired. Anchor on the
|
||||
// Pinata pin-admission clone so the window is the Pinata task, not the sibling
|
||||
// IPFS/encrypt spawn (which shares `pin_semaphore` but never re-derives).
|
||||
let anchor = production
|
||||
.find("let pin_sem_pinata")
|
||||
.expect("F2 gate stale: the Pinata pin-admission clone (pin_sem_pinata) moved");
|
||||
let tail = &production[anchor..];
|
||||
let acquire = tail
|
||||
.find(".acquire_owned()")
|
||||
.expect("F2 gate: the Pinata task no longer acquires a pin permit (acquire_owned)");
|
||||
let rederive = tail.find("pinata_object_list_for_refs(").expect(
|
||||
"F2 gate missing: the Pinata task must re-derive its object set via \
|
||||
pinata_object_list_for_refs; it can no longer move a pre-resolved list into the closure",
|
||||
);
|
||||
assert!(
|
||||
acquire < rederive,
|
||||
"F2 gate bypassed: the Pinata object-set re-derivation must run AFTER the pin \
|
||||
permit is acquired, so a parked task never holds the MB-scale object list"
|
||||
);
|
||||
|
||||
// The re-derivation is driven by the push's ref tuples (the enqueued unit), not a
|
||||
// retained object list — tie "enqueue ref_updates" to the call explicitly.
|
||||
assert!(
|
||||
tail[rederive..].starts_with("pinata_object_list_for_refs(")
|
||||
&& tail[rederive..]
|
||||
.get(..400)
|
||||
.map(|w| w.contains("ref_updates_clone"))
|
||||
.unwrap_or(false),
|
||||
"F2 gate: pinata_object_list_for_refs must re-derive from the push's ref tuples \
|
||||
(ref_updates_clone), the small unit the parked task retains"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U2 / F3 (second same-repo writer serialized until a disconnected first push's
|
||||
/// git group is reaped): `git_receive_pack` must take the per-repo in-process write
|
||||
/// lease and CARRY it on the write-path `AdmissionGuard` (via `.with_lease`), so the
|
||||
/// lease rides `KillGroupOnDrop`'s detached reaper and frees only after the group is
|
||||
/// reaped — supplementing the pg advisory lock, which `RepoWriteGuard::Drop` releases at
|
||||
/// the disconnect instant. Tying the lease to `RepoWriteGuard` instead (or dropping the
|
||||
/// `.with_lease` carry) reopens the race; the behavioral RED/GREEN test
|
||||
/// (`f3_second_push_serialized_until_disconnected_group_reaped`) is the real bar, this
|
||||
/// is the completeness tripwire. Scanned against the PRODUCTION half of `api/repos.rs`
|
||||
/// (the `mod tests` half names these identifiers in its own harness).
|
||||
#[test]
|
||||
fn f3_second_writer_leased_until_reap() {
|
||||
let repos = src("api/repos.rs");
|
||||
let smart_http = src("git/smart_http.rs");
|
||||
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
|
||||
// test-only items (the drain fault seam, the task entry point) ABOVE the production
|
||||
// code these gates scan for, so splitting on the attribute truncated the production
|
||||
// half above every line being checked and the gate went vacuously blind.
|
||||
let repos_production = repos
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// The lease is acquired, then carried by the AdmissionGuard (.with_lease), before
|
||||
// receive_pack runs the write. Severing any of the three turns this red.
|
||||
let lease_acquire = repos_production.find("repo_write_leases").expect(
|
||||
"F3 gate missing: git_receive_pack no longer takes the per-repo write lease \
|
||||
(state.repo_write_leases)",
|
||||
);
|
||||
let with_lease = repos_production.find(".with_lease(").expect(
|
||||
"F3 gate missing: the write-path AdmissionGuard no longer carries the lease \
|
||||
(.with_lease). The lease must ride the disconnect reaper via the AdmissionGuard, \
|
||||
NOT RepoWriteGuard (which drops at the disconnect instant, reopening F3).",
|
||||
);
|
||||
let receive = repos_production
|
||||
.find("smart_http::receive_pack(")
|
||||
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack");
|
||||
assert!(
|
||||
lease_acquire < with_lease && with_lease < receive,
|
||||
"F3 gate bypassed: the write lease must be acquired, then carried by the \
|
||||
AdmissionGuard (.with_lease), BEFORE receive_pack runs the write"
|
||||
);
|
||||
|
||||
// The AdmissionGuard must actually hold the lease (Option<RepoWriteLease>) via a
|
||||
// with_lease setter, so it travels into the detached reaper. Removing the field or
|
||||
// method turns this red.
|
||||
assert!(
|
||||
smart_http.contains("_lease: Option<crate::state::RepoWriteLease>")
|
||||
&& smart_http.contains("pub fn with_lease("),
|
||||
"F3 gate missing: AdmissionGuard must hold an Option<RepoWriteLease> set via \
|
||||
with_lease, so the lease rides the guard into KillGroupOnDrop's reaper"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U1 — every blocking walk in the `/ipfs` scan carries the request's walk
|
||||
/// admission.
|
||||
///
|
||||
/// The admission is an `Arc<WalkAdmission>` cloned into each `spawn_blocking`
|
||||
/// closure, so the global + per-source permits release only when the last holder
|
||||
/// drops: a client disconnect leaves the abandoned closure holding the slot, and a
|
||||
/// panicking closure leaves the handler holding it.
|
||||
///
|
||||
/// Only the walk site runs under `state.git_bin`, so only it can be pinned by the
|
||||
/// fake-git harness and mutation-verified dynamically (see
|
||||
/// `get_by_cid_walk_permit_held_through_blocking_walk`). The probe and the content
|
||||
/// read deliberately shell to the real `git`, which is why this structural check
|
||||
/// exists: it binds all three sites, and any blocking site added to this loop
|
||||
/// later, without reversing that deliberate independence.
|
||||
///
|
||||
/// MUTATION (RED): delete any one `Arc::clone(ctx.admission)` binding, or drop the
|
||||
/// clone from inside its closure, and the count falls below three.
|
||||
#[test]
|
||||
fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
|
||||
let ipfs = src("api/ipfs.rs");
|
||||
|
||||
// The shared owner must exist and be built once per request.
|
||||
assert!(
|
||||
ipfs.contains("struct WalkAdmission") && ipfs.contains("Arc::new(WalkAdmission {"),
|
||||
"U1 gate missing: the /ipfs walk admission must be a shared WalkAdmission, \
|
||||
not a handler-local permit pair"
|
||||
);
|
||||
|
||||
// Every blocking site in the scan takes its own clone...
|
||||
// `Arc::clone(ctx.admission)` in the gate, `Arc::clone(&admission)` if a site is
|
||||
// ever added back in the handler body itself; count both spellings.
|
||||
let clones = ipfs.matches("Arc::clone(ctx.admission)").count()
|
||||
+ ipfs.matches("Arc::clone(&admission)").count();
|
||||
assert!(
|
||||
clones >= 3,
|
||||
"U1 gate bypassed: expected an admission clone for each of the three \
|
||||
/ipfs spawn_blocking sites (probe, walk, read); found {clones}. A blocking \
|
||||
walk that does not hold the admission lets a disconnect or a panic free the \
|
||||
slot while its git child is still running."
|
||||
);
|
||||
|
||||
// ...and each clone is actually moved INTO the blocking closure, not merely
|
||||
// created in the async frame (which would hold nothing across the join).
|
||||
let held = ipfs.matches("let _admission = ").count();
|
||||
assert!(
|
||||
held >= 3,
|
||||
"U1 gate bypassed: each admission clone must be bound inside its \
|
||||
spawn_blocking closure so the blocking work owns it; found {held} of 3."
|
||||
);
|
||||
|
||||
// The count of blocking sites is itself the thing being covered: if a fourth
|
||||
// appears, it needs an admission clone too and this gate must be revisited.
|
||||
let sites = ipfs.matches("spawn_blocking(move ||").count();
|
||||
assert_eq!(
|
||||
sites, 3,
|
||||
"the /ipfs scan grew or lost a spawn_blocking site ({sites} found, expected 3); \
|
||||
give any new blocking walk its own Arc::clone(&admission) and update this gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 U5: the post-receive replication tail is spawned at the DURABILITY BOUNDARY,
|
||||
/// which is the moment receive-pack returns success, not the end of the handler and
|
||||
/// not after `guard.release()`.
|
||||
///
|
||||
/// The tail owes this push its pins, recovery copy, and announcements. Everything
|
||||
/// below the spawn stays in the cancellable request future, so anything the tail is
|
||||
/// spawned after is a window where a client disconnect drops that work while the pack
|
||||
/// is already durable on disk. `guard.release()` is such a window: on success it
|
||||
/// awaits the Tigris upload and then the advisory unlock.
|
||||
///
|
||||
/// The lower bound matters just as much as the upper one: `release` runs on failure
|
||||
/// too, so an ungated spawn would fire for a push git rejected, pinning and announcing
|
||||
/// a half-applied repo. Above `release` the `?` on `receive_result` can no longer be
|
||||
/// what gates it, so the success check is explicit and this gate binds it: the spawn
|
||||
/// must sit inside `if push_succeeded`, and `release` must consume the same flag so
|
||||
/// the two cannot drift apart.
|
||||
///
|
||||
/// This is an ordering check rather than a cancellation-race test on purpose: it is
|
||||
/// the companion to `receive_pack_tail_survives_a_disconnect_during_release`, which
|
||||
/// drives the actual disconnect through a parked `release`. Same instrument the F3
|
||||
/// gate above uses.
|
||||
///
|
||||
/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below
|
||||
/// `guard.release(` and the ordering assertion fails; take it out of the
|
||||
/// `if push_succeeded` block and the failed-push assertion fails.
|
||||
#[test]
|
||||
fn inv22_replication_tail_spawns_at_the_durability_boundary() {
|
||||
let repos = src("api/repos.rs");
|
||||
// Production half only — the tests below name these identifiers too.
|
||||
// Split at the TEST MODULE, not at the first `#[cfg(test)]`. api/repos.rs carries
|
||||
// test-only items (the drain fault seam, the task entry point) ABOVE the production
|
||||
// code these gates scan for, so splitting on the attribute truncated the production
|
||||
// half above every line being checked and the gate went vacuously blind.
|
||||
let production = repos
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
let success_flag = production
|
||||
.find("let push_succeeded = receive_result.is_ok();")
|
||||
.expect(
|
||||
"U5 gate missing: the tail's success gate must be bound from receive_result.is_ok()",
|
||||
);
|
||||
let gate_open = production
|
||||
.find("if push_succeeded {")
|
||||
.expect("U5 gate missing: the tail spawn must be gated on the push having succeeded");
|
||||
let spawn = production
|
||||
.find("tokio::spawn(post_receive_replication_tail(")
|
||||
.expect("U5 gate missing: the replication tail must be spawned by git_receive_pack");
|
||||
let release = production
|
||||
.find(".release(push_succeeded)")
|
||||
.expect("U5 gate stale: release must consume the same success flag as the tail gate");
|
||||
let touch = production
|
||||
.find("state.db.touch_repo(")
|
||||
.expect("U5 gate stale: git_receive_pack no longer calls touch_repo");
|
||||
let webhook = production
|
||||
.find("webhooks::fire_event(")
|
||||
.expect("U5 gate stale: git_receive_pack no longer fires push webhooks");
|
||||
|
||||
assert!(
|
||||
success_flag < gate_open && gate_open < spawn,
|
||||
"U5 gate bypassed: the tail must be spawned inside `if push_succeeded`, or a \
|
||||
rejected push spawns a tail that pins and announces a half-applied repo"
|
||||
);
|
||||
// Still inside that block: no `}` may close it between the gate and the spawn.
|
||||
assert!(
|
||||
!production[gate_open + "if push_succeeded {".len()..spawn].contains('}'),
|
||||
"U5 gate bypassed: the tail spawn left the `if push_succeeded` block, so a \
|
||||
rejected push now spawns a tail"
|
||||
);
|
||||
assert!(
|
||||
spawn < release && spawn < touch && spawn < webhook,
|
||||
"U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \
|
||||
and the webhook fan-out, so a disconnect in any of those windows cannot drop \
|
||||
this push's pins, recovery copy, and announcements"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
version.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
-575
@@ -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,583 +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")?
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub(crate) 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
|
||||
}
|
||||
req.send().await.with_context(|| format!("PUT {url}"))
|
||||
}
|
||||
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).
|
||||
pub(crate) 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::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use mockito::Server;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
/// 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());
|
||||
|
||||
/// 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(),
|
||||
);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
#[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");
|
||||
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(())
|
||||
}
|
||||
|
||||
+14
-334
@@ -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 {
|
||||
@@ -33,32 +28,24 @@ pub enum IpfsCmd {
|
||||
cid: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(args: IpfsArgs) -> Result<()> {
|
||||
match args.cmd {
|
||||
IpfsCmd::List { node, dir } => cmd_list(node, dir).await,
|
||||
IpfsCmd::Get { cid, node, dir } => cmd_get(cid, 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);
|
||||
@@ -89,32 +76,11 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_get(cid: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
// #173 (F5): the resolver now serves path-scoped objects to authorized readers,
|
||||
// so sign with an available identity like `gl ipfs list` — otherwise an owner or
|
||||
// listed reader gets the opaque anonymous 404 for content they can read.
|
||||
// `get_authed` signs when a keypair is present and falls back to unsigned.
|
||||
//
|
||||
// An explicit `--dir` is a request to use THAT identity: propagate a
|
||||
// missing/corrupt-keystore error (like `list`) instead of silently sending an
|
||||
// anonymous request the authorized reader would see as the node's opaque 404
|
||||
// (#173 review). Only the default (no `--dir`) keeps the best-effort unsigned
|
||||
// fallback, so `get` stays usable for genuinely public content.
|
||||
let keypair = match dir.as_deref() {
|
||||
Some(dir) => Some(crate::identity::load_keypair_from_dir(Some(dir))?),
|
||||
None => crate::identity::load_keypair_from_dir(None).ok(),
|
||||
};
|
||||
let client = NodeClient::new(&node, keypair);
|
||||
// #173 review (F1): the node now accepts equivalent multibase spellings,
|
||||
// including base64 CIDs (prefix 'm'), whose alphabet contains '/', '+', '='.
|
||||
// Interpolating the CID raw would make the client request (and sign)
|
||||
// `/ipfs/<prefix>/<suffix>`, which neither matches the single-segment Axum
|
||||
// route nor points at the intended target. Percent-encode the CID as exactly
|
||||
// one path segment so the signed and sent target agree and the server's
|
||||
// `Path` extractor decodes it back to the original CID.
|
||||
let path = format!("/ipfs/{}", encode_cid_segment(&cid));
|
||||
async fn cmd_get(cid: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let path = format!("/ipfs/{cid}");
|
||||
let resp = client
|
||||
.get_authed(&path)
|
||||
.get(&path)
|
||||
.await
|
||||
.with_context(|| format!("failed to fetch CID {cid} from {node}"))?;
|
||||
|
||||
@@ -142,289 +108,3 @@ async fn cmd_get(cid: String, node: String, dir: Option<PathBuf>) -> Result<()>
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Percent-encode a CID so it occupies exactly one path segment of `/ipfs/<cid>`.
|
||||
/// `urlencoding::encode` escapes every byte outside the RFC 3986 unreserved set
|
||||
/// (ALPHA / DIGIT / `-._~`), so the base64-CID characters that would otherwise
|
||||
/// break the single-segment route — `/`, `+`, `=` — are all escaped, and the
|
||||
/// server's `Path` extractor decodes the result back to the original CID (#173
|
||||
/// review, F1).
|
||||
fn encode_cid_segment(cid: &str) -> String {
|
||||
urlencoding::encode(cid).into_owned()
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
/// #173 (F5): `gl ipfs get` must SIGN with an available identity, like
|
||||
/// `gl ipfs list`, so an owner/reader can retrieve a path-scoped object the node
|
||||
/// now resolves by CID. RED before the fix: cmd_get ignores the identity dir and
|
||||
/// sends an unsigned request, so the signature-matching mock is never hit
|
||||
/// (cmd_get errors on the unmatched 501, and m.assert fails). GREEN after: the
|
||||
/// signed request carries the RFC 9421 headers and is served 200.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_signs_when_identity_present() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let keystore = seed_keystore();
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreitestcid")
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/octet-stream")
|
||||
.with_header("x-git-hash", "abc123")
|
||||
.with_body("object bytes")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_get(
|
||||
"bafkreitestcid".to_string(),
|
||||
server.url(),
|
||||
Some(keystore.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.expect("signed get of a resolvable object should succeed");
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 (F5) must-not: a genuine anonymous denial must surface as an error, not
|
||||
/// be masked as success. With no identity dir the request is unsigned; a 404
|
||||
/// from the node must produce an Err mentioning the status.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_anonymous_denial_is_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreidenied")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("no git object found")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_get("bafkreidenied".to_string(), server.url(), None)
|
||||
.await
|
||||
.expect_err("a 404 denial must be an error, not masked success");
|
||||
assert!(
|
||||
err.to_string().contains("404"),
|
||||
"error should mention the status, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 (INV-8) must-not: the node's new 503 "search incomplete" (the legacy CID
|
||||
/// scan hit its bound and could not prove absence) must surface as an actionable
|
||||
/// Err naming the status, NOT be rendered as an empty/"not found" success — a
|
||||
/// retryable outcome the caller has to see. Mirrors the 404 denial case for the
|
||||
/// bounded-search response the resolver now emits.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_search_incomplete_503_is_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreiincomplete")
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"search_incomplete","message":"CID search incomplete — retry"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_get("bafkreiincomplete".to_string(), server.url(), None)
|
||||
.await
|
||||
.expect_err("a 503 incomplete-search must be an error, not masked as not-found");
|
||||
assert!(
|
||||
err.to_string().contains("503"),
|
||||
"error should mention the status, got: {err}"
|
||||
);
|
||||
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #173 review (F1): a base64 CID (multibase prefix 'm') can contain '/', '+',
|
||||
/// and '='. The client must percent-encode it into ONE path segment before
|
||||
/// building and signing `/ipfs/<cid>`; otherwise the '/' splits the target so
|
||||
/// it misses the single-segment Axum route and the signature covers the wrong
|
||||
/// path. Assert the encoded segment carries no raw '/', '+', or '=', and that
|
||||
/// it decodes back to the original CID (the server's `Path` extractor performs
|
||||
/// that same decode). RED with the old raw `format!("/ipfs/{cid}")`: the
|
||||
/// segment still contains '/'.
|
||||
#[test]
|
||||
fn test_encode_cid_segment_escapes_base64_alphabet() {
|
||||
let cid = "mFoo/Bar+baz==";
|
||||
let encoded = encode_cid_segment(cid);
|
||||
|
||||
assert!(
|
||||
!encoded.contains('/'),
|
||||
"encoded CID must be a single path segment (no raw '/'), got: {encoded}"
|
||||
);
|
||||
assert!(
|
||||
!encoded.contains('+'),
|
||||
"encoded CID must escape '+', got: {encoded}"
|
||||
);
|
||||
assert!(
|
||||
!encoded.contains('='),
|
||||
"encoded CID must escape '=', got: {encoded}"
|
||||
);
|
||||
|
||||
let decoded = urlencoding::decode(&encoded).expect("encoded CID must decode");
|
||||
assert_eq!(
|
||||
decoded, cid,
|
||||
"encoding must round-trip back to the original CID"
|
||||
);
|
||||
}
|
||||
|
||||
/// #173 review: `gl ipfs get --dir <path>` must PROPAGATE a missing/corrupt
|
||||
/// identity-load error like `gl ipfs list`, not silently fall back to an anonymous
|
||||
/// request — otherwise an authorized reader pointing `--dir` at a broken keystore
|
||||
/// gets the node's opaque 404 instead of the actionable key-load error. The
|
||||
/// unsigned fallback is preserved only when NO `--dir` is given (covered by
|
||||
/// `test_cmd_get_anonymous_denial_is_error`). RED before the fix (`.ok()` swallows
|
||||
/// the error, an anonymous request is sent, and the `.expect(0)` mock is hit),
|
||||
/// GREEN after.
|
||||
#[tokio::test]
|
||||
async fn test_cmd_get_explicit_dir_no_identity_errors_without_request() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Empty keystore dir passed explicitly via --dir: no identity.pem present.
|
||||
let empty = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// The endpoint must never be hit when an explicit --dir fails to load.
|
||||
let m = server
|
||||
.mock("GET", "/ipfs/bafkreitestcid")
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_get(
|
||||
"bafkreitestcid".to_string(),
|
||||
server.url(),
|
||||
Some(empty.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.expect_err("an explicit --dir that fails to load must be an error");
|
||||
assert!(
|
||||
err.to_string().contains("gl identity new")
|
||||
|| err.to_string().contains("no identity found")
|
||||
|| err.to_string().contains("failed to load keypair"),
|
||||
"error should name the key-load failure, 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);
|
||||
}
|
||||
}
|
||||
+12
-406
@@ -7,7 +7,7 @@ use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{read_body_capped, sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -97,74 +97,6 @@ async fn cmd_list(node: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warning text for a local peer-add reply, or `None` when the node
|
||||
/// accepted it.
|
||||
///
|
||||
/// Split out so the refusal path is assertable. The node now answers 403 when
|
||||
/// an unproven caller tries to repoint an existing peer's `http_url`, so a
|
||||
/// reply that arrived and refused must not reach the success line.
|
||||
fn local_add_refusal(status: reqwest::StatusCode, body: &Value) -> Option<String> {
|
||||
if status.is_success() {
|
||||
return None;
|
||||
}
|
||||
// The message is the node's, not ours, so it is defanged before it reaches
|
||||
// the terminal. Only the message: the status and the prose are ours.
|
||||
let msg = sanitize_node_msg(body["message"].as_str().unwrap_or("unknown error"));
|
||||
Some(format!(
|
||||
"warning: local peer list not updated ({status}): {msg}"
|
||||
))
|
||||
}
|
||||
|
||||
/// The error text for a remote announce reply, or `None` when the peer accepted
|
||||
/// it.
|
||||
///
|
||||
/// Split out so the ordering is assertable: the status has to reach the caller
|
||||
/// even when the body does not parse. That case is reachable rather than
|
||||
/// theoretical, because the read above is capped, so a peer answering with a
|
||||
/// body past the cap leaves truncated JSON behind, and `peer_url` is whatever
|
||||
/// the caller passed, so a non-JSON error page is just as easy. Parsing first
|
||||
/// and failing on the parse would swallow the status the user needs.
|
||||
fn remote_announce_failure(status: reqwest::StatusCode, raw: &str) -> Option<String> {
|
||||
if status.is_success() {
|
||||
return None;
|
||||
}
|
||||
// Falls back to the body itself when there is no `message` field, which is
|
||||
// what a truncated or non-JSON reply leaves. Sanitized either way: it is the
|
||||
// peer's text and it is headed for the terminal.
|
||||
let parsed: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
|
||||
let msg = sanitize_node_msg(parsed["message"].as_str().unwrap_or(raw));
|
||||
Some(format!("announce failed ({status}): {msg}"))
|
||||
}
|
||||
|
||||
/// Which line the local peer-add prints, and whether it is a warning.
|
||||
///
|
||||
/// Split out because the SELECTION is what the fix is: `local_add_refusal` was
|
||||
/// already unit-tested six ways, and replacing this whole match with an
|
||||
/// unconditional "Added to local peer list." still left 312 tests green. The
|
||||
/// helper was pinned; the wiring was not, which is the shape
|
||||
/// `unit-test-on-helper-does-not-prove-handler-wiring` names. With the choice
|
||||
/// extracted, the call site is a two-line dispatch carrying no logic.
|
||||
fn local_add_report(status: reqwest::StatusCode, body: &Value) -> (bool, String) {
|
||||
match local_add_refusal(status, body) {
|
||||
Some(warning) => (true, warning),
|
||||
None => (false, " Added to local peer list.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block printed after a peer accepts our announce. Split out so the
|
||||
/// defanging is assertable: the DID and URL are the remote peer's own strings,
|
||||
/// and `peer_url` is caller-chosen, so this is the least trusted body the
|
||||
/// command handles. Only the display is sanitized; the values sent on to our
|
||||
/// local node stay verbatim, since that is data rather than terminal output and
|
||||
/// the node runs its own validation on them.
|
||||
fn announced_peer_summary(their_did: &str, their_url: &str, peer_count: u64) -> String {
|
||||
format!(
|
||||
"Announced to peer node:\n DID: {}\n URL: {}\n Their peers: {peer_count}\n",
|
||||
sanitize_node_msg(their_did),
|
||||
sanitize_node_msg(their_url),
|
||||
)
|
||||
}
|
||||
|
||||
async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let my_did = keypair.did().to_string();
|
||||
@@ -195,26 +127,21 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
.await
|
||||
.context("failed to connect to peer")?;
|
||||
let status = resp.status();
|
||||
// `peer_url` is fully caller-chosen, so this body is the least trusted one
|
||||
// in the command: bound the read, and defang the message before it reaches
|
||||
// the terminal through the error return. An announce reply is a DID, a URL
|
||||
// and a count, so 8 KiB is well past what the shape needs.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if let Some(failure) = remote_announce_failure(status, &raw) {
|
||||
anyhow::bail!("{failure}");
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("announce failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let result: Value = serde_json::from_str(&raw).context("invalid JSON response")?;
|
||||
|
||||
let their_did = result["node_did"].as_str().unwrap_or("?");
|
||||
let their_url = result["node_url"].as_str().unwrap_or("?");
|
||||
let peer_count = result["peer_count"].as_u64().unwrap_or(0);
|
||||
|
||||
print!(
|
||||
"{}",
|
||||
announced_peer_summary(their_did, their_url, peer_count)
|
||||
);
|
||||
println!("Announced to peer node:");
|
||||
println!(" DID: {their_did}");
|
||||
println!(" URL: {their_url}");
|
||||
println!(" Their peers: {peer_count}");
|
||||
|
||||
// Also add their info to our local node's peer list
|
||||
// (the peer's /announce response includes their did + url)
|
||||
@@ -223,31 +150,9 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
"did": their_did,
|
||||
"http_url": their_url,
|
||||
}))?;
|
||||
// This requires the local node to be running, so a transport failure
|
||||
// stays a warning rather than failing the command. A reply that
|
||||
// ARRIVED and refused is different: the announce route answers 403
|
||||
// when an unproven caller tries to repoint an existing peer, and
|
||||
// printing the success line over that reports a completed add that
|
||||
// never happened. Same status handling as the remote announce above,
|
||||
// except best effort.
|
||||
match local_client.post("/api/v1/peers/announce", &add_body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// Same bound as the remote announce above: a compromised local
|
||||
// node (or a MITM on plain http) must not force an unbounded
|
||||
// read. A body that does not parse stays `Null`, which still
|
||||
// routes a non-success status to the warning.
|
||||
let raw = read_body_capped(resp, 8 * 1024).await;
|
||||
let result: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
|
||||
let (is_warning, line) = local_add_report(status, &result);
|
||||
if is_warning {
|
||||
eprintln!("{line}");
|
||||
} else {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("warning: local peer list not updated: {e}"),
|
||||
}
|
||||
// This requires the local node to be running; ignore errors here
|
||||
let _ = local_client.post("/api/v1/peers/announce", &add_body).await;
|
||||
println!(" Added to local peer list.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -305,302 +210,3 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
announced_peer_summary, cmd_add, local_add_refusal, local_add_report,
|
||||
remote_announce_failure,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
/// The node's 403 for repointing an existing peer must surface, not print
|
||||
/// as a completed add. Without the status check the command prints "Added
|
||||
/// to local peer list." over a refusal.
|
||||
///
|
||||
/// The fixture body is the real wire shape, not an invented one: the
|
||||
/// message is the Display of `PeerWriteDenied::UnprovenRepoint`, declared
|
||||
/// in `crates/gitlawb-node/src/db/mod.rs`. The executable detector for a
|
||||
/// wording change is the node-side pin
|
||||
/// `announce_unsigned_repoint_is_a_403_denial` in
|
||||
/// `crates/gitlawb-node/src/api/peers.rs`, which asserts this same
|
||||
/// substring against a response driven through the real router, so
|
||||
/// rewording the Display turns that test red and the grep for the old
|
||||
/// phrase lands here.
|
||||
///
|
||||
/// The assertion bites rather than echoing its own input because the
|
||||
/// substring is reachable only through the helper's `body["message"]`
|
||||
/// extraction; the fixture's `error` field carries a different string, so
|
||||
/// reading `body["error"]` instead fails this test.
|
||||
#[test]
|
||||
fn a_refused_local_add_warns_with_the_node_reason() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({
|
||||
"error": "forbidden",
|
||||
"message": "unproven announce cannot change an existing peer's http_url: did:key:z6MkuMqUm4i228K9qXidJ57zqSWAcQLgrcbMxB8RKVLuqitj"
|
||||
}),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(warning.contains("403"), "must name the status: {warning}");
|
||||
assert!(
|
||||
warning.contains("unproven announce cannot change an existing peer"),
|
||||
"must carry the node's own reason: {warning}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refusal with no message body still warns rather than passing silently.
|
||||
#[test]
|
||||
fn a_refusal_without_a_message_still_warns() {
|
||||
let warning = local_add_refusal(StatusCode::BAD_REQUEST, &serde_json::Value::Null)
|
||||
.expect("a refusal must not render as success");
|
||||
assert!(warning.contains("400"), "must name the status: {warning}");
|
||||
}
|
||||
|
||||
/// The accepted case stays quiet, so the success line still prints.
|
||||
#[test]
|
||||
fn an_accepted_local_add_produces_no_warning() {
|
||||
assert!(local_add_refusal(StatusCode::OK, &json!({ "peer_count": 3 })).is_none());
|
||||
}
|
||||
|
||||
/// The warning is printed straight to a terminal, so a node-supplied message
|
||||
/// carrying an OSC title-rewrite plus a C0 escape must reach it defanged.
|
||||
#[test]
|
||||
fn a_refusal_message_cannot_carry_terminal_escapes() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "\u{1b}]2;pwned\u{7}legit-looking text" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
!warning.chars().any(|c| c.is_control()),
|
||||
"control char leaked to the terminal: {warning:?}"
|
||||
);
|
||||
assert!(
|
||||
warning.contains("legit-looking text"),
|
||||
"printable text dropped: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bidi format chars are not `char::is_control`, yet they reorder the
|
||||
/// displayed line, so a refusal must not carry them either.
|
||||
#[test]
|
||||
fn a_refusal_message_cannot_carry_bidi_overrides() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "peer \u{202e}refused\u{202c} here" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
!warning.chars().any(gitlawb_core::sanitize::is_bidi_format),
|
||||
"bidi format char leaked to the terminal: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hostile node must not be able to dump an arbitrary amount of text into
|
||||
/// the user's scrollback through the warning line.
|
||||
#[test]
|
||||
fn a_refusal_message_is_length_bounded() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "x".repeat(5000) }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
warning.chars().count() < 500,
|
||||
"warning not bounded: {} chars",
|
||||
warning.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
/// The status is the part the user can act on, so it must survive a body
|
||||
/// that does not parse. The read is capped, so a peer answering past the cap
|
||||
/// leaves exactly this: a valid status and truncated JSON.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_keeps_the_status_when_the_body_does_not_parse() {
|
||||
let truncated = r#"{"error":"service_unavailable","message":"backend is do"#;
|
||||
let failure = remote_announce_failure(StatusCode::SERVICE_UNAVAILABLE, truncated)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
failure.contains("503"),
|
||||
"the status must survive an unparseable body: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A parseable error body still reports the peer's own message.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_carries_the_peers_message() {
|
||||
let failure = remote_announce_failure(
|
||||
StatusCode::FORBIDDEN,
|
||||
r#"{"error":"forbidden","message":"unproven announce cannot change an existing peer"}"#,
|
||||
)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
failure.contains("403")
|
||||
&& failure.contains("unproven announce cannot change an existing peer"),
|
||||
"status and reason must both survive: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fallback path prints the raw body, so it is defanged like every other
|
||||
/// peer-supplied string headed for the terminal.
|
||||
#[test]
|
||||
fn a_remote_announce_failure_defangs_an_unparseable_body() {
|
||||
let failure = remote_announce_failure(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"\u{1b}]2;pwned\u{7}<html>gateway error</html>",
|
||||
)
|
||||
.expect("a non-success status must produce a failure");
|
||||
|
||||
assert!(
|
||||
!failure.chars().any(|c| c.is_control()),
|
||||
"control char leaked to the terminal: {failure:?}"
|
||||
);
|
||||
assert!(failure.contains("502"), "status dropped: {failure:?}");
|
||||
assert!(
|
||||
failure.contains("gateway error"),
|
||||
"the body is all the user gets when there is no message field: {failure:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unit tests above bind the helper. This one binds the CALL SITE: the
|
||||
/// failure check has to run before the parse, or a body past the read cap
|
||||
/// turns a 503 into "invalid JSON response" and the status the user needs is
|
||||
/// gone. Reordering the two statements in `cmd_add` compiles and passes every
|
||||
/// other test in this file, so only driving the command catches it.
|
||||
#[tokio::test]
|
||||
async fn a_peer_failure_past_the_read_cap_still_reports_its_status() {
|
||||
let mut peer = mockito::Server::new_async().await;
|
||||
let mut local = 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 _local_info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://me.example"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Valid JSON, but past the 8 KiB read cap, so what survives the read is
|
||||
// truncated and unparseable. This is the shape the cap itself creates.
|
||||
let oversized = format!(r#"{{"message":"{}"}}"#, "x".repeat(16 * 1024));
|
||||
let _peer_refusal = peer
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(503)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(oversized)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_add(peer.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.expect_err("a 503 from the peer must fail the command");
|
||||
|
||||
let rendered = format!("{err:#}");
|
||||
assert!(
|
||||
rendered.contains("503"),
|
||||
"the status must survive a body past the read cap: {rendered:?}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("invalid JSON response"),
|
||||
"the parse failure must not stand in for the status: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An accepted announce produces no error, so the caller proceeds to parse.
|
||||
#[test]
|
||||
fn an_accepted_remote_announce_produces_no_failure() {
|
||||
assert!(remote_announce_failure(StatusCode::OK, r#"{"peer_count":3}"#).is_none());
|
||||
}
|
||||
|
||||
/// The selection, not the helper. A refusal must never yield the success
|
||||
/// line, and the success line must never carry the warning prefix. This is
|
||||
/// what stayed green when the entire match was replaced with an
|
||||
/// unconditional "Added to local peer list."
|
||||
#[test]
|
||||
fn a_local_add_refusal_never_selects_the_success_line() {
|
||||
let (is_warning, line) = local_add_report(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "unproven announce cannot change an existing peer's http_url: did:key:zAbc" }),
|
||||
);
|
||||
|
||||
assert!(is_warning, "a 403 must select the warning stream: {line:?}");
|
||||
assert!(
|
||||
!line.contains("Added to local peer list"),
|
||||
"a refusal rendered as the success line: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
line.contains("unproven announce cannot change an existing peer"),
|
||||
"the node's reason must reach the user: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other direction, so the fix cannot be satisfied by always warning.
|
||||
#[test]
|
||||
fn an_accepted_local_add_selects_the_success_line() {
|
||||
let (is_warning, line) = local_add_report(StatusCode::OK, &json!({ "peer_count": 3 }));
|
||||
|
||||
assert!(!is_warning, "a 200 must not select the warning stream");
|
||||
assert!(
|
||||
line.contains("Added to local peer list"),
|
||||
"the success line must still print: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The accepted-announce block prints the remote peer's own DID and URL, so
|
||||
/// a hostile peer answering 200 must not get escapes onto the terminal that
|
||||
/// way either. The refusal path is not the only sink in this command.
|
||||
#[test]
|
||||
fn an_accepted_announce_summary_cannot_carry_terminal_escapes() {
|
||||
let summary = announced_peer_summary(
|
||||
"did:key:z6Mk\u{1b}]2;pwned\u{7}abc",
|
||||
"https://peer.example\u{202e}moc.live//:sptth",
|
||||
3,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!summary.chars().any(|c| c.is_control() && c != '\n'),
|
||||
"control char leaked to the terminal: {summary:?}"
|
||||
);
|
||||
assert!(
|
||||
!summary.chars().any(gitlawb_core::sanitize::is_bidi_format),
|
||||
"bidi format char leaked to the terminal: {summary:?}"
|
||||
);
|
||||
assert!(
|
||||
summary.contains("did:key:z6Mk") && summary.contains("Their peers: 3"),
|
||||
"printable content dropped: {summary:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The counterweight to the stripping tests: a legitimate message with RTL
|
||||
/// script letters and a ZWJ must survive intact, so the sanitizer is not
|
||||
/// proven by over-stripping.
|
||||
#[test]
|
||||
fn a_legitimate_refusal_message_survives_intact() {
|
||||
let warning = local_add_refusal(
|
||||
StatusCode::FORBIDDEN,
|
||||
&json!({ "message": "peer refused \u{0627}\u{200D}b" }),
|
||||
)
|
||||
.expect("a refusal must not render as success");
|
||||
|
||||
assert!(
|
||||
warning.contains("peer refused \u{0627}\u{200D}b"),
|
||||
"legitimate text mangled: {warning:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user