mirror of
https://github.com/Twigpine/node.git
synced 2026-11-10 05:01:48 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef597a1936 | ||
|
|
99a9c661c7 | ||
|
|
dc3ed80360 | ||
|
|
8dc6c26190 | ||
|
|
63e6e3f34b |
No files matched your search
@@ -21,25 +21,6 @@ ignore = [
|
||||
# the build, at which point it becomes a real reachable advisory.
|
||||
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
|
||||
|
||||
# lru 0.16.3 (use-after-free in pop()). Reachable via alloy. The
|
||||
# lockfile holds only 0.16.3 — the aws-sdk-s3 path that once pulled
|
||||
# 0.12.5 no longer does. The RUSTSEC-2026-0253 advisory covers this
|
||||
# range, so the ignore stays load-bearing until a fixed 0.16.x lands;
|
||||
# REMOVE when the locked version is fixed or lru leaves the tree.
|
||||
# Retirement owner: node maintainers, enforced by the weekly
|
||||
# audit-schedule drift guard ("Fail if lru moved while its ignore
|
||||
# remains"), which fails if either locked version moves or lru
|
||||
# leaves the tree. No separate tracking issue exists; file one
|
||||
# against the lru upgrade if this waiver needs discussion outside
|
||||
# the schedule job.
|
||||
"RUSTSEC-2026-0253",
|
||||
#
|
||||
# Round-3 P2 (reviewer 2): the previous version of this file
|
||||
# ignored RUSTSEC-2026-0258 for h2 0.4.13, but the lockfile
|
||||
# already has h2 0.4.18 (verified 2026-08-31: cargo tree -p h2
|
||||
# shows 0.4.18) — the fix is in. The ignore was stale and would
|
||||
# have silently re-accepted the advisory if h2 dropped back to
|
||||
# 0.4.13. Removed.
|
||||
# crypto-bigint 0.5.5 (secret-dependent branches in operations meant
|
||||
# to be constant-time). REACHABLE accepted risk, on two paths:
|
||||
# - alloy-signer -> elliptic-curve 0.13 -> crypto-bigint 0.5: the k256
|
||||
@@ -58,16 +39,4 @@ ignore = [
|
||||
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
|
||||
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
|
||||
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
|
||||
|
||||
# libsqlite3-sys 0.30.1 (bundled SQLite WAL uninitialized-memory disclosure,
|
||||
# CVE-2026-119375). Present in Cargo.lock because sqlx resolves sqlx-sqlite
|
||||
# unconditionally; sqlx is built postgres-only
|
||||
# (crates/gitlawb-node/Cargo.toml), so sqlx-sqlite and its bundled SQLite C
|
||||
# source are never compiled or linked (`cargo tree -i sqlx-sqlite` prints
|
||||
# nothing) and there is no reachable WAL open to attack. The fix requires
|
||||
# libsqlite3-sys >= 0.39, but sqlx-sqlite 0.8.6 pins ^0.30.1; a compatible
|
||||
# bump needs a sqlx upgrade, not a lockfile refresh. REMOVE this ignore if
|
||||
# sqlx is ever built with the `sqlite` feature or any other libsqlite3-sys
|
||||
# consumer enters the build.
|
||||
"RUSTSEC-2026-0459", # libsqlite3-sys bundled SQLite CVE (not linked in our build)
|
||||
]
|
||||
+4
-65
@@ -6,11 +6,7 @@
|
||||
# Path to the node's Ed25519 keypair PEM file. Must name a key file, not a
|
||||
# directory: a trailing slash, a final `.` or `..`, and `..` traversal are
|
||||
# refused so the path cannot retarget a parent. A `~/` value must stay under
|
||||
# home after expansion, and the path must be absolute: its parent is also
|
||||
# the delegation store that `gl ucan import` writes and `git-remote-gitlawb`
|
||||
# reads, and the two do not share a working directory, so a relative path
|
||||
# sends them to different places. Bare `identity.pem` under the default
|
||||
# config dir is still legal.
|
||||
# home after expansion. Bare `identity.pem` is still legal.
|
||||
# Generate with: gl identity new
|
||||
GITLAWB_KEY=/data/keys/identity.pem
|
||||
|
||||
@@ -43,12 +39,6 @@ GITLAWB_KEY=/data/keys/identity.pem
|
||||
# Publicly reachable URL of this node (used in peer announcements)
|
||||
GITLAWB_PUBLIC_URL=https://your-node.example.com
|
||||
|
||||
# Base URL for the web view of repos on this node (used by `gl` to print a
|
||||
# working View: link after repo creation). Omit for nodes with no web front-end.
|
||||
# Distinct from GITLAWB_PUBLIC_URL: that is API reachability for peers;
|
||||
# this is browser reachability for humans.
|
||||
# GITLAWB_WEB_URL=https://gitlawb.com
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
GITLAWB_HOST=0.0.0.0
|
||||
GITLAWB_PORT=7545
|
||||
@@ -156,10 +146,9 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
|
||||
# push to every repository, private ones included. On by default.
|
||||
#
|
||||
# Set to false only for a rolling upgrade whose pushers are not yet the repo
|
||||
# owner. A holder of an owner-rooted, time-bounded git/push UCAN naming that
|
||||
# repository can push while this is on — see docs/RUN-A-NODE.md for issuing and
|
||||
# importing one. A CI key with no such delegation counts as a non-owner and
|
||||
# cannot push.
|
||||
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
|
||||
# verified but not yet honored for authorization, so they cannot push while this
|
||||
# is on. See docs/RUN-A-NODE.md.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
@@ -331,19 +320,6 @@ GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
|
||||
# from the concurrency caps above). 0 disables. Default 600.
|
||||
GITLAWB_IPFS_RATE_LIMIT=600
|
||||
|
||||
# Per-client-IP rate limit for the anonymous task read routes
|
||||
# (GET /api/v1/tasks, GET /api/v1/tasks/{id}), in requests per hour.
|
||||
# Both are publicly reachable (optional_signature). GET /api/v1/tasks runs
|
||||
# collect_visible_tasks and returns a visibility-filtered page;
|
||||
# GET /api/v1/tasks/{id} runs get_visible_task and returns an opaque 404 when
|
||||
# the task is hidden or missing. These reads can require task, repository,
|
||||
# and visibility-rule queries even when no task is returned, so the brake
|
||||
# bounds the cost of anonymous probes. Keyed on the resolved client IP via
|
||||
# GITLAWB_TRUSTED_PROXY. 0 disables. Default: 1200 (a list page
|
||||
# followed by per-task reads is a normal client pattern, so this sits above
|
||||
# the /ipfs budget). The GraphQL/WS task-read brake shares this budget.
|
||||
GITLAWB_TASK_READ_RATE_LIMIT=1200
|
||||
|
||||
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
|
||||
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
|
||||
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
|
||||
@@ -352,14 +328,6 @@ GITLAWB_TASK_READ_RATE_LIMIT=1200
|
||||
# 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
|
||||
# Per-DID creation requests allowed inside the window below (repo create,
|
||||
# issue create, PR create, register, fork). 0 is rejected at startup: it would
|
||||
# silently disable the limiter. Default 10.
|
||||
GITLAWB_RATE_LIMIT_MAX=10
|
||||
# Window the per-DID creation count above applies over, in seconds. 0 is
|
||||
# rejected at startup: every timestamp would prune immediately, disabling the
|
||||
# limiter. Default 3600.
|
||||
GITLAWB_RATE_LIMIT_WINDOW_SECS=3600
|
||||
|
||||
# ── 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
|
||||
@@ -385,35 +353,6 @@ GITLAWB_TRUSTED_PROXY=
|
||||
# Enable automatic background sync from known peers
|
||||
GITLAWB_AUTO_SYNC=false
|
||||
|
||||
# ── External upstream mirroring ───────────────────────────────────────────
|
||||
# Default-off scheduled fetch worker for repositories configured as INBOUND.
|
||||
# Enabling requires GITLAWB_ENFORCE_OWNER_PUSH=true; the node refuses to start
|
||||
# otherwise. The worker updates branches and tags atomically while preserving
|
||||
# Gitlawb-owned review/internal refs. Enabling also requires Git >= 2.37 on
|
||||
# the host for http.curloptResolve DNS pinning; the node refuses to start
|
||||
# when the installed Git is older.
|
||||
GITLAWB_UPSTREAM_MIRROR_ENABLED=false
|
||||
# Pause after each complete scan. Must be 5..=86400 seconds.
|
||||
GITLAWB_UPSTREAM_MIRROR_INTERVAL_SECS=300
|
||||
# Deadline for the external fetch child only; the preflight probes run on
|
||||
# their own short bound. Must be 1..=3153600000 seconds.
|
||||
GITLAWB_UPSTREAM_MIRROR_FETCH_TIMEOUT_SECS=600
|
||||
# Keyset page size; every page is processed before the next scan delay.
|
||||
GITLAWB_UPSTREAM_MIRROR_PAGE_SIZE=100
|
||||
# ── Reconciliation sweep ─────────────────────────────────────────────────
|
||||
# Periodic durability sweep: re-derives the public pin set and the withheld-blob
|
||||
# recovery set each hour and fills gaps so a dropped replication job never means
|
||||
# data loss. Defaults to true; set to false to disable the sweep even when a pin
|
||||
# backend (IPFS/Pinata) is configured.
|
||||
#
|
||||
# Phase-capability matrix:
|
||||
# - Public pin repair: IPFS-only, Pinata-only, or both (requires the
|
||||
# respective backend to be configured).
|
||||
# - Encrypted recovery repair: requires local IPFS (GITLAWB_IPFS_API).
|
||||
# Pinata-only nodes reconcile public pins only; encrypted recovery
|
||||
# reconciliation is not performed.
|
||||
GITLAWB_RECONCILIATION_SWEEP=true
|
||||
|
||||
# ── 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.
|
||||
|
||||
@@ -86,27 +86,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "No drift: ignore list is consistent with the pinned hickory-proto version."
|
||||
|
||||
# Drift guard: the RUSTSEC-2026-0253 (lru) ignore is only valid while
|
||||
# the lockfile holds exactly the version it was written for (0.16.3
|
||||
# via alloy). Any move — an upgrade that may carry the upstream fix,
|
||||
# or removal of the dependency — must revisit the ignore. Retirement
|
||||
# owner: node maintainers, via this weekly job (no separate tracking
|
||||
# issue; see the waiver comment in .cargo/audit.toml).
|
||||
- name: Fail if lru moved while its ignore remains
|
||||
run: |
|
||||
set -euo pipefail
|
||||
versions="$(grep -A1 'name = "lru"' Cargo.lock | grep '^version' | cut -d'"' -f2 | sort -u | tr '\n' ' ')"
|
||||
echo "lru versions in Cargo.lock: ${versions:-not present}"
|
||||
lru_ignore_present=false
|
||||
if grep -q 'RUSTSEC-2026-0253' .cargo/audit.toml; then
|
||||
lru_ignore_present=true
|
||||
fi
|
||||
# Drift = the lockfile no longer matches what the ignore assumes:
|
||||
# the version moved (possible fix) or lru left the tree
|
||||
# (dead ignore). Both must fail for explicit re-triage.
|
||||
if [ "$lru_ignore_present" = true ] && [ "${versions}" != "0.16.3 " ]; then
|
||||
echo "::error::lru versions are [${versions:-absent from Cargo.lock}] but the RUSTSEC-2026-0253 ignore is still in .cargo/audit.toml. Re-triage the waiver against the advisory's fixed range and either drop the ignore or update the pinned set."
|
||||
exit 1
|
||||
fi
|
||||
echo "No drift: lru ignore is consistent with the pinned versions."
|
||||
@@ -1,100 +0,0 @@
|
||||
name: CodeQL
|
||||
|
||||
# Advanced-setup replacement for CodeQL default setup. Default setup analyzes
|
||||
# every detected language on every PR, including Swift (macos-app/), which
|
||||
# autobuilds on a macOS runner for ~13 minutes per PR and has hung for 55 on
|
||||
# main; that single job was the longest check on most PRs. Here a PR analyzes
|
||||
# only the languages it touches (scripts/ci-surfaces.sh), while push to main
|
||||
# and the weekly schedule analyze every language, so main's code-scanning
|
||||
# baseline stays complete.
|
||||
#
|
||||
# Requires CodeQL default setup to be DISABLED (Settings -> Code security ->
|
||||
# Code scanning): GitHub rejects advanced-setup uploads while it is enabled.
|
||||
#
|
||||
# A PR that skips a language may show "configuration not found" on the CodeQL
|
||||
# check for that language; that is informational and expected.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: changed languages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
codeql: ${{ steps.surfaces.outputs.codeql }}
|
||||
matrix: ${{ steps.surfaces.outputs.codeql_matrix }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Map changed paths to languages
|
||||
id: surfaces
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
# -z: literal paths; plain --name-only C-quotes unusual names.
|
||||
git diff -z --name-only --no-renames HEAD^1 HEAD > changed-paths
|
||||
tr '\0' '\n' < changed-paths >&2
|
||||
scripts/ci-surfaces.sh < changed-paths > surfaces.out
|
||||
else
|
||||
scripts/ci-surfaces.sh --all > surfaces.out
|
||||
fi
|
||||
cat surfaces.out
|
||||
grep -E '^(codeql|codeql_matrix)=' surfaces.out >> "$GITHUB_OUTPUT"
|
||||
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.codeql == 'true'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# Normal runs take <=14 min (Swift autobuild is the longest); cap a hung
|
||||
# autobuild instead of letting it hold a runner for an hour.
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Autobuild
|
||||
if: matrix.build-mode == 'autobuild'
|
||||
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -30,62 +30,8 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Decide which checks this event needs. On pull_request, each job below runs
|
||||
# only when the PR touches the surface it validates, and the cargo test /
|
||||
# release-build jobs are scoped to the touched crates plus their reverse
|
||||
# dependencies (scripts/ci-surfaces.sh). Every other event (push to main,
|
||||
# merge_group, the release flow's workflow_dispatch) runs the full suite, so
|
||||
# main and the merge queue keep whole-workspace coverage and main-red-alert
|
||||
# still sees a complete run. A skipped job reports as "skipped", not green.
|
||||
changes:
|
||||
name: changed surfaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
rust: ${{ steps.surfaces.outputs.rust }}
|
||||
rust_packages: ${{ steps.surfaces.outputs.rust_packages }}
|
||||
windows: ${{ steps.surfaces.outputs.windows }}
|
||||
windows_packages: ${{ steps.surfaces.outputs.windows_packages }}
|
||||
docker: ${{ steps.surfaces.outputs.docker }}
|
||||
audit: ${{ steps.surfaces.outputs.audit }}
|
||||
core_deps: ${{ steps.surfaces.outputs.core_deps }}
|
||||
release_versioning: ${{ steps.surfaces.outputs.release_versioning }}
|
||||
release_tag: ${{ steps.surfaces.outputs.release_tag }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The pull_request checkout is the test-merge commit; depth 2 brings
|
||||
# its first parent (the base tip) so the diff is exactly this PR.
|
||||
fetch-depth: 2
|
||||
|
||||
# Every job's gate comes from the mapper, so pin its mapping first; a
|
||||
# failure here fails this job and the run is red, not silently narrowed.
|
||||
- name: Test the surface mapper
|
||||
run: scripts/test-ci-surfaces.sh
|
||||
|
||||
- name: Map changed paths to surfaces
|
||||
id: surfaces
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
# -z: literal paths; plain --name-only C-quotes unusual names.
|
||||
git diff -z --name-only --no-renames HEAD^1 HEAD > changed-paths
|
||||
tr '\0' '\n' < changed-paths >&2
|
||||
scripts/ci-surfaces.sh < changed-paths > surfaces.out
|
||||
else
|
||||
scripts/ci-surfaces.sh --all > surfaces.out
|
||||
fi
|
||||
cat surfaces.out
|
||||
cat surfaces.out >> "$GITHUB_OUTPUT"
|
||||
|
||||
fmt-clippy:
|
||||
name: fmt + clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -111,8 +57,6 @@ jobs:
|
||||
|
||||
release-tag-validation:
|
||||
name: release tag validation
|
||||
needs: changes
|
||||
if: needs.changes.outputs.release_tag == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -142,8 +86,6 @@ jobs:
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.toolchain }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
@@ -188,17 +130,10 @@ jobs:
|
||||
- name: cargo test
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
|
||||
# `--workspace`, or `-p <crate>...` for the crates a PR touches plus
|
||||
# every crate that depends on them.
|
||||
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086 # PACKAGES is a word list by design
|
||||
cargo test --locked $PACKAGES
|
||||
run: cargo test --locked --workspace
|
||||
|
||||
build-release:
|
||||
name: build --release
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -218,20 +153,10 @@ jobs:
|
||||
key: release
|
||||
|
||||
- name: cargo build --release
|
||||
env:
|
||||
PACKAGES: ${{ needs.changes.outputs.rust_packages }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086 # PACKAGES is a word list by design
|
||||
cargo build --locked --release $PACKAGES
|
||||
run: cargo build --locked --release --workspace
|
||||
|
||||
audit:
|
||||
name: cargo audit
|
||||
# cargo audit reads only Cargo.lock (+ .cargo/audit.toml), so a PR that
|
||||
# leaves both unchanged cannot change its verdict; a newly published
|
||||
# advisory against main is caught by the push-to-main run and the weekly
|
||||
# Scheduled Audit instead of turning every unrelated PR red.
|
||||
needs: changes
|
||||
if: needs.changes.outputs.audit == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -291,8 +216,6 @@ jobs:
|
||||
|
||||
msrv:
|
||||
name: MSRV (Rust 1.91)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -316,12 +239,6 @@ jobs:
|
||||
|
||||
docker-build:
|
||||
name: Docker build smoke test
|
||||
# Runs when the image's own inputs change: the Dockerfile, the manifests
|
||||
# its dependency layer copies, the lockfile, bootstrap-peers.json. A
|
||||
# source-only Rust change is already compiled by `build --release` (stable)
|
||||
# and `MSRV` (1.91, the image's toolchain); push to main still runs this.
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -338,8 +255,6 @@ jobs:
|
||||
|
||||
test-windows:
|
||||
name: test (windows, non-blocking)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
|
||||
@@ -367,13 +282,7 @@ jobs:
|
||||
key: windows
|
||||
|
||||
- name: cargo test (shipped Windows crates)
|
||||
shell: bash
|
||||
env:
|
||||
# `-p gl -p git-remote-gitlawb`, or whichever of the two a PR affects.
|
||||
PACKAGES: ${{ needs.changes.outputs.windows_packages }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086 # PACKAGES is a word list by design
|
||||
cargo test --locked $PACKAGES
|
||||
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
|
||||
@@ -383,8 +292,6 @@ jobs:
|
||||
# cannot do. Regen instructions live in the allowlist header.
|
||||
core-deps-purity:
|
||||
name: gitlawb-core dependency purity
|
||||
needs: changes
|
||||
if: needs.changes.outputs.core_deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -417,8 +324,6 @@ jobs:
|
||||
# not its author knew this gate existed.
|
||||
release-versioning:
|
||||
name: release versioning coverage
|
||||
needs: changes
|
||||
if: needs.changes.outputs.release_versioning == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
||||
@@ -40,5 +40,5 @@ When a node denies a request, surface the denial to the user. Never render a den
|
||||
## 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's jobs are `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. On a PR, `scripts/ci-surfaces.sh` runs each job only when the PR touches its inputs, and scopes `cargo test` and the release build to the touched crates plus every crate that depends on them. Root `Cargo.toml`/`Cargo.lock`/`.cargo/` changes run the Rust jobs (fmt, clippy, tests, release build, MSRV) on the whole workspace; every other job still runs only when its own inputs change. Edits to the CI wiring itself (`pr-checks.yml`, `codeql.yml`, `scripts/ci-surfaces.sh`), push to `main`, and the merge queue run every job on the whole workspace. Edits to any other workflow run none of these jobs on a PR; CodeQL only analyzes them as Actions code. Run `cargo test --workspace` locally regardless. The same script limits CodeQL (`codeql.yml`) to the languages a PR touches. 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.
|
||||
- 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.
|
||||
+1
-1
@@ -81,7 +81,7 @@ time once updated.
|
||||
|
||||
**Requirements:**
|
||||
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
|
||||
- PostgreSQL 16+ — required for the node (it uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later). Use the bundled `docker-compose.yml` for local dev.
|
||||
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
|
||||
- Docker (optional, for full-stack local testing)
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
Generated
+5
-14
@@ -2208,7 +2208,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2701,7 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3473,13 +3473,10 @@ name = "git-remote-gitlawb"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3566,9 +3563,7 @@ dependencies = [
|
||||
"libp2p-swarm",
|
||||
"mockito",
|
||||
"multiaddr",
|
||||
"percent-encoding",
|
||||
"prometheus",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3585,7 +3580,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
"zstd",
|
||||
@@ -3604,9 +3598,7 @@ dependencies = [
|
||||
"ed25519-dalek 2.2.0",
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"libc",
|
||||
"mockito",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3615,7 +3607,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
@@ -3651,9 +3642,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -7958,7 +7949,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-3
@@ -54,10 +54,8 @@ uuid = { version = "1", features = ["v4"] }
|
||||
# http client
|
||||
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
|
||||
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
|
||||
# take a parsed URL without pulling reqwest into gitlawb-core; also used for the
|
||||
# absolute-browser-url validation on GITLAWB_WEB_URL in the node and CLI)
|
||||
# take a parsed URL without pulling reqwest into gitlawb-core)
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ Good today:
|
||||
|
||||
- Local or Docker node startup.
|
||||
- Postgres-backed repo metadata.
|
||||
- Bounded GraphQL queries with [repository pagination](docs/graphql-pagination.md).
|
||||
- Bare git repository storage.
|
||||
- Git smart-HTTP clone/fetch/push.
|
||||
- RFC 9421-signed writes.
|
||||
@@ -67,10 +66,10 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Repository write authorization is one switch: `GITLAWB_ENFORCE_OWNER_PUSH`, on by default since #330. With it off — rolling upgrades only — a valid HTTP Signature identifies a pusher but does not authorize one, so every signed caller may push everywhere.
|
||||
- UCAN capabilities are consulted on the push path only. There, the chain's root issuer is anchored to the repository owner, so an owner-rooted, time-bounded delegation that names the repository grants scoped collaborator access for pushing to it, for the `git/push`, `*`, or `repo/admin` actions. A `*` *resource* is refused: a delegation must name the repository it applies to, so it cannot grow to cover repos created later. Push-only cuts the other way too: a delegation grants no read access, so on a private repository the delegate must also be a reader (`gl visibility set`) — the two gates a push crosses are described in [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). The rest is unchanged: there is no revocation path, `nb` constraints are refused rather than interpreted, and no other route — reads, pull requests, issues, agents — consults capabilities at all.
|
||||
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- Task list/get reads (REST and GraphQL) are repository/task-gated and omit `ucan_token`. Slash-form `repo_id` rows (mirror-hosted tasks) can appear on list for the delegator or trimmed assignee, but `GET /api/v1/tasks/{id}` and GraphQL `task` fail closed for every caller because there is no local repository backing to serve. Read visibility is not a blanket data-classification boundary: IPFS-pin and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
|
||||
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
|
||||
@@ -312,8 +311,7 @@ metadata local disk / optional S3
|
||||
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
|
||||
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
|
||||
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
|
||||
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
|
||||
| UCAN | Capability token. An owner delegates `git/push` on a repo to another DID; the node honors it when the proof chain roots at that owner. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
|
||||
| Gossipsub | libp2p topic for ref-update events. |
|
||||
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
|
||||
@@ -401,10 +399,9 @@ Important node settings:
|
||||
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
|
||||
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
|
||||
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_RECONCILIATION_SWEEP` | Enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. |
|
||||
| `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. |
|
||||
@@ -422,7 +419,6 @@ Important node settings:
|
||||
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
|
||||
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `GITLAWB_TASK_READ_RATE_LIMIT` | Max anonymous task-read requests (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`) per client IP per hour, sharing the budget with the GraphQL/WS task-read brake. Uses `GITLAWB_TRUSTED_PROXY` to resolve the client IP. 0 disables. Default 1200 (above the `/ipfs` budget to allow list pages followed by per-task reads). |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
|
||||
@@ -557,7 +553,7 @@ Short-term priorities:
|
||||
2. Add Docker and installer smoke tests.
|
||||
3. Improve operator docs and `gl doctor` checks.
|
||||
4. Harden peer writes and publish the signed-peer rollout plan.
|
||||
5. Wire trusted UCAN delegation into repository write authorization.
|
||||
5. Close default-open write authorization and wire trusted UCAN delegation into repository permissions.
|
||||
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
|
||||
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
|
||||
|
||||
|
||||
+11
-11
@@ -26,7 +26,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
|
||||
**Content addressing**
|
||||
- Every git object is content-addressed via CIDv1 (SHA-256)
|
||||
- Tamper-evident by construction: a modified object changes its CID
|
||||
- Tamper-evident by construction — a modified object changes its CID
|
||||
|
||||
**UCAN token validation**
|
||||
- Bootstrap UCAN tokens are issued at registration.
|
||||
@@ -35,8 +35,8 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
|
||||
|
||||
**Smart contracts (Base Sepolia testnet)**
|
||||
- `GitlawbDIDRegistry`: on-chain DID → document registry
|
||||
- `GitlawbNameRegistry`: human name → DID registry
|
||||
- `GitlawbDIDRegistry` — on-chain DID → document registry
|
||||
- `GitlawbNameRegistry` — human name → DID registry
|
||||
- Both auditable on-chain, no admin keys
|
||||
|
||||
---
|
||||
@@ -54,9 +54,9 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
|
||||
### Repository write authorization defaults
|
||||
- `git-receive-pack` verifies HTTP Signatures and, with the default configuration, also requires the pusher DID to be the repository owner: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher, and the pusher must be the repository owner. Authentication is not authorization on its own, since anyone can mint a `did:key` and sign, so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, including private ones.
|
||||
- **Mitigation:** `GITLAWB_ENFORCE_OWNER_PUSH=false` is the explicit rolling-upgrade compatibility override. Disable it only for the duration of an upgrade whose pushers are not yet the repo owner, then turn it back on. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on.
|
||||
- `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout.
|
||||
- **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner.
|
||||
- **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it.
|
||||
|
||||
### UCAN delegation and revocation
|
||||
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
|
||||
@@ -73,7 +73,7 @@ These are documented limitations of the current live release. They should be pri
|
||||
### Private repository reads
|
||||
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
|
||||
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
|
||||
- Task list/get reads (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`, and GraphQL `tasks` / `task` over HTTP and WebSocket) are repository/task-gated through shared visibility collectors and omit `ucan_token`. Slash-form `repo_id` rows (mirror-hosted tasks) can appear on list for the delegator or trimmed assignee, but `GET /api/v1/tasks/{id}` and GraphQL `task` fail closed for every caller because there is no local repository backing to serve. All of those transports share the same per-IP `GITLAWB_TASK_READ_RATE_LIMIT` budget. `/api/v1/ipfs/pins` and `/api/v1/arweave/anchors` are not repository-gated; pin and anchor listings expose object and ref metadata.
|
||||
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
|
||||
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
|
||||
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
|
||||
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
|
||||
@@ -119,10 +119,10 @@ These are documented limitations of the current live release. They should be pri
|
||||
## Threat Model
|
||||
|
||||
Twigpine is designed to be secure against:
|
||||
- **Unauthenticated writes:** HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
- **Tampered git objects:** CIDv1 content addressing detects modification
|
||||
- **Identity spoofing:** DIDs derived from public keys, unforgeable without the private key
|
||||
- **Centralized takedown:** no single point of control; data on IPFS + Arweave
|
||||
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
|
||||
- **Tampered git objects** — CIDv1 content addressing detects modification
|
||||
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
|
||||
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
|
||||
|
||||
Twigpine is **not yet** designed to defend against:
|
||||
- A compromised node operator (node operators are trusted for their own node)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-10-02",
|
||||
"updated": "2026-06-30",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
@@ -42,14 +42,6 @@
|
||||
"http_url": "https://rapybus-gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-05-16"
|
||||
},
|
||||
{
|
||||
"name": "moonwake-ledger",
|
||||
"operator": "Moonwake Ledger",
|
||||
"did": "did:key:z6Mkh9AZ46Z3RkGZfFCom5vzX2535f54d6G2o2ZGkCqjwW8K",
|
||||
"http_url": "https://node.moonwakeledger.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-10-02"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,17 +14,8 @@ path = "src/main.rs"
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
# Reading the node's DID from `GET /` so a delegated push can address its
|
||||
# invocation to the right executor.
|
||||
serde_json = { workspace = true }
|
||||
# The invocation carries the delegation's expiry, so an unbounded write capability
|
||||
# is never minted; converting the stored i64 timestamp needs chrono.
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# Home-directory lookup for the shared GITLAWB_KEY resolver in gitlawb-core,
|
||||
# which takes `home` as an argument so core's dependency allowlist stays lean.
|
||||
dirs = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
|
||||
@@ -124,9 +124,7 @@ fn help_text() -> String {
|
||||
\n\
|
||||
ENVIRONMENT:\n\
|
||||
\x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\
|
||||
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push, absolute\n\
|
||||
\x20 (default: ~/.gitlawb/identity.pem). Its parent also\n\
|
||||
\x20 holds the delegations `gl ucan import` writes.\n\
|
||||
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\
|
||||
\x20 GITLAWB_LOG Log filter (default: warn)\n\
|
||||
\n\
|
||||
FLAGS:\n\
|
||||
@@ -409,205 +407,6 @@ fn build_advertisement_request(
|
||||
/// Public-repo fetch still works anonymously when no keypair is present. The body
|
||||
/// is signed (content-digest) but NOT attached here, so the caller can move the
|
||||
/// (possibly large) pack bytes into `.body()` rather than clone them.
|
||||
/// How long to wait for the node's DID before giving up and pushing without a
|
||||
/// delegation. Short on purpose: the answer is optional, and the node decides
|
||||
/// whether the header was required.
|
||||
const NODE_DID_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// A path component safe to build a filename from. Mirrors `gl`'s
|
||||
/// `ucan_cmd::is_safe_component`; change both together.
|
||||
///
|
||||
/// Here the value comes from the remote URL rather than a token, and the path is
|
||||
/// only read — but a `..` owner would still read an arbitrary file and send its
|
||||
/// contents to the node as `X-Ucan`, so the same allow-list applies.
|
||||
fn is_safe_component(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& !s.contains("..")
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
|
||||
}
|
||||
|
||||
/// Where a delegation for `owner_did`/`repo` is stored.
|
||||
///
|
||||
/// Must agree with `gl`'s `ucan_cmd::delegation_path`, which writes these files.
|
||||
/// `gl` is not a dependency of this crate, so the six lines are duplicated rather
|
||||
/// than shared; change both together. Keyed on the bare base58 key because
|
||||
/// `did:key:` contains a colon, which Windows rejects in a filename.
|
||||
fn delegation_path(dir: &std::path::Path, owner_did: &str, repo: &str) -> std::path::PathBuf {
|
||||
let bare = owner_did.strip_prefix("did:key:").unwrap_or(owner_did);
|
||||
dir.join("delegations").join(format!("{bare}__{repo}.ucan"))
|
||||
}
|
||||
|
||||
/// Wrap a stored delegation into an invocation addressed to the node.
|
||||
///
|
||||
/// `iss=agent, aud=node, prf=[delegation]` is exactly the shape the node's
|
||||
/// `validate_ucan_chain` expects: it binds `iss` to the request signer and `aud`
|
||||
/// to its own DID, then walks `prf` to the root.
|
||||
///
|
||||
/// Capabilities are copied from the delegation unchanged, so the invocation is
|
||||
/// never broader than what was delegated and cannot fail attenuation. No expiry
|
||||
/// is set: the delegation's own `exp` still bounds the chain, because
|
||||
/// `verify_chain` checks each proof's expiry as it recurses.
|
||||
fn build_invocation(
|
||||
agent: &Keypair,
|
||||
node_did: &gitlawb_core::did::Did,
|
||||
delegation: &gitlawb_core::ucan::Ucan,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<gitlawb_core::ucan::Ucan> {
|
||||
use gitlawb_core::ucan::{caps, Capability};
|
||||
|
||||
let resource = format!("gitlawb://repos/{owner}/{repo}");
|
||||
|
||||
// The whole chain has to grant push to this repository, by the same rule the
|
||||
// node applies (`chain_grants_push_to`): a stored token whose proof is still a
|
||||
// wildcard would mint an invocation the node is certain to refuse, and the push
|
||||
// would fail remotely with a vaguer message than this one. Checked before the
|
||||
// leaf is selected so the message names the actual reason.
|
||||
if !delegation.chain_grants_push_to(owner, repo) {
|
||||
anyhow::bail!(
|
||||
"stored delegation does not grant git/push for {resource} on every link of
|
||||
its chain — a proof behind it may be a wildcard or name another repository.
|
||||
Ask the owner to re-issue it against this repository and re-run
|
||||
`gl ucan import`."
|
||||
);
|
||||
}
|
||||
let source = delegation
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.find(|c| c.grants_push_to(owner, repo))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("stored delegation carries no git/push capability for {resource}")
|
||||
})?;
|
||||
|
||||
// The parent's own resource, verbatim: the node compares `with` by equality via
|
||||
// `is_attenuated_by`, and every capability reaching here already names this repo,
|
||||
// so there is nothing left to narrow.
|
||||
let mut narrowed = Capability::new(source.with.clone(), caps::GIT_PUSH);
|
||||
narrowed.constraints = source.constraints.clone();
|
||||
|
||||
// Carry the delegation's own expiry onto the invocation. The node refuses a
|
||||
// chain with any unbounded link, because without a revocation path an
|
||||
// unbounded write capability can never be withdrawn.
|
||||
let exp = delegation
|
||||
.payload
|
||||
.exp
|
||||
.and_then(|e| chrono::DateTime::from_timestamp(e, 0));
|
||||
|
||||
gitlawb_core::ucan::Ucan::delegate(agent, node_did.clone(), vec![narrowed], exp, delegation)
|
||||
.map_err(|e| anyhow::anyhow!("failed to build UCAN invocation: {e}"))
|
||||
}
|
||||
|
||||
/// Split a pack POST URL into `(node_base, owner, repo)`.
|
||||
///
|
||||
/// ```text
|
||||
/// https://node/zOwner/myrepo/git-receive-pack -> ("https://node", "zOwner", "myrepo")
|
||||
/// https://node/gitlawb/zOwner/myrepo/git-receive-pack -> ("https://node/gitlawb", "zOwner", "myrepo")
|
||||
/// ```
|
||||
///
|
||||
/// Strips the KNOWN trailing `<owner>/<repo>/<service>` rather than reading the
|
||||
/// first two segments, because `GITLAWB_NODE` may carry a path prefix — a
|
||||
/// reverse-proxied `https://host/gitlawb` is a supported base, and `repo_base` is
|
||||
/// built as `{node_base}/{owner}/{repo}` with the service appended after it.
|
||||
/// Reading from the front makes the prefix the owner, which fails the delegation
|
||||
/// lookup and probes the wrong URL for the node DID.
|
||||
///
|
||||
/// This is coupled to how `repo_base` is constructed in `main`; the two must
|
||||
/// change together.
|
||||
fn split_pack_post_url(post_url: &str) -> Option<(String, String, String)> {
|
||||
let path = url_path(post_url);
|
||||
let origin = post_url.strip_suffix(&path)?;
|
||||
|
||||
let segs: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
// owner, repo, service — plus any base-path prefix ahead of them.
|
||||
if segs.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let repo = segs[segs.len() - 2];
|
||||
let owner = segs[segs.len() - 3];
|
||||
let prefix = &segs[..segs.len() - 3];
|
||||
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
if !is_safe_component(owner) || !is_safe_component(repo) {
|
||||
return None;
|
||||
}
|
||||
// Every prefix segment stays part of the base the node DID is fetched from, so
|
||||
// it gets the same allow-list: a `..` here would redirect that probe, and an
|
||||
// encoded separator would smuggle structure past this split.
|
||||
if !prefix.iter().all(|s| is_safe_component(s)) {
|
||||
return None;
|
||||
}
|
||||
let node_base = if prefix.is_empty() {
|
||||
origin.to_string()
|
||||
} else {
|
||||
format!("{origin}/{}", prefix.join("/"))
|
||||
};
|
||||
Some((node_base, owner.to_string(), repo.to_string()))
|
||||
}
|
||||
|
||||
/// Build the `X-Ucan` value for a delegated push, or `None` when this push does
|
||||
/// not need one.
|
||||
///
|
||||
/// Entirely best-effort. A missing delegation, an unreachable node, or an
|
||||
/// unreadable stored token all yield `None` and the push proceeds without the
|
||||
/// header — the node decides whether one was required, and a node denial must
|
||||
/// reach the user rather than being pre-empted by a local guess.
|
||||
fn delegation_header(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
keypair: &Keypair,
|
||||
) -> Option<String> {
|
||||
let (origin, owner, repo) = split_pack_post_url(post_url)?;
|
||||
|
||||
// The owner pushes on their own authority; no delegation is involved.
|
||||
let bare = |d: &str| d.strip_prefix("did:key:").unwrap_or(d).to_string();
|
||||
if bare(&keypair.did().to_string()) == bare(&owner) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dir = resolve_identity_dir()?;
|
||||
let path = delegation_path(&dir, &owner, &repo);
|
||||
let raw = std::fs::read_to_string(&path).ok()?;
|
||||
|
||||
let delegation = match gitlawb_core::ucan::Ucan::decode(raw.trim()) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::warn!("stored delegation at {path:?} is unreadable: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// The invocation must be addressed to the node that will execute it.
|
||||
// The shared client carries a 300s timeout, which is right for a pack transfer
|
||||
// and wrong for a best-effort metadata probe: a stalled node would delay every
|
||||
// delegated push by five minutes before falling back to sending no header.
|
||||
let node_did: gitlawb_core::did::Did = client
|
||||
.get(&origin)
|
||||
.timeout(std::time::Duration::from_secs(NODE_DID_TIMEOUT_SECS))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.ok()
|
||||
.and_then(|r| r.json::<serde_json::Value>().ok())
|
||||
.and_then(|v| v.get("did")?.as_str().map(str::to_owned))
|
||||
.or_else(|| {
|
||||
tracing::warn!("could not read the node DID from {origin}; pushing without X-Ucan");
|
||||
None
|
||||
})?
|
||||
.parse()
|
||||
.ok()?;
|
||||
|
||||
match build_invocation(keypair, &node_did, &delegation, &owner, &repo) {
|
||||
Ok(inv) => inv.encode().ok(),
|
||||
Err(e) => {
|
||||
tracing::warn!("could not build the UCAN invocation: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_pack_post_request(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
@@ -626,15 +425,6 @@ fn build_pack_post_request(
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
tracing::debug!("signed {service} POST (DID: {})", kp.did());
|
||||
|
||||
// A non-owner pushing under a delegation presents it here. Only on the
|
||||
// push: a fetch is gated by read visibility, not by git/push.
|
||||
if service == "git-receive-pack" {
|
||||
if let Some(token) = delegation_header(client, post_url, kp) {
|
||||
tracing::debug!("attaching a delegated push capability");
|
||||
req = req.header("X-Ucan", token);
|
||||
}
|
||||
}
|
||||
} else if service == "git-receive-pack" {
|
||||
tracing::warn!("no identity keypair found, push will be unsigned (v0.1 local alpha only)");
|
||||
}
|
||||
@@ -1103,7 +893,7 @@ fn safe_error_body_excerpt(body: &str) -> String {
|
||||
// ── Keypair loading ───────────────────────────────────────────────────────────
|
||||
|
||||
fn load_keypair() -> Option<Keypair> {
|
||||
let key_path = resolve_key_path()?;
|
||||
let key_path = resolve_key_path();
|
||||
if !key_path.exists() {
|
||||
tracing::debug!("no keypair found at {key_path:?}");
|
||||
return None;
|
||||
@@ -1126,38 +916,16 @@ fn load_keypair() -> Option<Keypair> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity PEM, resolved by the same rules `gl` uses.
|
||||
///
|
||||
/// Shared through `gitlawb-core` rather than reimplemented here: this helper and
|
||||
/// `gl ucan import` have to derive the same delegation store from `GITLAWB_KEY`,
|
||||
/// and the two had drifted — the local version read `env::var` (so a non-UTF-8
|
||||
/// value silently became the default key), expanded only a literal `"~/"`, fell
|
||||
/// back to `"."` when `HOME` was unset, and never required an absolute path.
|
||||
///
|
||||
/// `None` means the value is unusable, not that the key is missing. Git runs this
|
||||
/// helper mid-push, so a misconfiguration is logged and the push continues
|
||||
/// unsigned rather than aborting the transfer.
|
||||
fn resolve_key_path() -> Option<std::path::PathBuf> {
|
||||
gitlawb_core::identity_path::identity_key_path(home_dir().as_deref())
|
||||
.inspect_err(|e| tracing::warn!("cannot resolve the identity key path: {e}"))
|
||||
.ok()
|
||||
}
|
||||
fn resolve_key_path() -> std::path::PathBuf {
|
||||
let path_str =
|
||||
std::env::var("GITLAWB_KEY").unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
|
||||
|
||||
/// The directory holding `identity.pem` and `delegations/`.
|
||||
fn resolve_identity_dir() -> Option<std::path::PathBuf> {
|
||||
gitlawb_core::identity_path::identity_dir(home_dir().as_deref())
|
||||
.inspect_err(|e| tracing::warn!("cannot resolve the identity directory: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// `dirs`, not `$HOME`: the old code fell back to `"."` when `HOME` was unset,
|
||||
/// which on Windows is always, so the default key resolved against whatever
|
||||
/// directory git happened to invoke the helper from.
|
||||
///
|
||||
/// `None` is not fatal — an absolute `GITLAWB_KEY` resolves without it, and only
|
||||
/// the default and `~/`-prefixed forms need a home at all.
|
||||
fn home_dir() -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir()
|
||||
if let Some(stripped) = path_str.strip_prefix("~/") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
std::path::PathBuf::from(home).join(stripped)
|
||||
} else {
|
||||
std::path::PathBuf::from(path_str)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -3127,612 +2895,3 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delegated_push_tests {
|
||||
use super::*;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
#[test]
|
||||
fn invocation_wraps_the_delegation_and_targets_the_node() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)],
|
||||
None,
|
||||
)
|
||||
.expect("issue");
|
||||
|
||||
let invocation =
|
||||
build_invocation(&agent, &node.did(), &delegation, "zowner", "r").expect("wrap");
|
||||
|
||||
assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes");
|
||||
assert_eq!(invocation.payload.aud, node.did(), "the node executes");
|
||||
assert_eq!(
|
||||
invocation.payload.prf.len(),
|
||||
1,
|
||||
"exactly one proof: chains are linear"
|
||||
);
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("must verify"),
|
||||
owner.did(),
|
||||
"the chain must still root at the owner after wrapping"
|
||||
);
|
||||
}
|
||||
|
||||
/// An expired delegation cannot be laundered into a live one by wrapping it.
|
||||
///
|
||||
/// Two independent guards now cover this: the invocation inherits the
|
||||
/// delegation's `exp`, so it is expired on its own terms, AND `verify_chain`
|
||||
/// recurses into the proof and rejects it there. Inheriting the expiry is what
|
||||
/// keeps the node's "every link must be bounded" rule satisfiable — a leaf with
|
||||
/// no expiry would be refused outright.
|
||||
#[test]
|
||||
fn an_expired_delegation_cannot_be_laundered_by_wrapping_it() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let expired = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)],
|
||||
Some(chrono::Utc::now() - chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue expired");
|
||||
|
||||
let invocation =
|
||||
build_invocation(&agent, &node.did(), &expired, "zowner", "r").expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation.payload.exp, expired.payload.exp,
|
||||
"the invocation inherits the delegation's expiry, never a longer one"
|
||||
);
|
||||
assert!(
|
||||
!invocation.chain_lifetime_is_bounded() || invocation.is_expired(),
|
||||
"an inherited expiry in the past leaves the invocation expired"
|
||||
);
|
||||
let err = invocation
|
||||
.verify_chain()
|
||||
.expect_err("an expired proof must fail the chain");
|
||||
assert!(
|
||||
err.to_string().contains("expired"),
|
||||
"the failure must name expiry, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipping combination: the owner issues with the FULL DID (what
|
||||
/// `RUN-A-NODE.md` instructs), while the push URL yields the BARE key
|
||||
/// (`parse_gitlawb_url` takes the last colon-delimited segment). Comparing the
|
||||
/// whole resource string never matches, so the delegation is not found, no
|
||||
/// `X-Ucan` is sent, and the delegate gets a 403 telling them to obtain the
|
||||
/// delegation they are already holding.
|
||||
#[test]
|
||||
fn build_invocation_matches_a_full_did_delegation_against_a_bare_owner() {
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let full = owner.did().to_string();
|
||||
let bare = full.strip_prefix("did:key:").unwrap().to_string();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{full}/r"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue");
|
||||
|
||||
let invocation = build_invocation(&agent, &node.did(), &delegation, &bare, "r")
|
||||
.expect("a full-DID delegation must match a bare-owner push URL");
|
||||
|
||||
// The narrowed capability must keep the parent's exact `with`: the node's
|
||||
// `is_attenuated_by` compares it by equality, so emitting the bare form under
|
||||
// a full-DID parent would fail attenuation and be refused at the node.
|
||||
assert_eq!(
|
||||
invocation.payload.att[0].with,
|
||||
format!("gitlawb://repos/{full}/r"),
|
||||
"narrowing must not rewrite a resource that already names this repo"
|
||||
);
|
||||
assert!(
|
||||
invocation.payload.att[0].is_attenuated_by(&delegation.payload.att[0]),
|
||||
"the narrowed capability must still attenuate under its parent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wildcard delegation is refused locally now, rather than narrowed.
|
||||
///
|
||||
/// Narrowing `*` to the pushed repo is what let one delegation reach every
|
||||
/// repository the owner had or later created: the node saw a concrete leaf and
|
||||
/// the `*` proof behind it satisfied attenuation. The node now requires every
|
||||
/// link to name the repository, so minting from a wildcard could only produce a
|
||||
/// push refused remotely with a vaguer message. Failing here says why.
|
||||
#[test]
|
||||
fn build_invocation_refuses_a_wildcard_delegation() {
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue");
|
||||
|
||||
assert!(
|
||||
build_invocation(&agent, &node.did(), &delegation, "z6MkOwner", "r").is_err(),
|
||||
"a bare wildcard cannot be narrowed into a usable invocation any more"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stored two-link delegation whose leaf names this repo but whose proof is
|
||||
/// `*`. The leaf alone would select fine; the node's chain walk refuses it, so
|
||||
/// the helper refuses it first with a message that says why.
|
||||
#[test]
|
||||
fn build_invocation_refuses_a_concrete_leaf_on_a_wildcard_proof() {
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let owner_bare = owner
|
||||
.did()
|
||||
.to_string()
|
||||
.strip_prefix("did:key:")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let proof = Ucan::issue(
|
||||
&owner,
|
||||
intermediary.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue");
|
||||
let stored = Ucan::delegate(
|
||||
&intermediary,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_bare}/r"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
&proof,
|
||||
)
|
||||
.expect("re-delegate");
|
||||
|
||||
let err = build_invocation(&agent, &node.did(), &stored, &owner_bare, "r")
|
||||
.expect_err("a wildcard proof must fail closed in the helper");
|
||||
assert!(
|
||||
err.to_string().contains("every link"),
|
||||
"the message must point at the chain, not the leaf: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A delegation holding both a constrained and an unconstrained grant must work
|
||||
/// regardless of their order. The node refuses a constrained leaf outright, so
|
||||
/// picking merely the FIRST push-class capability made a valid delegation
|
||||
/// succeed or fail purely on how the owner happened to order `att`.
|
||||
#[test]
|
||||
fn build_invocation_skips_a_constrained_capability_in_either_order() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = "gitlawb://repos/z6MkOwner/r";
|
||||
|
||||
let mut constrained = Capability::new(resource, caps::GIT_PUSH);
|
||||
constrained.constraints = Some(serde_json::json!({"max_bytes": 1}));
|
||||
let usable = Capability::new(resource, caps::GIT_PUSH);
|
||||
|
||||
for (label, att) in [
|
||||
(
|
||||
"constrained first",
|
||||
vec![constrained.clone(), usable.clone()],
|
||||
),
|
||||
("usable first", vec![usable.clone(), constrained.clone()]),
|
||||
] {
|
||||
let delegation = Ucan::issue(&owner, agent.did(), att, Some(hour)).expect("issue");
|
||||
let invocation = build_invocation(&agent, &node.did(), &delegation, "z6MkOwner", "r")
|
||||
.unwrap_or_else(|e| panic!("{label}: {e}"));
|
||||
assert!(
|
||||
invocation.payload.att[0].constraints.is_none(),
|
||||
"{label}: the invocation must carry the capability the node can authorize"
|
||||
);
|
||||
}
|
||||
|
||||
// With ONLY a constrained grant there is nothing the node would accept, so
|
||||
// failing here beats minting an invocation guaranteed to be refused.
|
||||
let only_constrained =
|
||||
Ucan::issue(&owner, agent.did(), vec![constrained], Some(hour)).expect("issue");
|
||||
assert!(
|
||||
build_invocation(&agent, &node.did(), &only_constrained, "z6MkOwner", "r").is_err(),
|
||||
"a delegation with no unconstrained grant must not produce an invocation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_pack_post_url_separates_origin_owner_and_repo() {
|
||||
assert_eq!(
|
||||
split_pack_post_url("http://127.0.0.1:7545/z6Mk/myrepo.git/git-receive-pack"),
|
||||
Some((
|
||||
"http://127.0.0.1:7545".to_string(),
|
||||
"z6Mk".to_string(),
|
||||
"myrepo".to_string()
|
||||
)),
|
||||
"the origin must come back intact so the node DID can be fetched from it"
|
||||
);
|
||||
// A reverse-proxied GITLAWB_NODE carries a path prefix, which survives into
|
||||
// the pack URL through `repo_base`. Reading the FIRST two segments as
|
||||
// owner/repo makes the prefix the owner: the delegation lookup misses, the
|
||||
// DID probe hits the wrong URL, no X-Ucan is sent, and a valid delegate is
|
||||
// refused with 403 — silently, because every failure here is best-effort.
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://host/gitlawb/z6Mk/myrepo/git-receive-pack"),
|
||||
Some((
|
||||
"https://host/gitlawb".to_string(),
|
||||
"z6Mk".to_string(),
|
||||
"myrepo".to_string()
|
||||
)),
|
||||
"a path-prefixed node base must keep its prefix and still find owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://host/a/b/c/z6Mk/myrepo/git-receive-pack"),
|
||||
Some((
|
||||
"https://host/a/b/c".to_string(),
|
||||
"z6Mk".to_string(),
|
||||
"myrepo".to_string()
|
||||
)),
|
||||
"prefix depth is not fixed"
|
||||
);
|
||||
// The .git suffix is optional on the wire; the delegation is stored under
|
||||
// the bare repo name either way, so both forms must resolve identically.
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://node.example/z6Mk/myrepo/git-receive-pack")
|
||||
.map(|(_, _, r)| r),
|
||||
Some("myrepo".to_string())
|
||||
);
|
||||
// A repo genuinely named "x.git" keeps its name: only one suffix is stripped.
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://node.example/z6Mk/x.git.git/git-receive-pack")
|
||||
.map(|(_, _, r)| r),
|
||||
Some("x.git".to_string())
|
||||
);
|
||||
for bad in [
|
||||
"not-a-url",
|
||||
"https://node.example",
|
||||
"https://node.example/",
|
||||
"https://node.example/onlyowner",
|
||||
// A traversing owner would read an arbitrary file and send it to the
|
||||
// node as X-Ucan, so it must not resolve to a lookup at all.
|
||||
"https://node.example/../../etc/passwd/git-receive-pack",
|
||||
"https://node.example/../x/git-receive-pack",
|
||||
"https://node.example/a%2Fb/x/git-receive-pack",
|
||||
] {
|
||||
assert!(
|
||||
split_pack_post_url(bad).is_none(),
|
||||
"{bad} must not parse as a pack POST URL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegation_path_matches_the_gl_layout() {
|
||||
let base = std::path::Path::new("/tmp/id");
|
||||
let expected = base.join("delegations").join("z6MkAbc__myrepo.ucan");
|
||||
assert_eq!(delegation_path(base, "did:key:z6MkAbc", "myrepo"), expected);
|
||||
assert_eq!(delegation_path(base, "z6MkAbc", "myrepo"), expected);
|
||||
}
|
||||
|
||||
// ── delegation_header ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Everything above tests one piece in isolation. `delegation_header` is where
|
||||
// they compose — URL split, owner comparison, store lookup, node-DID probe,
|
||||
// invocation build — and it is the piece with no safety net: every failure
|
||||
// inside it is deliberately silent, so a regression does not fail loudly, it
|
||||
// just stops attaching `X-Ucan` and the delegate starts getting 403s with no
|
||||
// local explanation.
|
||||
|
||||
/// `delegation_header` resolves its store from `GITLAWB_KEY`, which is
|
||||
/// process-global. Every case that sets it takes this lock.
|
||||
///
|
||||
/// Only these cases need it. `advertisement_and_pack_post_are_signed_…` also
|
||||
/// reaches `delegation_header` (through `build_pack_post_request` on
|
||||
/// `git-receive-pack`), but it asserts on signature headers alone and is
|
||||
/// unaffected by whichever store is in scope.
|
||||
static KEY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point `GITLAWB_KEY` at `dir/identity.pem` for the duration of `f`, so the
|
||||
/// delegation store resolves to `dir/delegations`.
|
||||
fn with_identity_dir<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let restore = std::env::var_os("GITLAWB_KEY");
|
||||
std::env::set_var("GITLAWB_KEY", dir.join("identity.pem"));
|
||||
let out = f();
|
||||
match restore {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write a token where `gl ucan import` would have left it.
|
||||
fn store_delegation(dir: &std::path::Path, owner: &str, repo: &str, raw: &str) {
|
||||
let path = delegation_path(dir, owner, repo);
|
||||
std::fs::create_dir_all(path.parent().expect("delegations dir")).expect("mkdir");
|
||||
std::fs::write(path, raw).expect("write delegation");
|
||||
}
|
||||
|
||||
fn bare(did: &gitlawb_core::did::Did) -> String {
|
||||
did.to_string()
|
||||
.strip_prefix("did:key:")
|
||||
.expect("did:key")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn push_delegation(owner: &Keypair, agent: &Keypair, resource: &str) -> Ucan {
|
||||
Ucan::issue(
|
||||
owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(resource, caps::GIT_PUSH)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue")
|
||||
}
|
||||
|
||||
fn did_body(node: &Keypair) -> String {
|
||||
format!(r#"{{"did":"{}"}}"#, node.did())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegation_header_wraps_a_stored_delegation_and_targets_the_node() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
|
||||
let delegation = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"));
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let did_probe = server
|
||||
.mock("GET", "/")
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(did_body(&node))
|
||||
.create();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(
|
||||
dir.path(),
|
||||
&owner_key,
|
||||
"r",
|
||||
&delegation.encode().expect("encode"),
|
||||
);
|
||||
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let header =
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
|
||||
.expect("a stored delegation must produce an X-Ucan");
|
||||
|
||||
did_probe.assert();
|
||||
|
||||
let invocation = Ucan::decode(&header).expect("the header must decode as a UCAN");
|
||||
assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes");
|
||||
assert_eq!(
|
||||
invocation.payload.aud,
|
||||
node.did(),
|
||||
"addressed to the node that will execute it"
|
||||
);
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("chain must verify"),
|
||||
owner.did(),
|
||||
"the chain must root at the repo owner"
|
||||
);
|
||||
assert!(
|
||||
invocation.chain_lifetime_is_bounded(),
|
||||
"the node refuses an unbounded push chain, so every link must carry an expiry"
|
||||
);
|
||||
}
|
||||
|
||||
/// The owner pushes on their own authority. The comparison has to survive the
|
||||
/// form mismatch — the keypair holds `did:key:z…`, the URL carries the bare key
|
||||
/// — or the owner takes the delegate path, finds nothing, and pays a node
|
||||
/// round-trip on every push. `.expect(0)` is the assertion that matters here.
|
||||
#[test]
|
||||
fn delegation_header_is_skipped_when_the_pusher_is_the_owner() {
|
||||
let owner = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let did_probe = server
|
||||
.mock("GET", "/")
|
||||
.with_body(did_body(&node))
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
// A delegation the owner does not need. Present so the assertion is about
|
||||
// the owner check and not about an empty store.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let delegate = Keypair::generate();
|
||||
store_delegation(
|
||||
dir.path(),
|
||||
&owner_key,
|
||||
"r",
|
||||
&push_delegation(&owner, &delegate, &format!("gitlawb://repos/{owner_key}/r"))
|
||||
.encode()
|
||||
.expect("encode"),
|
||||
);
|
||||
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let header =
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &owner));
|
||||
|
||||
assert!(header.is_none(), "the owner needs no delegation");
|
||||
did_probe.assert();
|
||||
}
|
||||
|
||||
/// Best-effort means best-effort: nothing here may panic or block the push. The
|
||||
/// node decides whether a delegation was required, and its denial has to reach
|
||||
/// the user instead of being pre-empted by a local guess.
|
||||
#[test]
|
||||
fn delegation_header_is_absent_without_a_usable_stored_delegation() {
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&Keypair::generate().did());
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let _did = server.mock("GET", "/").with_body(did_body(&node)).create();
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
// Nothing stored at all.
|
||||
let empty = tempfile::tempdir().expect("tempdir");
|
||||
assert!(
|
||||
with_identity_dir(empty.path(), || delegation_header(
|
||||
&client, &post_url, &agent
|
||||
))
|
||||
.is_none(),
|
||||
"an empty store must yield no header, not an error"
|
||||
);
|
||||
|
||||
// Stored, but not a UCAN — a truncated write or a hand-edited file.
|
||||
let garbage = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(garbage.path(), &owner_key, "r", "not a ucan");
|
||||
assert!(
|
||||
with_identity_dir(garbage.path(), || delegation_header(
|
||||
&client, &post_url, &agent
|
||||
))
|
||||
.is_none(),
|
||||
"an unreadable stored token must yield no header, not a panic"
|
||||
);
|
||||
|
||||
// Stored and valid, but for a capability the push path cannot use. `gl ucan
|
||||
// import` refuses these now; a store written by an older `gl` still holds them.
|
||||
let owner = Keypair::generate();
|
||||
let wrong_owner_key = bare(&owner.did());
|
||||
let unusable = tempfile::tempdir().expect("tempdir");
|
||||
let fetch_only = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{wrong_owner_key}/r"),
|
||||
caps::GIT_FETCH,
|
||||
)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue");
|
||||
store_delegation(
|
||||
unusable.path(),
|
||||
&wrong_owner_key,
|
||||
"r",
|
||||
&fetch_only.encode().expect("encode"),
|
||||
);
|
||||
let fetch_url = format!("{}/{wrong_owner_key}/r/git-receive-pack", server.url());
|
||||
assert!(
|
||||
with_identity_dir(unusable.path(), || delegation_header(
|
||||
&client, &fetch_url, &agent
|
||||
))
|
||||
.is_none(),
|
||||
"a git/fetch delegation carries no push capability to wrap"
|
||||
);
|
||||
}
|
||||
|
||||
/// The node DID addresses the invocation, so without it there is nothing to
|
||||
/// build. A node that is down, slow, or serving something other than JSON must
|
||||
/// cost the push a header, never an abort.
|
||||
#[test]
|
||||
fn delegation_header_is_absent_when_the_node_did_cannot_be_read() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
let encoded = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"))
|
||||
.encode()
|
||||
.expect("encode");
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
for (label, status, body) in [
|
||||
("a 500 from the node", 500, "boom"),
|
||||
("a JSON body with no did", 200, r#"{"name":"gitlawb"}"#),
|
||||
("an HTML error page from a proxy", 200, "<html>502</html>"),
|
||||
("a did that does not parse", 200, r#"{"did":"not-a-did"}"#),
|
||||
] {
|
||||
let mut server = mockito::Server::new();
|
||||
let _did = server
|
||||
.mock("GET", "/")
|
||||
.with_status(status)
|
||||
.with_body(body)
|
||||
.create();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(dir.path(), &owner_key, "r", &encoded);
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
|
||||
assert!(
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
|
||||
.is_none(),
|
||||
"{label} must drop the header, not fail the push"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reverse-proxied `GITLAWB_NODE` carries a path prefix, and that prefix
|
||||
/// survives into the pack URL. `split_pack_post_url` is unit-tested for it, but
|
||||
/// nothing checked that the probe actually goes to the prefixed base — a
|
||||
/// regression there would GET `/` on the proxy host, read whatever landing page
|
||||
/// it serves, and silently drop the header. `.expect(0)` on `/` is the half that
|
||||
/// catches it.
|
||||
#[test]
|
||||
fn delegation_header_probes_the_prefixed_node_base() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let prefixed = server
|
||||
.mock("GET", "/gitlawb")
|
||||
.with_body(did_body(&node))
|
||||
.create();
|
||||
let root = server.mock("GET", "/").expect(0).create();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(
|
||||
dir.path(),
|
||||
&owner_key,
|
||||
"r",
|
||||
&push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"))
|
||||
.encode()
|
||||
.expect("encode"),
|
||||
);
|
||||
|
||||
let post_url = format!("{}/gitlawb/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let header =
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
|
||||
.expect("a path-prefixed node base must still yield an X-Ucan");
|
||||
|
||||
prefixed.assert();
|
||||
root.assert();
|
||||
assert_eq!(
|
||||
Ucan::decode(&header).expect("decode").payload.aud,
|
||||
node.did()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -137,11 +137,6 @@ enum ShimMode {
|
||||
struct Shim {
|
||||
base_url: String,
|
||||
posts: Arc<AtomicUsize>,
|
||||
/// Advertisement (GET /info/refs) count. One `git fetch` performs exactly
|
||||
/// one advertisement, so tests assert this is EXACTLY one: any fetch-level
|
||||
/// retry that comes back re-advertises and goes red on the spot, which the
|
||||
/// lower-bound POST assertions alone cannot see (#275 round 6).
|
||||
gets: Arc<AtomicUsize>,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
@@ -171,17 +166,15 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{addr}");
|
||||
let posts = Arc::new(AtomicUsize::new(0));
|
||||
let gets = Arc::new(AtomicUsize::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let posts_t = posts.clone();
|
||||
let gets_t = gets.clone();
|
||||
let stop_t = stop.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
while !stop_t.load(Ordering::SeqCst) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
handle_conn(stream, &repo, mode, &posts_t, &gets_t);
|
||||
handle_conn(stream, &repo, mode, &posts_t);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
@@ -194,7 +187,6 @@ fn start_shim(repo: PathBuf, mode: ShimMode) -> Shim {
|
||||
Shim {
|
||||
base_url,
|
||||
posts,
|
||||
gets,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
}
|
||||
@@ -220,13 +212,7 @@ fn normalize_accepted_stream(stream: &TcpStream) {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
}
|
||||
|
||||
fn handle_conn(
|
||||
stream: TcpStream,
|
||||
repo: &Path,
|
||||
mode: ShimMode,
|
||||
posts: &AtomicUsize,
|
||||
gets: &AtomicUsize,
|
||||
) {
|
||||
fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsize) {
|
||||
normalize_accepted_stream(&stream);
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
@@ -277,7 +263,6 @@ fn handle_conn(
|
||||
receive_pack(repo, false, &body),
|
||||
)
|
||||
} else if method == "GET" && target.contains("/info/refs") {
|
||||
gets.fetch_add(1, Ordering::SeqCst);
|
||||
// v0 advertisement, wrapped exactly as the node's info_refs does.
|
||||
let adv = upload_pack(repo, true, b"");
|
||||
let mut wrapped = pkt(b"# service=git-upload-pack\n");
|
||||
@@ -1022,7 +1007,6 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
let posts = AtomicUsize::new(0);
|
||||
let gets = AtomicUsize::new(0);
|
||||
let stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((s, _)) => break s,
|
||||
@@ -1037,7 +1021,7 @@ fn shim_answers_a_connection_that_arrives_non_blocking() {
|
||||
// the assertions below run on every platform.
|
||||
stream.set_nonblocking(true).unwrap();
|
||||
accepted_tx.send(()).unwrap();
|
||||
handle_conn(stream, &repo, ShimMode::Normal, &posts, &gets);
|
||||
handle_conn(stream, &repo, ShimMode::Normal, &posts);
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).unwrap();
|
||||
@@ -1101,17 +1085,6 @@ fn real_git_multi_round_fetch_completes() {
|
||||
posts >= 2,
|
||||
"fixture did not force multi-round negotiation (observed {posts} POST(s)); the bridging path was not exercised"
|
||||
);
|
||||
// Exactly one advertisement: the POST bound above is a floor, so a
|
||||
// reintroduced fetch-level retry could satisfy it by running a second full
|
||||
// fetch instead of multi-round negotiation. Re-advertising is what a
|
||||
// second fetch cannot avoid, so this is the committed guard on the
|
||||
// retry's absence.
|
||||
let gets = shim.gets.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
gets, 1,
|
||||
"expected exactly one advertisement GET per fetch; {gets} means a \
|
||||
fetch-level retry is back"
|
||||
);
|
||||
|
||||
// The fetched tip is present and the clone's object graph is intact.
|
||||
let server_head = String::from_utf8(git(&server, &["rev-parse", "HEAD"])).unwrap();
|
||||
@@ -1204,17 +1177,6 @@ fn real_git_withheld_shaped_first_post() {
|
||||
"helper hung on a withheld-shaped response (should forward-and-terminate, not deadlock). stderr:\n{stderr}"
|
||||
);
|
||||
|
||||
// Exactly one advertisement, pass or fail: the POST assertions below are
|
||||
// floors, so a reintroduced fetch-level retry could mask the withheld
|
||||
// shape behind a second full fetch. A retry cannot avoid re-advertising,
|
||||
// so this is the committed guard on its absence.
|
||||
let gets = shim.gets.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
gets, 1,
|
||||
"expected exactly one advertisement GET per fetch; {gets} means a \
|
||||
fetch-level retry is back"
|
||||
);
|
||||
|
||||
if out.status.success() {
|
||||
// Real git accepted the mid-negotiation pack: the withheld multi-round
|
||||
// path works end to end with no extra handling.
|
||||
|
||||
@@ -504,23 +504,6 @@ mod tests {
|
||||
assert!(matches!(err, Error::Did(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_oversized_method_id_before_decoding() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
// A well-formed prefix on an absurdly long id must fail the length
|
||||
// bound, not the decoder: base58 decode cost is quadratic in input
|
||||
// length, so reaching it at all is the defect.
|
||||
att.signer = format!("did:key:z{}", "1".repeat(256));
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
// Match the bound's own message: any other Did error (a decode or
|
||||
// length mismatch downstream) means the oversized input was decoded.
|
||||
match err {
|
||||
Error::Did(m) => assert!(m.contains("too long"), "unexpected: {m}"),
|
||||
e => panic!("expected Error::Did, got {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_bad_signature_base64() {
|
||||
let sk = fresh();
|
||||
@@ -539,43 +522,6 @@ mod tests {
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
|
||||
/// The identity-point forgery must be rejected: the shared attestation
|
||||
/// verifier is a cert-bound provenance gate, so accepting the weak-key
|
||||
/// signature would let anyone mint a forged attestation that verifies.
|
||||
/// Strict verification rejects small-order public keys and R (the identity
|
||||
/// point here), which ordinary verification does not.
|
||||
#[test]
|
||||
fn verify_rejects_identity_point_forgery() {
|
||||
let cert_hash = sample_cert_hash();
|
||||
let mut att = dummy_attestation(&fresh(), cert_hash);
|
||||
|
||||
// Public key A = identity point (0,1); signature R = identity, S = 0.
|
||||
// The equation `[S]B = R + [k]A` then holds for any k and any message.
|
||||
let identity = [
|
||||
1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
];
|
||||
let mut buf = Vec::with_capacity(34);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&identity);
|
||||
att.signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
let mut sig = [0u8; 64];
|
||||
sig[..32].copy_from_slice(&identity);
|
||||
att.sig = B64U.encode(sig);
|
||||
|
||||
let err = att.verify_signature(cert_hash).unwrap_err();
|
||||
match err {
|
||||
Error::Did(msg) => assert!(
|
||||
msg.contains("small-order"),
|
||||
"identity-point signer must be refused as a small-order key, got: {msg}"
|
||||
),
|
||||
other => panic!("expected Error::Did for an identity-point signer, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A payload that happens to contain a `cert_hash` field of its own does
|
||||
/// not interfere with the outer binding: the attestation envelope's
|
||||
/// `cert_hash` is the only field consulted by `verify_signature`, and the
|
||||
|
||||
@@ -150,48 +150,23 @@ impl Registry {
|
||||
}
|
||||
|
||||
/// Verify a batch, then enforce `RequireAll`.
|
||||
///
|
||||
/// Under [`Policy::RequireAll`] this never short-circuits: an attestation
|
||||
/// that fails to verify — bad signature, cert-hash mismatch, or malformed
|
||||
/// payload, the actual `Err` paths of [`Registry::verify`] — is dropped
|
||||
/// from the result rather than aborting the batch. A valid attestation of
|
||||
/// a type with NO registered verifier is not one of those: under
|
||||
/// `RequireAll` it verifies to `Ok` with `fully_verified: false` and stays
|
||||
/// in the result, preserving the distinction between *untrusted* and
|
||||
/// *invalid* (see `require_all_is_lenient_on_unknown_types_in_the_batch`).
|
||||
/// Any third party can attach an attestation, so a hard error here would
|
||||
/// let an unrelated `attacker/spam/v1` deny-of-service the whole cert (the
|
||||
/// DoS vector the module docstring warns about). The `required_types`
|
||||
/// check below is the real gate, and it only counts `fully_verified`
|
||||
/// entries — so a required type represented solely by a malformed
|
||||
/// attestation still fails with [`Error::RequiredMissing`].
|
||||
///
|
||||
/// Under `AcceptKnown` and `RejectUnknown` the batch stays strict: the
|
||||
/// first verification error is surfaced to the caller.
|
||||
pub fn verify_all(
|
||||
&self,
|
||||
attestations: &[Attestation],
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<Vec<VerifiedAttestation>> {
|
||||
if self.policy != Policy::RequireAll {
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
}
|
||||
return Ok(verified);
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
}
|
||||
|
||||
// RequireAll: drop entries that fail to verify instead of aborting.
|
||||
let verified: Vec<VerifiedAttestation> = attestations
|
||||
.iter()
|
||||
.filter_map(|a| self.verify(a, expected_cert_hash).ok())
|
||||
.collect();
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
if self.policy == Policy::RequireAll {
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(verified)
|
||||
@@ -349,38 +324,6 @@ mod tests {
|
||||
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
|
||||
}
|
||||
|
||||
/// The strict policies must surface a batch member's verification error
|
||||
/// through `verify_all`, not launder it into a partial result. This binds
|
||||
/// the POLICY BOUNDARY itself: only the `RequireAll` arm may drop failing
|
||||
/// entries, and an apparently harmless cleanup that applies its
|
||||
/// `filter_map(Result::ok)` shape to the strict arms would silently turn
|
||||
/// the fail-closed contracts into fail-open — with every per-attestation
|
||||
/// `verify` test still green, because none of them go through the batch
|
||||
/// path. The failing entry is a wrong-cert-hash attestation (signed over a
|
||||
/// different hash than the batch expects), a genuine `Err` under every
|
||||
/// policy.
|
||||
#[test]
|
||||
fn strict_policies_propagate_a_batch_members_error() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut wrong_hash = sample_hash();
|
||||
wrong_hash[0] ^= 0xff;
|
||||
|
||||
for policy in [Policy::AcceptKnown, Policy::RejectUnknown] {
|
||||
let mut reg = Registry::new().with_policy(policy);
|
||||
reg.register(DemoVerifier);
|
||||
let good = signed_demo(&sk, cert_hash, "ok");
|
||||
let bad = signed_demo(&sk, wrong_hash, "ok");
|
||||
let err = reg
|
||||
.verify_all(&[good, bad], cert_hash)
|
||||
.expect_err("a strict policy must fail the whole batch");
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_) | Error::CertHashMismatch),
|
||||
"expected the underlying verification error, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A required type that is present but unverified (no verifier registered
|
||||
/// even though the type appears in `required_types`) must fail
|
||||
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
|
||||
@@ -398,65 +341,6 @@ mod tests {
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
/// A cert-hash that does not match `sample_hash()`, so an attestation
|
||||
/// signed against it fails `verify_signature` when the batch is checked
|
||||
/// against `sample_hash()`.
|
||||
fn other_hash() -> [u8; 32] {
|
||||
let mut h = sample_hash();
|
||||
h[0] ^= 0xff;
|
||||
h
|
||||
}
|
||||
|
||||
/// Under `RequireAll`, a malformed attestation (here: signed against the
|
||||
/// wrong cert hash, so it fails signature verification) attached alongside a
|
||||
/// valid required one must not abort the batch. The junk is dropped and the
|
||||
/// cert still verifies. Before the fix, `verify_all` propagated the error
|
||||
/// via `?` and the whole batch failed — the DoS vector the docstring warns
|
||||
/// about.
|
||||
#[test]
|
||||
fn require_all_drops_a_malformed_extra_and_still_accepts() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
// Signed against `other_hash()`, so it will not verify under `cert_hash`.
|
||||
let malformed = signed_demo(&sk, other_hash(), "boom");
|
||||
let real = signed_demo(&sk, cert_hash, "ok");
|
||||
|
||||
let verified = reg
|
||||
.verify_all(&[malformed, real], cert_hash)
|
||||
.expect("a malformed extra must not abort the batch under RequireAll");
|
||||
assert_eq!(verified.len(), 1, "the malformed entry should be dropped");
|
||||
assert!(verified
|
||||
.iter()
|
||||
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
|
||||
}
|
||||
|
||||
/// Leniency must not open a hole: if the *only* attestation of a required
|
||||
/// type is malformed, dropping it leaves the required type absent and
|
||||
/// `verify_all` must still fail with `RequiredMissing`. Guards against a fix
|
||||
/// that skips errors so eagerly it lets an unverified required type pass.
|
||||
#[test]
|
||||
fn require_all_rejects_when_only_a_malformed_required_type_is_present() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
// The sole demo/v1 is bound to the wrong cert hash → fails verification.
|
||||
let malformed_required = signed_demo(&sk, other_hash(), "ok");
|
||||
|
||||
let err = reg
|
||||
.verify_all(&[malformed_required], cert_hash)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_check_failure_rejects() {
|
||||
let sk = fresh();
|
||||
|
||||
+11
-381
@@ -5,28 +5,6 @@
|
||||
//!
|
||||
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
|
||||
//! Nodes that receive a certificate with an unknown version MUST reject it.
|
||||
//!
|
||||
//! ## Replay
|
||||
//!
|
||||
//! A certificate proves *who* asserted a ref update and *what* they asserted.
|
||||
//! It does not prove the assertion is fresh. [`RefUpdateBody::seq`] and
|
||||
//! [`RefUpdateBody::nonce`] are inside the signed bytes (see
|
||||
//! [`RefUpdateBody::to_signing_bytes`]), so a verifier can trust the values,
|
||||
//! but deciding whether a nonce is being reused or a `seq` is going backwards
|
||||
//! needs state.
|
||||
//!
|
||||
//! This crate is deliberately stateless — it is embedded in both nodes and
|
||||
//! clients — so replay is **not** enforced here and cannot be. Ownership of
|
||||
//! the check sits with the verifier that accepts and persists certificates,
|
||||
//! which must:
|
||||
//!
|
||||
//! - reject a `nonce` it has already accepted for the same `(repo, ref_name)`;
|
||||
//! - reject a `seq` that does not advance past the last accepted `seq` for
|
||||
//! that `(repo, ref_name)`.
|
||||
//!
|
||||
//! Until such a verifier exists, replay of ref-update certificates is not
|
||||
//! enforced anywhere in this workspace. This contract exists so the first one
|
||||
//! inherits the requirement instead of re-deriving it.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -41,30 +19,6 @@ use crate::{Error, Result};
|
||||
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
|
||||
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
|
||||
|
||||
/// Exact url-safe unpadded base64 length of a 64-byte Ed25519 signature.
|
||||
/// The tolerant threshold scan requires this canonical unpadded length before
|
||||
/// attempting decode: the length gate is a DoS bound that keeps an
|
||||
/// attacker-sized sig string from costing allocation before the signature is
|
||||
/// rejected. A longer string is not meaningless (the same 64 bytes can appear
|
||||
/// in padded or non-url-safe encodings) but no in-tree signer produces one,
|
||||
/// so the scan skips non-canonical entries rather than decoding them.
|
||||
const ENCODED_SIG_LEN: usize = 86;
|
||||
|
||||
/// Hard ceiling on signature entries a certificate may carry into the
|
||||
/// tolerant threshold scan. A legitimate certificate holds one entry per
|
||||
/// maintainer (a re-sign after key rotation may briefly leave a second), so
|
||||
/// this is far above any real signer set, while turning the scan's worst case
|
||||
/// from "one curve verification per attacker-appended entry" into a small
|
||||
/// constant. Rejection is loud (`Err`), not a silent prefix scan: scanning
|
||||
/// only the first N entries would let junk placed ahead of the real
|
||||
/// signatures starve them out of the window, which is the #349 denial again
|
||||
/// in positional form. An attacker positioned to inflate the list past this
|
||||
/// ceiling can already suppress the certificate outright. The loud
|
||||
/// rejection does deny a legitimately over-long certificate; that is an
|
||||
/// explicit, detectable denial above the cap rather than a silent window
|
||||
/// junk placement could exploit.
|
||||
const MAX_SIGNATURE_ENTRIES: usize = 64;
|
||||
|
||||
/// A signature on a ref-update certificate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CertSignature {
|
||||
@@ -87,19 +41,11 @@ pub struct RefUpdateBody {
|
||||
pub from: String,
|
||||
/// The new commit hash (SHA-256 hex).
|
||||
pub to: String,
|
||||
/// Monotonically increasing sequence number, scoped to `(repo, ref_name)`.
|
||||
///
|
||||
/// Signed, but not enforced by this crate: core holds no state. The
|
||||
/// verifier that persists certificates must reject a `seq` that does not
|
||||
/// advance for the same repo and ref. See the module docs on replay.
|
||||
/// Monotonically increasing sequence number. Prevents replay.
|
||||
pub seq: u64,
|
||||
/// RFC 3339 timestamp of this update.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Random nonce, unique per certificate.
|
||||
///
|
||||
/// Signed, but not enforced by this crate: core holds no state. The
|
||||
/// verifier that persists certificates must reject a nonce it has already
|
||||
/// accepted for the same repo and ref. See the module docs on replay.
|
||||
/// Random nonce for deduplication.
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
@@ -162,15 +108,9 @@ impl RefUpdateCert {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify all signatures on this certificate, failing closed.
|
||||
/// Verify all signatures on this certificate.
|
||||
///
|
||||
/// On success returns the list of DIDs whose signatures are valid. This is
|
||||
/// strict: it returns an error if *any* signature entry is malformed or
|
||||
/// invalid. Because signature entries live outside the signed body (anyone
|
||||
/// can append one without key material), do not use this to make a
|
||||
/// threshold decision on an untrusted certificate — a single appended junk
|
||||
/// entry would reject the whole cert. Use [`Self::satisfies_threshold`],
|
||||
/// which ignores invalid entries and counts only the valid signers.
|
||||
/// Returns the list of DIDs whose signatures are valid.
|
||||
pub fn verify_all(&self) -> Result<Vec<Did>> {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let signing_bytes = self.body.to_signing_bytes()?;
|
||||
@@ -199,65 +139,12 @@ impl RefUpdateCert {
|
||||
/// from the provided set of authorized maintainer DIDs.
|
||||
///
|
||||
/// Counts distinct signer DIDs, not signature entries: a repeated
|
||||
/// signature from the same maintainer counts once. Malformed and invalid
|
||||
/// signature entries are skipped, not fatal — entries live outside the
|
||||
/// signed body, so any third party can append junk without key material,
|
||||
/// and short-circuiting on the first bad one (as [`Self::verify_all`]
|
||||
/// does) would let that junk deny an otherwise valid certificate (#349).
|
||||
///
|
||||
/// Tolerance must not become an unbounded verification path (#349 review
|
||||
/// round 2): entries are attacker-extensible, so the work done for them is
|
||||
/// bounded BEFORE the loop, not discovered inside it:
|
||||
///
|
||||
/// - a list over [`MAX_SIGNATURE_ENTRIES`] is rejected outright (see the
|
||||
/// constant for why loud rejection beats a silent prefix scan);
|
||||
/// - an encoded signature is length-gated before base64 work — a 64-byte
|
||||
/// Ed25519 signature is exactly [`ENCODED_SIG_LEN`] characters in
|
||||
/// url-safe unpadded base64, so an oversized blob never allocates;
|
||||
/// - entries whose signer is not in `maintainers`, or is already counted,
|
||||
/// are skipped on a string compare, before any decode or curve work —
|
||||
/// so even within the cap, junk naming strangers costs no crypto;
|
||||
/// - the scan returns as soon as the threshold is met.
|
||||
/// signature from the same maintainer counts once.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
if threshold == 0 {
|
||||
return Err(Error::RefCert("threshold must be at least 1".to_string()));
|
||||
}
|
||||
if self.signatures.len() > MAX_SIGNATURE_ENTRIES {
|
||||
return Err(Error::RefCert(format!(
|
||||
"certificate carries {} signature entries (max {})",
|
||||
self.signatures.len(),
|
||||
MAX_SIGNATURE_ENTRIES
|
||||
)));
|
||||
}
|
||||
let signing_bytes = self.body.to_signing_bytes()?;
|
||||
let mut counted: HashSet<&Did> = HashSet::new();
|
||||
|
||||
for cert_sig in &self.signatures {
|
||||
if counted.contains(&cert_sig.signer) || !maintainers.contains(&cert_sig.signer) {
|
||||
continue;
|
||||
}
|
||||
if cert_sig.sig.len() != ENCODED_SIG_LEN {
|
||||
continue;
|
||||
}
|
||||
let Ok(vk) = cert_sig.signer.to_verifying_key() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(sig_bytes_vec) = URL_SAFE_NO_PAD.decode(&cert_sig.sig) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(sig_bytes) = <[u8; 64]>::try_from(sig_bytes_vec) else {
|
||||
continue;
|
||||
};
|
||||
if verify(&vk, &signing_bytes, &sig_bytes).is_ok() {
|
||||
counted.insert(&cert_sig.signer);
|
||||
if counted.len() >= threshold {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(counted.len() >= threshold)
|
||||
let valid = self.verify_all()?;
|
||||
let distinct_signers: HashSet<&Did> =
|
||||
valid.iter().filter(|d| maintainers.contains(d)).collect();
|
||||
Ok(distinct_signers.len() >= threshold)
|
||||
}
|
||||
|
||||
/// Validate the certificate structure (not signatures).
|
||||
@@ -490,151 +377,8 @@ mod tests {
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
/// An attacker can append junk signature entries to a cert (they live
|
||||
/// outside the signed body, so no key material is needed). `satisfies_threshold`
|
||||
/// must ignore them and still count the real signers — before the fix,
|
||||
/// `verify_all`'s `?` on the junk made the whole check error out, denying a
|
||||
/// valid 2-of-2 cert.
|
||||
#[test]
|
||||
fn satisfies_threshold_ignores_appended_junk_signatures() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp1.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
cert.countersign(&kp2).unwrap();
|
||||
|
||||
// Junk sits AHEAD of the real entries so the scan must actually skip
|
||||
// it: appended last, the threshold-2 early return at the second real
|
||||
// signature would fire before any junk is examined and the test would
|
||||
// stay green even if the skip logic never ran.
|
||||
cert.signatures.insert(
|
||||
0,
|
||||
CertSignature {
|
||||
signer: kp1.did(),
|
||||
sig: "!!!not-base64!!!".to_string(),
|
||||
},
|
||||
);
|
||||
// Valid base64 but only 3 bytes — fails the 64-byte length check.
|
||||
cert.signatures.insert(
|
||||
0,
|
||||
CertSignature {
|
||||
signer: kp2.did(),
|
||||
sig: "AAAA".to_string(),
|
||||
},
|
||||
);
|
||||
// Exactly ENCODED_SIG_LEN of valid base64 decoding to garbage —
|
||||
// exercises the decode+verify path before the skip, not just the
|
||||
// length gate.
|
||||
cert.signatures.insert(
|
||||
0,
|
||||
CertSignature {
|
||||
signer: kp1.did(),
|
||||
sig: "A".repeat(ENCODED_SIG_LEN),
|
||||
},
|
||||
);
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
assert!(cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
/// A junk entry that names a real maintainer DID but carries a signature
|
||||
/// that does not verify must not be counted — otherwise appending garbage
|
||||
/// under a maintainer's DID could forge a threshold.
|
||||
#[test]
|
||||
fn satisfies_threshold_does_not_count_a_forged_maintainer_signature() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp1.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
// Well-formed 64-byte signature from kp2, but over unrelated bytes, so
|
||||
// it fails verification against the cert body.
|
||||
cert.signatures.push(CertSignature {
|
||||
signer: kp2.did(),
|
||||
sig: kp2.sign_b64(b"unrelated bytes"),
|
||||
});
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
// Only kp1 actually signed the body: 2-of-2 must fail, 1-of-2 holds.
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
}
|
||||
|
||||
/// The tolerant scan must not be an unbounded verification path: a list
|
||||
/// past MAX_SIGNATURE_ENTRIES is rejected loudly before any curve work,
|
||||
/// while a list at the cap still verifies normally.
|
||||
#[test]
|
||||
fn satisfies_threshold_rejects_an_oversized_signature_list() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
let junk = || CertSignature {
|
||||
signer: Keypair::generate().did(),
|
||||
sig: "AAAA".to_string(),
|
||||
};
|
||||
while cert.signatures.len() < MAX_SIGNATURE_ENTRIES {
|
||||
cert.signatures.push(junk());
|
||||
}
|
||||
let maintainers = vec![kp.did()];
|
||||
// At the cap: still fine, the real signature counts.
|
||||
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
// One past the cap: loud rejection, no tolerant scan.
|
||||
cert.signatures.push(junk());
|
||||
let err = cert.satisfies_threshold(&maintainers, 1).unwrap_err();
|
||||
assert!(err.to_string().contains("signature entries"));
|
||||
}
|
||||
|
||||
/// An entry whose encoded signature is not the exact base64 length of a
|
||||
/// 64-byte Ed25519 signature is skipped before any base64 decode — an
|
||||
/// attacker-sized sig string must cost neither allocation nor curve work,
|
||||
/// and must not affect the verdict.
|
||||
#[test]
|
||||
fn satisfies_threshold_skips_an_oversized_encoded_signature() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp1.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
// Names a real maintainer, valid base64 alphabet, but megabytes long:
|
||||
// the length gate must refuse it without decoding.
|
||||
cert.signatures.push(CertSignature {
|
||||
signer: kp2.did(),
|
||||
sig: "A".repeat(2 * 1024 * 1024),
|
||||
});
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_rejected() {
|
||||
fn threshold_zero_is_always_satisfied() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
@@ -645,120 +389,6 @@ mod tests {
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Zero must never read as "no signatures required", even with an empty
|
||||
// maintainer set.
|
||||
let err = cert.satisfies_threshold(&[], 0).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("threshold must be at least 1"),
|
||||
"zero must be refused as an invalid threshold"
|
||||
);
|
||||
}
|
||||
|
||||
/// The threshold is checked before any signature work, so a zero is
|
||||
/// reported as a threshold error even when the certificate's signatures
|
||||
/// are also invalid. Otherwise a broken certificate would let a caller
|
||||
/// mistake the failure for a legitimate verification result.
|
||||
#[test]
|
||||
fn threshold_zero_is_rejected_before_signature_verification() {
|
||||
let kp = Keypair::generate();
|
||||
let other = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.signatures[0].sig = other.sign_b64(b"unrelated bytes");
|
||||
|
||||
let err = cert.satisfies_threshold(&[kp.did()], 0).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("threshold must be at least 1"),
|
||||
"the invalid threshold must win over the invalid signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// `seq` and `nonce` are the verifier's replay inputs, so they have to be
|
||||
/// inside the signed body; tapping either one must invalidate the
|
||||
/// certificate rather than leave it verifying with different values.
|
||||
#[test]
|
||||
fn replay_fields_are_covered_by_the_signature() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
7,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.verify_all().expect("baseline must verify");
|
||||
|
||||
let mut bumped = cert.clone();
|
||||
bumped.body.seq = 8;
|
||||
assert!(
|
||||
matches!(bumped.verify_all(), Err(Error::SignatureInvalid)),
|
||||
"changing seq must invalidate the signature"
|
||||
);
|
||||
|
||||
let mut renamed = cert.clone();
|
||||
renamed.body.nonce = "different-nonce".to_string();
|
||||
assert!(
|
||||
matches!(renamed.verify_all(), Err(Error::SignatureInvalid)),
|
||||
"changing nonce must invalidate the signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signature minted by a real key must not verify when the signer is
|
||||
/// spelled in another multibase alphabet. Otherwise the same identity
|
||||
/// would satisfy a maintainer allowlist under several DID strings.
|
||||
///
|
||||
/// Base64url is deliberate: its 47-character method-id fits under the
|
||||
/// pre-existing length bound, so this test reaches the canonical-prefix
|
||||
/// rule and fails on the pre-fix code instead of being refused for length.
|
||||
#[test]
|
||||
fn verify_all_rejects_a_non_canonical_did_spelling() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut prefixed = vec![0xed, 0x01];
|
||||
prefixed.extend_from_slice(&kp.verifying_key().to_bytes());
|
||||
cert.signatures[0].signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base64Url, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = cert
|
||||
.verify_all()
|
||||
.expect_err("a non-canonical did:key spelling must not resolve");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"rejection must come from the canonical-prefix rule, not the length guard"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
cert.satisfies_threshold(&[cert.signatures[0].signer.clone()], 1),
|
||||
Ok(true)
|
||||
),
|
||||
"the alternate spelling must not satisfy a threshold"
|
||||
);
|
||||
|
||||
// The canonical spelling of the same key is unaffected.
|
||||
cert.signatures[0].signer = kp.did();
|
||||
assert!(cert.satisfies_threshold(&[kp.did()], 1).unwrap());
|
||||
assert!(cert.satisfies_threshold(&[], 0).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -20,19 +20,6 @@ use crate::{Error, Result};
|
||||
/// Value: 0xed01 encoded as a varint.
|
||||
const ED25519_MULTICODEC: &[u8] = &[0xed, 0x01];
|
||||
|
||||
/// The only multibase prefix a `did:key` method-specific id may carry.
|
||||
///
|
||||
/// The DID:key spec encodes Ed25519 keys as base58btc, which is multibase
|
||||
/// `z`. `multibase::decode` happily accepts every other alphabet, and each
|
||||
/// spelling of the same key bytes would produce a distinct DID string while
|
||||
/// gates, allowlists, and database rows compare DIDs as strings — one
|
||||
/// identity would read as several. Rejecting the alternatives here keeps the
|
||||
/// mapping from `did:key` to key single-valued for every caller of
|
||||
/// [`Did::to_verifying_key`] (HTTP signatures, UCAN, ref certs, peer
|
||||
/// announces). `gitlawb-attest` enforces the same rule for attestation
|
||||
/// signers.
|
||||
const DID_KEY_MULTIBASE_PREFIX: char = 'z';
|
||||
|
||||
/// A Decentralized Identifier.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -84,14 +71,8 @@ impl Did {
|
||||
|
||||
/// Resolve the Ed25519 verifying key from a `did:key`.
|
||||
///
|
||||
/// Only the canonical base58btc (`z`) spelling is accepted. Other
|
||||
/// multibase encodings decode to the same key but spell a different DID
|
||||
/// string, so accepting them would let one identity appear under several
|
||||
/// identifiers to string-comparing gates and allowlists.
|
||||
///
|
||||
/// Returns `Err` if the DID is not a `did:key`, uses a non-canonical
|
||||
/// multibase prefix, the key bytes are invalid, or the key is a
|
||||
/// small-order (weak) point.
|
||||
/// Returns `Err` if the DID is not a `did:key`, the key bytes are malformed,
|
||||
/// or the key is a small-order (weak) point.
|
||||
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
|
||||
if !self.is_did_key() {
|
||||
return Err(Error::InvalidDid(format!(
|
||||
@@ -113,27 +94,9 @@ impl Did {
|
||||
));
|
||||
}
|
||||
|
||||
// Cheap prefix gate before the decode, then the base check on what
|
||||
// multibase actually parsed. The first keeps a clearly non-canonical id
|
||||
// off the decoder. The second is belt-and-braces — multibase maps a
|
||||
// leading `z` only to `Base58Btc` — but it states the requirement
|
||||
// directly instead of relying on that mapping, and `gitlawb-attest`
|
||||
// makes the same pair of checks.
|
||||
if !self.method_id().starts_with(DID_KEY_MULTIBASE_PREFIX) {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key must use base58btc (z-prefix)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (base, bytes) =
|
||||
let (_, bytes) =
|
||||
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
if base != Base::Base58Btc {
|
||||
return Err(Error::InvalidDid(
|
||||
"did:key must use base58btc (z-prefix)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !bytes.starts_with(ED25519_MULTICODEC) {
|
||||
return Err(Error::InvalidDid(
|
||||
"not an ed25519 multicodec key".to_string(),
|
||||
@@ -426,124 +389,6 @@ mod tests {
|
||||
did.to_verifying_key().expect("a real did:key must resolve");
|
||||
}
|
||||
|
||||
/// `multibase::decode` accepts every alphabet, so the same Ed25519 key can
|
||||
/// be spelled as several `did:key` strings. Only base58btc is canonical;
|
||||
/// the rest must be refused so one identity cannot appear as several to
|
||||
/// gates and allowlists that compare DID strings.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_non_base58btc_multibase() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[9u8; 32]);
|
||||
let mut prefixed = ED25519_MULTICODEC.to_vec();
|
||||
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let canonical = Did::from_verifying_key(&signing.verifying_key());
|
||||
assert_eq!(
|
||||
canonical.to_verifying_key().unwrap().to_bytes(),
|
||||
signing.verifying_key().to_bytes(),
|
||||
"base58btc is the accepted spelling"
|
||||
);
|
||||
|
||||
// These spellings fit inside the method-id bound, so they reach the
|
||||
// canonical-prefix check and must be refused there.
|
||||
for base in [Base::Base64Url, Base::Base32Lower] {
|
||||
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
|
||||
let did: Did = alternative.parse().expect("method is still `key`");
|
||||
assert_ne!(
|
||||
did.as_str(),
|
||||
canonical.as_str(),
|
||||
"the same key must not compare equal under another spelling"
|
||||
);
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("non-canonical multibase must be refused");
|
||||
assert!(
|
||||
matches!(err, Error::InvalidDid(_)),
|
||||
"must be an InvalidDid, got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// A base16 spelling of the same key is longer than the method-id
|
||||
// bound, so it is refused by the length guard before the prefix check.
|
||||
// Either way it must not resolve.
|
||||
for base in [Base::Base16Lower, Base::Base16Upper] {
|
||||
let alternative = format!("did:key:{}", multibase::encode(base, &prefixed));
|
||||
let did: Did = alternative.parse().expect("method is still `key`");
|
||||
assert!(
|
||||
matches!(did.to_verifying_key(), Err(Error::InvalidDid(_))),
|
||||
"a base16 spelling of a real key must not resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// base58flickr (`Z`) is the non-bitcoin sibling alphabet. Its spelling of
|
||||
/// a real key fits under the length bound, so it must be refused for the
|
||||
/// prefix, not merely for its size.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_a_base58flickr_spelling() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[11u8; 32]);
|
||||
let mut prefixed = ED25519_MULTICODEC.to_vec();
|
||||
prefixed.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let did: Did = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(Base::Base58Flickr, &prefixed)
|
||||
)
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("base58flickr must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty method-specific id must be refused by the prefix rule without
|
||||
/// panicking on a missing first character.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_an_empty_method_id() {
|
||||
let did: Did = "did:key:".parse().expect("method is still `key`");
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("an empty method id must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("base58btc"),
|
||||
"the error must name the canonical encoding: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A canonical base58btc spelling is not enough on its own: the bytes must
|
||||
/// also carry the ed25519-pub multicodec. This pins the check that stops a
|
||||
/// key of another type from resolving as an Ed25519 key.
|
||||
#[test]
|
||||
fn to_verifying_key_rejects_base58btc_with_a_wrong_multicodec() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let signing = SigningKey::from_bytes(&[12u8; 32]);
|
||||
let mut buf = vec![0x12, 0x00]; // sha2-256, not ed25519-pub
|
||||
buf.extend_from_slice(&signing.verifying_key().to_bytes());
|
||||
|
||||
let did: Did = format!("did:key:{}", multibase::encode(Base::Base58Btc, &buf))
|
||||
.parse()
|
||||
.expect("method is still `key`");
|
||||
|
||||
let err = did
|
||||
.to_verifying_key()
|
||||
.expect_err("a non-ed25519 multicodec must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("ed25519"),
|
||||
"the error must name the expected multicodec: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `did:key` can encode a small-order (weak) Ed25519 point. Such a key
|
||||
/// satisfies the verification equation for any message and, converted to
|
||||
/// Montgomery form for X25519, yields the all-zero shared secret, so an
|
||||
|
||||
@@ -566,26 +566,6 @@ mod tests {
|
||||
assert!(sig.check_created().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_created_timestamps_reject_without_wrapping() {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
let parse = |created: i64| {
|
||||
let sig_input =
|
||||
format!(r#"sig1=("@method");keyid="{did}";alg="ed25519";created={created}"#);
|
||||
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
|
||||
};
|
||||
// i64::MIN used to panic in debug (subtraction overflow) and wrap to a
|
||||
// passing skew in release; now - 2^63 wrapped to i64::MIN, whose abs()
|
||||
// wrapped back to itself and read as fresh. Both must reject.
|
||||
for created in [i64::MIN, i64::MAX, Utc::now().timestamp() - i64::MAX] {
|
||||
assert!(
|
||||
parse(created).check_created().is_err(),
|
||||
"created={created} must not pass the freshness gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_missing_sig1_prefix() {
|
||||
let err = HttpSignature::parse(
|
||||
|
||||
@@ -77,14 +77,6 @@ impl Keypair {
|
||||
}
|
||||
|
||||
/// Verify an Ed25519 signature.
|
||||
///
|
||||
/// Strict verification: rejects small-order `R` and small-order public keys
|
||||
/// (the identity point, and any point of low order). Ordinary `verify` accepts
|
||||
/// a signature forged with the identity point as the public key plus
|
||||
/// `R = identity, S = 0`, which verifies for *any* message. `identity::verify`
|
||||
/// is the shared primitive behind HTTP request authentication, UCANs, and
|
||||
/// certificates, so weak-key acceptance is an authentication bypass, not a
|
||||
/// malleability nuance.
|
||||
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
|
||||
let sig = Signature::from_bytes(sig_bytes);
|
||||
verifying_key
|
||||
@@ -216,38 +208,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The identity-point forgery: with public key A = identity, R = identity,
|
||||
/// and S = 0, the equation `[S]B = R + [k]A` holds for every message,
|
||||
/// because `[k]·identity = identity`. Ordinary (non-strict) Ed25519
|
||||
/// verification accepts it, so the shared `verify` primitive must use
|
||||
/// strict verification, which rejects small-order R and public keys.
|
||||
#[test]
|
||||
fn verify_rejects_identity_point_forgery() {
|
||||
use ed25519_dalek::Verifier;
|
||||
// The identity point (0,1) compresses to y = 1 with sign bit 0.
|
||||
let identity = [
|
||||
1u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
];
|
||||
let vk = VerifyingKey::from_bytes(&identity).expect("identity point is on the curve");
|
||||
let mut sig_bytes = [0u8; 64];
|
||||
sig_bytes[..32].copy_from_slice(&identity);
|
||||
let msg = b"arbitrary message the key owner never signed";
|
||||
|
||||
// Prove the forged signature satisfies the ordinary verification
|
||||
// equation, so the strict check below is what actually defends the
|
||||
// boundary (not a signature that was already invalid everywhere).
|
||||
assert!(
|
||||
vk.verify(msg, &Signature::from_bytes(&sig_bytes)).is_ok(),
|
||||
"identity-point forgery must satisfy ordinary verification (this is why strict is needed)"
|
||||
);
|
||||
|
||||
assert!(
|
||||
verify(&vk, msg, &sig_bytes).is_err(),
|
||||
"strict verification must reject the identity-point forgery"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_weak_key_signature() {
|
||||
// Regression guard for strict verification: a signature forged under a
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Where the identity key and the delegation store live.
|
||||
//!
|
||||
//! `gl` and `git-remote-gitlawb` have to agree on this. `gl ucan import` writes a
|
||||
//! delegation to `<dir>/delegations/`, and the helper reads it back from the same
|
||||
//! place when it builds the `X-Ucan` header on push. If the two resolve
|
||||
//! `GITLAWB_KEY` differently the push goes out with no header and the node refuses
|
||||
//! the delegate, with nothing on either side to say why — so the rules live here
|
||||
//! once, in the crate both binaries already depend on, rather than being written
|
||||
//! twice and drifting.
|
||||
//!
|
||||
//! The home directory is a parameter rather than something this module looks up.
|
||||
//! `gitlawb-core` is embedded by every consumer and is held to an explicit
|
||||
//! dependency allowlist (`ci/gitlawb-core-allowed-deps.txt`); the callers already
|
||||
//! carry `dirs`, so taking `home` keeps the rules shared without widening core's
|
||||
//! tree. It also makes every case below testable against a fixed home.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Environment variable naming the identity PEM.
|
||||
pub const KEY_ENV: &str = "GITLAWB_KEY";
|
||||
|
||||
/// Name of the PEM file inside the identity directory.
|
||||
pub const KEY_FILE_NAME: &str = "identity.pem";
|
||||
|
||||
/// Directory under the home directory used when `GITLAWB_KEY` is unset.
|
||||
pub const DEFAULT_DIR_NAME: &str = ".gitlawb";
|
||||
|
||||
/// Absolute path of the identity PEM: `$GITLAWB_KEY`, else
|
||||
/// `<home>/.gitlawb/identity.pem`.
|
||||
///
|
||||
/// An empty `GITLAWB_KEY` counts as unset. That is what a shell leaves behind for
|
||||
/// `FOO=` and for an unset variable expanded into a wrapper script, and reading it
|
||||
/// as a path would resolve against the process working directory instead.
|
||||
pub fn identity_key_path(home: Option<&Path>) -> Result<PathBuf> {
|
||||
// `var_os`, not `var`: `var` folds a non-UTF-8 value into the same `Err` as
|
||||
// unset, so an operator whose key path is not valid UTF-8 would silently get
|
||||
// the default directory rather than theirs — or an error naming the real
|
||||
// problem.
|
||||
match std::env::var_os(KEY_ENV) {
|
||||
Some(raw) if !raw.is_empty() => resolve_key_value(Path::new(&raw), home),
|
||||
_ => Ok(require_home(home)?
|
||||
.join(DEFAULT_DIR_NAME)
|
||||
.join(KEY_FILE_NAME)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory holding `identity.pem` and `delegations/` — the parent of
|
||||
/// [`identity_key_path`].
|
||||
pub fn identity_dir(home: Option<&Path>) -> Result<PathBuf> {
|
||||
let key = identity_key_path(home)?;
|
||||
key.parent().map(Path::to_path_buf).ok_or_else(|| {
|
||||
Error::Key(format!(
|
||||
"{KEY_ENV} has no parent directory: {}",
|
||||
key.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The home directory, demanded only where the value being resolved needs it.
|
||||
///
|
||||
/// An absolute `GITLAWB_KEY` never needs home, so requiring it up front would
|
||||
/// discard a perfectly good key on a host where `dirs::home_dir()` returns `None`
|
||||
/// — no `HOME` and no passwd entry, which is an ordinary container shape. The
|
||||
/// helper would then push unsigned, blaming the home directory for a setting the
|
||||
/// operator had configured correctly.
|
||||
fn require_home(home: Option<&Path>) -> Result<&Path> {
|
||||
home.ok_or_else(|| {
|
||||
Error::Key(format!(
|
||||
"could not determine the home directory, which is needed to resolve this \
|
||||
{KEY_ENV} value. Set {KEY_ENV} to an absolute path to avoid needing it."
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the `GITLAWB_KEY` rules to a raw value.
|
||||
///
|
||||
/// Split out from [`identity_key_path`] so the rules can be tested without setting
|
||||
/// a process-global environment variable, which would make the tests race.
|
||||
fn resolve_key_value(raw: &Path, home: Option<&Path>) -> Result<PathBuf> {
|
||||
let path = expand_tilde(raw, home)?;
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must be an absolute path (got {}). It also determines where \
|
||||
delegations are stored, and `gl` and `git-remote-gitlawb` do not share a \
|
||||
working directory, so a relative path sends them to different stores.",
|
||||
raw.display()
|
||||
)));
|
||||
}
|
||||
if path.parent().is_none() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must name the key file, not the filesystem root (got {}). \
|
||||
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
|
||||
raw.display()
|
||||
)));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Expand a leading `~/`, and only that.
|
||||
///
|
||||
/// `~user` is shell syntax this does not implement; leaving its `~` in place makes
|
||||
/// it fail the absolute-path check with a message that names the real problem,
|
||||
/// which beats resolving it somewhere the operator did not ask for. A bare `~` or
|
||||
/// `~/` is refused outright: it names a directory where a file is required, and
|
||||
/// expanding it to the home directory would put the delegation store beside the
|
||||
/// home directory rather than inside it, since the store is the key's *parent*.
|
||||
///
|
||||
/// Matched on the first path component rather than on a string prefix. That is
|
||||
/// what lets the value stay an `OsStr` end to end: the helper's old
|
||||
/// `str::strip_prefix("~/")` needed a `String` first, which is why it reached for
|
||||
/// `env::var` and folded every non-UTF-8 path into "unset".
|
||||
fn expand_tilde(path: &Path, home: Option<&Path>) -> Result<PathBuf> {
|
||||
let mut components = path.components();
|
||||
match components.next() {
|
||||
Some(Component::Normal(first)) if first == OsStr::new("~") => {
|
||||
let rest = components.as_path();
|
||||
if rest.as_os_str().is_empty() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must name the key file, not a directory (got {}). \
|
||||
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(require_home(home)?.join(rest))
|
||||
}
|
||||
_ => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A home that is absolute on the host running the tests. `/home/op` is not
|
||||
/// absolute on Windows — it has a root but no prefix — so a shared literal
|
||||
/// would make the absolute-path assertions test the wrong thing there.
|
||||
fn home() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\Users\op")
|
||||
} else {
|
||||
PathBuf::from("/home/op")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_value_is_taken_verbatim() {
|
||||
let raw = home().join("data").join("keys").join(KEY_FILE_NAME);
|
||||
assert_eq!(resolve_key_value(&raw, Some(&home())).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_slash_expands_to_the_home_directory() {
|
||||
let resolved = resolve_key_value(Path::new("~/keys/identity.pem"), Some(&home())).unwrap();
|
||||
assert_eq!(resolved, home().join("keys").join(KEY_FILE_NAME));
|
||||
}
|
||||
|
||||
/// The shell-style spelling of the default resolves to the default. Worth
|
||||
/// pinning: this is the form an operator gets by copying a path out of their
|
||||
/// shell, and the two binaries used to reach it by different routes.
|
||||
#[test]
|
||||
fn the_tilde_spelling_of_the_default_resolves_to_the_default() {
|
||||
let resolved =
|
||||
resolve_key_value(Path::new("~/.gitlawb/identity.pem"), Some(&home())).unwrap();
|
||||
assert_eq!(resolved, home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME));
|
||||
}
|
||||
|
||||
/// A relative value resolves against the working directory, and `gl` and
|
||||
/// `git-remote-gitlawb` do not share one: the import would land where the helper
|
||||
/// never looks.
|
||||
#[test]
|
||||
fn relative_values_are_refused() {
|
||||
for raw in ["identity.pem", "keys/identity.pem", "./keys/identity.pem"] {
|
||||
assert!(
|
||||
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
|
||||
"{raw} is relative and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `~user` is shell syntax, not a path, and a bare `~` names a directory where
|
||||
/// a file is required. Refused rather than guessed at.
|
||||
#[test]
|
||||
fn unsupported_tilde_forms_are_refused() {
|
||||
for raw in ["~", "~/", "~someone/keys/identity.pem"] {
|
||||
assert!(
|
||||
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
|
||||
"{raw} must be refused rather than resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The root has no parent, so the delegation store would have nowhere to go.
|
||||
#[test]
|
||||
fn the_filesystem_root_is_refused() {
|
||||
assert!(resolve_key_value(Path::new("/"), Some(&home())).is_err());
|
||||
}
|
||||
|
||||
/// The whole point of `var_os`: a non-UTF-8 value must reach the rules rather
|
||||
/// than being folded into "unset" by `var`. Byte 0xFF is not valid UTF-8 in any
|
||||
/// position, so this value is unreachable through `env::var`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_values_reach_the_rules() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let relative = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
|
||||
assert!(
|
||||
resolve_key_value(Path::new(&relative), Some(&home())).is_err(),
|
||||
"a non-UTF-8 relative path must be refused, not silently defaulted"
|
||||
);
|
||||
|
||||
let mut absolute = OsString::from("/data/");
|
||||
absolute.push(OsString::from_vec(vec![0xFF]));
|
||||
absolute.push("/identity.pem");
|
||||
let resolved = resolve_key_value(Path::new(&absolute), Some(&home())).unwrap();
|
||||
assert_eq!(resolved.as_os_str(), absolute.as_os_str());
|
||||
}
|
||||
|
||||
/// Unset and empty both mean "use the default", and the two accessors must stay
|
||||
/// consistent: the directory is the parent of the key, never a sibling of it.
|
||||
/// The process environment is global, so the two cases share one test and one
|
||||
/// lock rather than racing each other.
|
||||
#[test]
|
||||
fn unset_and_empty_both_select_the_default_directory() {
|
||||
use std::sync::Mutex;
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let restore = std::env::var_os(KEY_ENV);
|
||||
|
||||
std::env::remove_var(KEY_ENV);
|
||||
let unset = (
|
||||
identity_key_path(Some(&home())),
|
||||
identity_dir(Some(&home())),
|
||||
);
|
||||
std::env::set_var(KEY_ENV, "");
|
||||
let empty = (
|
||||
identity_key_path(Some(&home())),
|
||||
identity_dir(Some(&home())),
|
||||
);
|
||||
|
||||
match restore {
|
||||
Some(v) => std::env::set_var(KEY_ENV, v),
|
||||
None => std::env::remove_var(KEY_ENV),
|
||||
}
|
||||
|
||||
for (label, (key, dir)) in [("unset", unset), ("empty", empty)] {
|
||||
assert_eq!(
|
||||
key.unwrap(),
|
||||
home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME),
|
||||
"{label} key path"
|
||||
);
|
||||
assert_eq!(
|
||||
dir.unwrap(),
|
||||
home().join(DEFAULT_DIR_NAME),
|
||||
"{label} directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod no_home_tests {
|
||||
use super::*;
|
||||
|
||||
/// An absolute key needs no home directory. Demanding one up front discarded a
|
||||
/// correctly-configured `GITLAWB_KEY` on any host where `dirs::home_dir()`
|
||||
/// returns `None` — no `HOME` and no passwd entry, an ordinary container shape —
|
||||
/// and the helper then pushed unsigned while blaming the home directory.
|
||||
#[test]
|
||||
fn an_absolute_key_resolves_without_a_home_directory() {
|
||||
let raw = if cfg!(windows) {
|
||||
r"C:\data\keys\identity.pem"
|
||||
} else {
|
||||
"/data/keys/identity.pem"
|
||||
};
|
||||
let resolved = resolve_key_value(Path::new(raw), None)
|
||||
.expect("an absolute key must not need a home directory");
|
||||
assert_eq!(resolved, PathBuf::from(raw));
|
||||
assert_eq!(resolved.parent().unwrap(), Path::new(raw).parent().unwrap());
|
||||
}
|
||||
|
||||
/// The forms that genuinely need a home still say so, rather than resolving
|
||||
/// somewhere arbitrary.
|
||||
#[test]
|
||||
fn the_forms_that_need_a_home_report_its_absence() {
|
||||
let err = resolve_key_value(Path::new("~/keys/identity.pem"), None)
|
||||
.expect_err("a ~/ path cannot resolve without a home directory");
|
||||
assert!(
|
||||
err.to_string().contains("home directory"),
|
||||
"the error must name the missing home directory, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
pub mod identity_path;
|
||||
// `url` is the one dependency here that drags a tail (idna, then the icu
|
||||
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
|
||||
// needs this predicate already parses URLs, so they opt in and nothing else
|
||||
|
||||
@@ -30,30 +30,10 @@ pub fn is_bidi_format(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Both halves of INV-6 in one call: drop every `Cc` control (which defangs
|
||||
/// ANSI and OSC escapes) and every bidi/format control that
|
||||
/// [`is_bidi_format`] names. What remains is safe to write to a terminal.
|
||||
///
|
||||
/// Length is not this function's business: a caller that shows the text
|
||||
/// decides how much of it to show.
|
||||
pub fn strip_terminal_controls(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !is_bidi_format(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_removes_escapes_and_reordering_but_keeps_text() {
|
||||
let hostile = "ok\u{1b}[31m red\u{1b}]0;title\u{7}\u{202E}dlrow\u{202C} \u{0627}\u{200D}";
|
||||
let clean = strip_terminal_controls(hostile);
|
||||
assert_eq!(clean, "ok[31m red]0;titledlrow \u{0627}\u{200D}");
|
||||
assert!(!clean.chars().any(|c| c.is_control() || is_bidi_format(c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_every_reordering_code_point() {
|
||||
// Asserted individually so a partial predicate (e.g. one that covers the
|
||||
|
||||
+25
-737
@@ -16,19 +16,6 @@ use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Proof chains deeper than this fail closed, in every walk over `prf`:
|
||||
/// [`Ucan::verify_chain`], [`Ucan::chain_lifetime_is_bounded`] and
|
||||
/// [`Ucan::chain_grants_push_to`] all stop here. Nothing this codebase mints is
|
||||
/// longer than owner → agent → node. The bound exists for the hand-built token:
|
||||
/// the node verifies the chain on the `X-Ucan` header of any signed request, and
|
||||
/// nothing else says how deep a sender may nest `prf`. In practice not very
|
||||
/// deep — a proof is a JSON string inside the next link's JSON, so each level
|
||||
/// re-escapes the one beneath it and the encoding roughly doubles per link
|
||||
/// (eight links is about 11 KiB, thirteen about 270 KiB) — but a walk should
|
||||
/// refuse on its own terms rather than count on the encoding running the sender
|
||||
/// out of room first.
|
||||
pub const MAX_CHAIN_DEPTH: usize = 8;
|
||||
|
||||
/// A UCAN capability: what resource the token grants access to.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Capability {
|
||||
@@ -62,32 +49,11 @@ impl Capability {
|
||||
/// action field and `repo/admin` in the parent's action position act as
|
||||
/// wildcards that cover any delegated value; wildcards on `self` carry no
|
||||
/// special meaning.
|
||||
///
|
||||
/// Constraints (`nb`) participate, and conservatively:
|
||||
///
|
||||
/// | parent | child | verdict |
|
||||
/// |---|---|---|
|
||||
/// | none | anything | attenuated — adding constraints narrows |
|
||||
/// | some | identical | attenuated |
|
||||
/// | some | different | refused — narrowing is unprovable without semantics |
|
||||
/// | some | none | refused — dropping constraints widens |
|
||||
///
|
||||
/// The last row is the one that matters. Ignoring `nb` here let a holder of a
|
||||
/// constrained capability re-delegate the same resource and action with the
|
||||
/// constraints removed, and the chain still verified — so a consumer that
|
||||
/// refuses constrained capabilities at the leaf saw an unconstrained one and
|
||||
/// granted it. Since `nb` has no interpreted semantics yet, "different" cannot
|
||||
/// be shown to be narrower and is refused with it.
|
||||
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
|
||||
let resource_ok = parent.with == self.with || parent.with == "*";
|
||||
let action_ok =
|
||||
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
|
||||
let constraints_ok = match (&parent.constraints, &self.constraints) {
|
||||
(None, _) => true,
|
||||
(Some(p), Some(c)) => p == c,
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
resource_ok && action_ok && constraints_ok
|
||||
resource_ok && action_ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,138 +71,6 @@ pub mod caps {
|
||||
pub const REPO_ADMIN: &str = "repo/admin";
|
||||
}
|
||||
|
||||
/// Rules shared by every boundary that decides whether a UCAN can push.
|
||||
///
|
||||
/// Four places answer "can this token push to this repository?": `gl ucan
|
||||
/// delegate` and the MCP `ucan_delegate` tool when a token is issued, `gl ucan
|
||||
/// import` when one is stored, `git-remote-gitlawb` when it mints an invocation,
|
||||
/// and the node when it authorizes the push. For three review rounds each of them
|
||||
/// carried its own copy of the answer, and every round found a token that one
|
||||
/// boundary accepted and the next refused: a constrained grant that imported and
|
||||
/// then authorized nothing; a wildcard proof the helper narrowed and the node
|
||||
/// rejected; a resource whose owner the leaf named but the chain root did not.
|
||||
///
|
||||
/// The rules live here, once, in the crate all four already depend on. A boundary
|
||||
/// may add checks of its own — the node anchors the chain root to a repository
|
||||
/// record it holds independently, import binds the audience to the local key —
|
||||
/// but the definition of a *usable push capability* is not one of them.
|
||||
pub mod push {
|
||||
use super::{caps, Capability, Ucan};
|
||||
|
||||
/// The `did:key` representation rule: `did:key:z6Mk…` and bare `z6Mk…` are
|
||||
/// the same identity. Mirror rows store the bare form; canonical rows and
|
||||
/// every token store the full form.
|
||||
///
|
||||
/// Collapses representation only within `did:key`. `did:web` and
|
||||
/// `did:gitlawb` share the base58 space, so a trailing-segment compare would
|
||||
/// treat `did:key:X` and `did:gitlawb:X` as equal; after stripping the prefix,
|
||||
/// a value that still contains `:` is a non-key DID and matches nothing bare.
|
||||
pub fn did_key_eq(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));
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// The actions that authorize a push: `git/push` itself, the action wildcard,
|
||||
/// and `repo/admin`, which covers it.
|
||||
pub fn is_push_action(can: &str) -> bool {
|
||||
can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN
|
||||
}
|
||||
|
||||
/// `gitlawb://repos/<owner>/<repo>` → `(owner, repo)`, or `None` for any other
|
||||
/// shape. Exactly two non-empty segments: an owner DID never contains `/`, so
|
||||
/// a third segment, a trailing slash, or an empty half is malformed rather than
|
||||
/// something to interpret.
|
||||
pub fn parse_repo_resource(with: &str) -> Option<(&str, &str)> {
|
||||
let rest = with.strip_prefix("gitlawb://repos/")?;
|
||||
let (owner, repo) = rest.split_once('/')?;
|
||||
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some((owner, repo))
|
||||
}
|
||||
|
||||
/// A push-class capability whose resource is the wildcard. Refused at
|
||||
/// issuance, at import, by the helper, and by the node: a delegation's scope is
|
||||
/// fixed when it is issued, and `*` cannot say which repositories it covered
|
||||
/// at that moment.
|
||||
pub fn is_push_wildcard(with: &str, can: &str) -> bool {
|
||||
with == "*" && is_push_action(can)
|
||||
}
|
||||
|
||||
pub use super::MAX_CHAIN_DEPTH;
|
||||
|
||||
impl Capability {
|
||||
/// Push-class, unconstrained, and naming exactly this repository.
|
||||
///
|
||||
/// Unconstrained because `nb` has no semantics yet: an owner who wrote
|
||||
/// constraints meant to restrict, and honouring the capability while
|
||||
/// ignoring them would grant more than was intended. The owner segment is
|
||||
/// compared with [`did_key_eq`], so a token issued against the full DID
|
||||
/// matches a mirror row keyed on the bare form.
|
||||
pub fn grants_push_to(&self, owner: &str, repo: &str) -> bool {
|
||||
self.constraints.is_none()
|
||||
&& is_push_action(&self.can)
|
||||
&& parse_repo_resource(&self.with)
|
||||
.is_some_and(|(o, r)| did_key_eq(o, owner) && r == repo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ucan {
|
||||
/// Whether this chain — the leaf **and every proof behind it** — carries a
|
||||
/// capability that [`Capability::grants_push_to`] this repository.
|
||||
///
|
||||
/// Every link, not just the leaf. [`Capability::is_attenuated_by`] accepts
|
||||
/// a concrete child under a `*` parent, so a leaf that names the repository
|
||||
/// can sit on a proof that names every repository the root owns — including
|
||||
/// ones created after the delegation was issued. Checking the leaf alone
|
||||
/// let exactly that through.
|
||||
///
|
||||
/// This establishes scope, not trust. It does not verify signatures,
|
||||
/// expiry, or audience; call [`Ucan::verify_chain`] first, and anchor the
|
||||
/// root it returns to something held independently of the token.
|
||||
///
|
||||
/// Fails closed on anything it cannot vouch for: a proof that does not
|
||||
/// decode, more than one proof per link, or a chain deeper than
|
||||
/// [`MAX_CHAIN_DEPTH`].
|
||||
pub fn chain_grants_push_to(&self, owner: &str, repo: &str) -> bool {
|
||||
self.chain_grants_push_to_at(owner, repo, 0)
|
||||
}
|
||||
|
||||
fn chain_grants_push_to_at(&self, owner: &str, repo: &str, depth: usize) -> bool {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.any(|c| c.grants_push_to(owner, repo))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// `verify_chain` refuses more than one proof per link for the same
|
||||
// reason: two proofs mean two roots, and nothing says which authorized
|
||||
// what.
|
||||
if self.payload.prf.len() > 1 {
|
||||
return false;
|
||||
}
|
||||
match self.payload.prf.first() {
|
||||
None => true,
|
||||
Some(token) => match Ucan::decode(token) {
|
||||
Ok(proof) => proof.chain_grants_push_to_at(owner, repo, depth + 1),
|
||||
Err(_) => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The UCAN payload (what gets signed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UcanPayload {
|
||||
@@ -312,37 +146,6 @@ impl Ucan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every link in this chain carries a finite `exp`.
|
||||
///
|
||||
/// `exp` is optional in the format, and [`Self::is_expired`] reports `false`
|
||||
/// when it is absent — so a link without one never expires. With no revocation
|
||||
/// mechanism, a chain containing such a link is a permanent grant: a leaked
|
||||
/// token cannot be withdrawn, and the issuer's only remedy is to rotate the
|
||||
/// identity the resource is keyed on.
|
||||
///
|
||||
/// Consumers that turn a UCAN into write authority should require this. It is
|
||||
/// deliberately not enforced inside [`Self::verify_chain`], because a
|
||||
/// non-expiring token is well-formed and may be perfectly appropriate for a
|
||||
/// read-only or advisory capability; whether an unbounded grant is acceptable
|
||||
/// is the consumer's policy, not the format's.
|
||||
///
|
||||
/// Fails closed past [`MAX_CHAIN_DEPTH`], like every other walk over `prf`.
|
||||
pub fn chain_lifetime_is_bounded(&self) -> bool {
|
||||
self.chain_lifetime_is_bounded_at(0)
|
||||
}
|
||||
|
||||
fn chain_lifetime_is_bounded_at(&self, depth: usize) -> bool {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if self.payload.exp.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.payload.prf.iter().all(|token| {
|
||||
Self::decode(token).is_ok_and(|proof| proof.chain_lifetime_is_bounded_at(depth + 1))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if this UCAN's not-before time is in the future (token not yet valid).
|
||||
pub fn is_before_valid(&self) -> bool {
|
||||
if let Some(nbf) = self.payload.nbf {
|
||||
@@ -445,33 +248,8 @@ impl Ucan {
|
||||
/// 3. Check the proof is not expired
|
||||
/// 4. Recursively verify the proof's own chain
|
||||
///
|
||||
/// A UCAN with no proofs is its own root, so it returns its own issuer.
|
||||
///
|
||||
/// **This establishes internal consistency, not trust.** `did:key` is
|
||||
/// self-certifying, so anyone can mint a keypair and produce a chain that
|
||||
/// verifies. A caller making an authorization decision MUST compare the
|
||||
/// returned root against an identity it trusts for some reason outside this
|
||||
/// token — a repo owner, a configured value, a registry lookup. Discarding
|
||||
/// the return value is only correct when the caller is checking that a token
|
||||
/// is well-formed and deliberately does not care who issued it.
|
||||
///
|
||||
/// The walk stops at [`MAX_CHAIN_DEPTH`] and fails closed there. The node
|
||||
/// runs this on the `X-Ucan` header of any signed request before it asks
|
||||
/// what the chain grants, so this is the first place sender-chosen depth is
|
||||
/// felt: each link costs a signature check and a decode of everything
|
||||
/// beneath it. The scope walk was bounded and this one was not, which made
|
||||
/// that bound decorative — a deep chain met the unbounded walk first.
|
||||
pub fn verify_chain(&self) -> Result<Did> {
|
||||
self.verify_chain_at(0)
|
||||
}
|
||||
|
||||
fn verify_chain_at(&self, depth: usize) -> Result<Did> {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain deeper than {MAX_CHAIN_DEPTH} links is not accepted"
|
||||
)));
|
||||
}
|
||||
|
||||
/// A UCAN with no proofs (root capability) passes trivially.
|
||||
pub fn verify_chain(&self) -> Result<()> {
|
||||
// First verify our own signature
|
||||
self.verify_signature()?;
|
||||
|
||||
@@ -483,44 +261,34 @@ impl Ucan {
|
||||
return Err(Error::Ucan("token is not yet valid".to_string()));
|
||||
}
|
||||
|
||||
if self.payload.prf.len() > 1 {
|
||||
return Err(Error::Ucan(
|
||||
"multi-proof chains are not supported: more than one proof means \
|
||||
more than one root, and which root authorized a given capability \
|
||||
is ambiguous"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
for proof_token in &self.payload.prf {
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
|
||||
let Some(proof_token) = self.payload.prf.first() else {
|
||||
// No proofs: this token is its own root.
|
||||
return Ok(self.payload.iss.clone());
|
||||
};
|
||||
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain broken: proof audience {} does not match issuer {}",
|
||||
proof.payload.aud, self.payload.iss
|
||||
)));
|
||||
}
|
||||
|
||||
// Every delegated capability must be covered by the proof (attenuation).
|
||||
for cap in &self.payload.att {
|
||||
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
|
||||
if !covered {
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
"proof chain broken: proof audience {} does not match issuer {}",
|
||||
proof.payload.aud, self.payload.iss
|
||||
)));
|
||||
}
|
||||
|
||||
// Every delegated capability must be covered by the proof (attenuation).
|
||||
for cap in &self.payload.att {
|
||||
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
|
||||
if !covered {
|
||||
return Err(Error::Ucan(format!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the proof's signature and chain recursively
|
||||
proof.verify_chain()?;
|
||||
}
|
||||
|
||||
// Recurse; the root of the proof's chain is the root of ours.
|
||||
proof.verify_chain_at(depth + 1)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,484 +664,4 @@ mod tests {
|
||||
|
||||
delegated.verify_chain().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_returns_the_root_issuer_of_a_delegated_chain() {
|
||||
// owner -> agent (delegation), agent -> node (invocation).
|
||||
// The root is the owner: that is the identity the whole chain rests on,
|
||||
// and the only one a caller can meaningfully anchor a trust decision to.
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
|
||||
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
|
||||
.expect("wrap invocation");
|
||||
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("chain must verify"),
|
||||
owner.did(),
|
||||
"the root issuer is the owner who started the chain, not the agent presenting it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A chain is only bounded if EVERY link is. An unbounded link anywhere makes
|
||||
/// the whole grant permanent, because `is_expired` reports false for it and
|
||||
/// there is no revocation path to withdraw it.
|
||||
#[test]
|
||||
fn chain_lifetime_is_bounded_requires_an_expiry_on_every_link() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
let hour = Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let bounded_root = Ucan::issue(&owner, agent.did(), cap(), Some(hour)).expect("issue");
|
||||
let unbounded_root = Ucan::issue(&owner, agent.did(), cap(), None).expect("issue");
|
||||
|
||||
assert!(
|
||||
Ucan::delegate(&agent, node.did(), cap(), Some(hour), &bounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"both links finite"
|
||||
);
|
||||
assert!(
|
||||
!Ucan::delegate(&agent, node.did(), cap(), None, &bounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"the leaf has no expiry, so the grant never lapses"
|
||||
);
|
||||
assert!(
|
||||
!Ucan::delegate(&agent, node.did(), cap(), Some(hour), &unbounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"a bounded leaf cannot rescue an unbounded proof: the holder can always \
|
||||
mint a fresh leaf from it"
|
||||
);
|
||||
assert!(
|
||||
!unbounded_root.chain_lifetime_is_bounded(),
|
||||
"a self-issued token with no expiry is itself unbounded"
|
||||
);
|
||||
}
|
||||
|
||||
/// A three-link chain: owner -> lead -> agent, which is the real shape of an
|
||||
/// org delegating to a team lead who delegates to a CI identity.
|
||||
///
|
||||
/// Every other chain here is depth two, where the immediate proof IS the root —
|
||||
/// so nothing distinguishes recursing to the true root from simply returning the
|
||||
/// proof's issuer. Both `assert_eq!` and `assert_ne!` below are load-bearing:
|
||||
/// without the second, returning the middle issuer would still satisfy a test
|
||||
/// that only checked "not the leaf".
|
||||
#[test]
|
||||
fn verify_chain_walks_past_the_immediate_proof_to_the_true_root() {
|
||||
let owner = Keypair::generate();
|
||||
let lead = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let root = Ucan::issue(&owner, lead.did(), cap(), None).expect("owner -> lead");
|
||||
let mid = Ucan::delegate(&lead, agent.did(), cap(), None, &root).expect("lead -> agent");
|
||||
let leaf = Ucan::delegate(&agent, node.did(), cap(), None, &mid).expect("agent -> node");
|
||||
|
||||
let found = leaf.verify_chain().expect("a three-link chain must verify");
|
||||
assert_eq!(
|
||||
found,
|
||||
owner.did(),
|
||||
"the root is the owner who started the chain, two hops up"
|
||||
);
|
||||
assert_ne!(
|
||||
found,
|
||||
lead.did(),
|
||||
"returning the immediate proof's issuer is not walking to the root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_returns_self_as_root_for_a_self_issued_token() {
|
||||
// A token with no proofs roots at its own issuer. This is what makes a
|
||||
// self-minted token useless: the caller compares this against the repo
|
||||
// owner and it will only ever match when the presenter IS the owner.
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let ucan =
|
||||
Ucan::issue(&agent, node.did(), vec![Capability::new("*", "*")], None).expect("issue");
|
||||
|
||||
assert_eq!(
|
||||
ucan.verify_chain().expect("a root token still verifies"),
|
||||
agent.did(),
|
||||
"a self-minted token roots at the minter, however permissive its capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stripping `nb` is a widening, and a widening must fail attenuation.
|
||||
///
|
||||
/// Without this, a constrained delegation is trivially escalated: the holder
|
||||
/// re-delegates the same resource and action with the constraints removed,
|
||||
/// `verify_chain` accepts the chain because attenuation only compared `with`
|
||||
/// and `can`, and a consumer that refuses constrained capabilities at the leaf
|
||||
/// (as the node's push gate does) then sees an unconstrained one and grants it.
|
||||
/// Guarding only the leaf guards the wrong end of the chain.
|
||||
#[test]
|
||||
fn verify_chain_rejects_a_child_that_strips_the_parents_constraints() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }));
|
||||
let delegation = Ucan::issue(&owner, agent.did(), vec![constrained], None)
|
||||
.expect("issue constrained delegation");
|
||||
|
||||
// Same resource, same action, constraints dropped.
|
||||
let widened = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
|
||||
let forged =
|
||||
Ucan::delegate(&agent, node.did(), vec![widened], None, &delegation).expect("wrap");
|
||||
|
||||
let err = forged
|
||||
.verify_chain()
|
||||
.expect_err("dropping the parent's constraints must fail attenuation");
|
||||
assert!(
|
||||
err.to_string().contains("attenuation"),
|
||||
"the failure must name attenuation, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_accepts_a_child_that_keeps_the_parents_constraints() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let nb = serde_json::json!({ "refs": ["refs/heads/feat/*"] });
|
||||
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(nb.clone());
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), vec![constrained.clone()], None).expect("issue");
|
||||
let invocation =
|
||||
Ucan::delegate(&agent, node.did(), vec![constrained], None, &delegation).expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation
|
||||
.verify_chain()
|
||||
.expect("an unchanged constraint must verify"),
|
||||
owner.did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconstrained_parent_still_allows_a_child_to_add_constraints() {
|
||||
// Adding `nb` narrows, which is always a legal attenuation.
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let open = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
|
||||
let delegation = Ucan::issue(&owner, agent.did(), vec![open], None).expect("issue");
|
||||
let narrowed = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/main"] }));
|
||||
let invocation =
|
||||
Ucan::delegate(&agent, node.did(), vec![narrowed], None, &delegation).expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("narrowing must verify"),
|
||||
owner.did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_rejects_a_multi_proof_chain() {
|
||||
// Two proofs mean two roots, and nothing says which root authorized a
|
||||
// given capability. Returning either one would be unsound, so refuse.
|
||||
let owner_a = Keypair::generate();
|
||||
let owner_b = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let proof_a = Ucan::issue(&owner_a, agent.did(), caps_vec.clone(), None).expect("issue a");
|
||||
let proof_b = Ucan::issue(&owner_b, agent.did(), caps_vec.clone(), None).expect("issue b");
|
||||
|
||||
// `delegate` only ever writes one proof, so build the two-proof payload by hand.
|
||||
let payload = UcanPayload {
|
||||
ucan: "1.0.0".to_string(),
|
||||
iss: agent.did(),
|
||||
aud: node.did(),
|
||||
att: caps_vec,
|
||||
exp: None,
|
||||
nbf: None,
|
||||
prf: vec![
|
||||
proof_a.encode().expect("encode a"),
|
||||
proof_b.encode().expect("encode b"),
|
||||
],
|
||||
};
|
||||
let signing_bytes = serde_json::to_vec(&payload).expect("serialize payload");
|
||||
let s = agent.sign_b64(&signing_bytes);
|
||||
let multi = Ucan { payload, s };
|
||||
|
||||
let err = multi
|
||||
.verify_chain()
|
||||
.expect_err("a two-proof chain must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("multi-proof"),
|
||||
"the error must name the reason, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod push_scope_tests {
|
||||
use super::push::*;
|
||||
use super::{caps, Capability, Ucan};
|
||||
use crate::identity::Keypair;
|
||||
|
||||
fn hour() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() + chrono::Duration::hours(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_key_eq_collapses_representation_only_within_did_key() {
|
||||
assert!(did_key_eq("did:key:z6MkAbc", "z6MkAbc"));
|
||||
assert!(did_key_eq("z6MkAbc", "did:key:z6MkAbc"));
|
||||
assert!(did_key_eq("did:key:z6MkAbc", "did:key:z6MkAbc"));
|
||||
assert!(!did_key_eq("did:key:z6MkAbc", "did:key:z6MkXyz"));
|
||||
// A bare id must never match across methods: `did:gitlawb` shares the
|
||||
// base58 space with `did:key`.
|
||||
assert!(!did_key_eq("did:gitlawb:z6MkAbc", "z6MkAbc"));
|
||||
assert!(!did_key_eq("did:key:z6MkAbc", "did:gitlawb:z6MkAbc"));
|
||||
assert!(!did_key_eq("did:web:example.com", "example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repo_resource_requires_exactly_two_segments() {
|
||||
assert_eq!(
|
||||
parse_repo_resource("gitlawb://repos/did:key:z6Mk/r"),
|
||||
Some(("did:key:z6Mk", "r"))
|
||||
);
|
||||
for bad in [
|
||||
"*",
|
||||
"",
|
||||
"gitlawb://repos/",
|
||||
"gitlawb://repos/owner",
|
||||
"gitlawb://repos/owner/",
|
||||
"gitlawb://repos//r",
|
||||
"gitlawb://repos/owner/r/extra",
|
||||
"https://repos/owner/r",
|
||||
] {
|
||||
assert_eq!(parse_repo_resource(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_push_wildcard_is_the_resource_wildcard_with_a_push_action() {
|
||||
for can in [caps::GIT_PUSH, "*", caps::REPO_ADMIN] {
|
||||
assert!(is_push_wildcard("*", can), "{can}");
|
||||
}
|
||||
assert!(!is_push_wildcard("*", caps::GIT_FETCH));
|
||||
assert!(!is_push_wildcard("*", caps::PR_OPEN));
|
||||
assert!(!is_push_wildcard("gitlawb://repos/o/r", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capability_grants_push_only_when_concrete_unconstrained_and_push_class() {
|
||||
let owner = "did:key:z6MkOwner";
|
||||
let ok = Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH);
|
||||
assert!(ok.grants_push_to(owner, "r"));
|
||||
// Bare and full owner forms are the same identity.
|
||||
assert!(ok.grants_push_to("z6MkOwner", "r"));
|
||||
|
||||
assert!(!Capability::new("*", caps::GIT_PUSH).grants_push_to(owner, "r"));
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_FETCH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/other", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOther/r", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({"max_bytes": 1}))
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
for can in ["*", caps::REPO_ADMIN] {
|
||||
assert!(
|
||||
Capability::new("gitlawb://repos/did:key:z6MkOwner/r", can)
|
||||
.grants_push_to(owner, "r"),
|
||||
"{can} covers git/push"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The chain walk, at every depth: a wildcard anywhere denies; concrete
|
||||
/// everywhere grants. The grandparent case is the one a single-level check
|
||||
/// misses, because the immediate proof already names the repo.
|
||||
#[test]
|
||||
fn chain_grants_push_only_when_every_link_names_the_repo() {
|
||||
let owner = Keypair::generate();
|
||||
let a = Keypair::generate();
|
||||
let b = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let concrete = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
let wild = || vec![Capability::new("*", caps::GIT_PUSH)];
|
||||
|
||||
// Single self-issued link.
|
||||
assert!(Ucan::issue(&owner, a.did(), concrete(), Some(hour()))
|
||||
.unwrap()
|
||||
.chain_grants_push_to(&owner_s, "r"));
|
||||
assert!(!Ucan::issue(&owner, a.did(), wild(), Some(hour()))
|
||||
.unwrap()
|
||||
.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Two links: concrete leaf on a wildcard proof must deny.
|
||||
let wild_proof = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
|
||||
let leaf = Ucan::delegate(&a, node.did(), concrete(), Some(hour()), &wild_proof).unwrap();
|
||||
assert!(
|
||||
leaf.verify_chain().is_ok(),
|
||||
"attenuation accepts it — that is why we walk"
|
||||
);
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Three links: wildcard grandparent, concrete parent, concrete leaf.
|
||||
let gp = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
|
||||
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
|
||||
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
|
||||
assert!(leaf.verify_chain().is_ok());
|
||||
assert!(
|
||||
!leaf.chain_grants_push_to(&owner_s, "r"),
|
||||
"a wildcard two links up must deny"
|
||||
);
|
||||
|
||||
// Three links, all concrete.
|
||||
let gp = Ucan::issue(&owner, a.did(), concrete(), Some(hour())).unwrap();
|
||||
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
|
||||
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
|
||||
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
// And the bare owner form is the same repo.
|
||||
assert!(leaf.chain_grants_push_to(owner_s.strip_prefix("did:key:").unwrap(), "r"));
|
||||
// But not another repo.
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "other"));
|
||||
}
|
||||
|
||||
/// Fail closed on anything the walk cannot vouch for.
|
||||
#[test]
|
||||
fn chain_walk_fails_closed_on_undecodable_or_multi_proofs_or_excess_depth() {
|
||||
let owner = Keypair::generate();
|
||||
let a = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
|
||||
let mut broken = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
broken.payload.prf = vec!["not a ucan".into()];
|
||||
assert!(!broken.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
let good = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
let mut two = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
two.payload.prf = vec![good.encode().unwrap(), good.encode().unwrap()];
|
||||
assert!(!two.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Deeper than MAX_CHAIN_DEPTH, all concrete: refused on depth alone.
|
||||
let mut cur = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
let mut signer = a;
|
||||
for _ in 0..MAX_CHAIN_DEPTH {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
assert!(
|
||||
!cur.chain_grants_push_to(&owner_s, "r"),
|
||||
"depth bound must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every walk over `prf` stops at the same depth. The round-ten P1: the scope
|
||||
/// walk was bounded while `verify_chain` and `chain_lifetime_is_bounded` were
|
||||
/// not, and the node runs `verify_chain` on an attacker-supplied `X-Ucan` header
|
||||
/// before the bounded walk ever sees it, so the bound protected nothing.
|
||||
#[cfg(test)]
|
||||
mod chain_depth_tests {
|
||||
use super::{caps, Capability, Ucan, MAX_CHAIN_DEPTH};
|
||||
use crate::identity::Keypair;
|
||||
|
||||
fn hour() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() + chrono::Duration::hours(1)
|
||||
}
|
||||
|
||||
/// A well-formed, fully signed, expiring chain of exactly `links` links, every
|
||||
/// one naming the owner's repository, so the only thing any walk can object
|
||||
/// to is how long it is. Returns the leaf and the owner it roots at.
|
||||
fn concrete_chain(links: usize) -> (Ucan, Keypair) {
|
||||
assert!(links >= 1);
|
||||
let owner = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
|
||||
let mut signer = Keypair::generate();
|
||||
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour())).unwrap();
|
||||
for _ in 1..links {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
(cur, owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_at_the_bound_passes_every_walk() {
|
||||
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH);
|
||||
let owner_s = owner.did().to_string();
|
||||
assert_eq!(
|
||||
leaf.verify_chain()
|
||||
.expect("a chain at the bound must verify"),
|
||||
owner.did()
|
||||
);
|
||||
assert!(leaf.chain_lifetime_is_bounded());
|
||||
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_link_past_the_bound_fails_every_walk_closed() {
|
||||
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH + 1);
|
||||
let owner_s = owner.did().to_string();
|
||||
|
||||
let err = leaf
|
||||
.verify_chain()
|
||||
.expect_err("verify_chain must refuse a chain past the bound");
|
||||
assert!(
|
||||
err.to_string().contains("deeper than"),
|
||||
"the refusal must say depth was the reason, not a fake signature failure: {err}"
|
||||
);
|
||||
assert!(
|
||||
!leaf.chain_lifetime_is_bounded(),
|
||||
"an unwalkable chain cannot be vouched for as bounded"
|
||||
);
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
}
|
||||
|
||||
/// A chain well past the bound is refused from the top, before any per-link
|
||||
/// work. Only a few links past it, deliberately: a proof is embedded as a JSON
|
||||
/// string inside the next link's JSON, so every level re-escapes the one
|
||||
/// inside it and the encoding roughly doubles per link — eight links is about
|
||||
/// 10 KiB, sixteen is megabytes, and a chain of "a few hundred levels" cannot
|
||||
/// physically be encoded, let alone sent as a header. The bound is still
|
||||
/// what stops the reachable dozen-odd levels from each costing a decode of
|
||||
/// everything beneath them.
|
||||
#[test]
|
||||
fn a_chain_well_past_the_bound_is_refused_not_walked() {
|
||||
let (leaf, _) = concrete_chain(MAX_CHAIN_DEPTH + 4);
|
||||
let err = leaf.verify_chain().expect_err("must refuse");
|
||||
assert!(err.to_string().contains("deeper than"), "{err}");
|
||||
assert!(!leaf.chain_lifetime_is_bounded());
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
axum = { version = "0.8", features = ["http1", "http2", "json", "ws"] }
|
||||
async-graphql = { version = "7", features = ["chrono", "uuid", "tracing"] }
|
||||
async-graphql-axum = "7"
|
||||
@@ -75,7 +73,6 @@ alloy = { version = "1", default-features = false, features = [
|
||||
"network",
|
||||
"rpc-types-eth",
|
||||
] }
|
||||
rand = { workspace = true }
|
||||
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::db::normalize_owner_key;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -22,62 +21,17 @@ fn default_limit() -> i64 {
|
||||
}
|
||||
|
||||
/// GET /api/v1/arweave/anchors
|
||||
///
|
||||
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
|
||||
/// request to the canonical read-authorization path used by every other
|
||||
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
|
||||
/// identity attached. Missing repositories, quarantined mirrors, and signed
|
||||
/// non-readers all collapse to the standard `404` (no existence oracle). The
|
||||
/// unscoped listing is auth-only — the #121 contract — and does not admit
|
||||
/// visibility filtering, which is the #136 stale-index class and explicitly
|
||||
/// out of scope for this auth slice.
|
||||
pub async fn list_anchors(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListAnchorsQuery>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
// The route's `optional_signature` layer is permissive (it admits unsigned
|
||||
// legacy callers) so this in-handler check is the actual gate. A signed
|
||||
// non-reader still passes this check and proceeds to the authz step below.
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
if caller.is_none() {
|
||||
return Err(AppError::Unauthorized(
|
||||
"authentication required for anchor listing".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp before the SQL query. A negative or non-numeric limit must not
|
||||
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
|
||||
// contract is the same default ceiling as the listing page itself.
|
||||
let limit = q.limit.clamp(0, 200);
|
||||
|
||||
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
|
||||
// Parse the user-supplied "owner/name" through the same slug validator
|
||||
// the sync path uses, so a malformed query is a 400, not a 500.
|
||||
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
|
||||
};
|
||||
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
|
||||
// missing repos, quarantined mirrors, and signed non-readers.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
|
||||
// Build the SQL filter from the canonical stored slug, NOT from the
|
||||
// user-supplied `?repo=` string. Anchor rows are written as
|
||||
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
|
||||
// request that passes authz with the full `did:key:…/name` form would
|
||||
// match zero rows and return a false empty page. The gate already
|
||||
// verified the repo is readable; `record.owner_did` is the canonical
|
||||
// identity and `record.name` is the stored name.
|
||||
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
|
||||
state
|
||||
.db
|
||||
.list_arweave_anchors(Some(&stored_slug), limit)
|
||||
.await?
|
||||
} else {
|
||||
// No scope → no repo-read decision. Auth-only.
|
||||
state.db.list_arweave_anchors(None, limit).await?
|
||||
};
|
||||
let limit = q.limit.min(200);
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let anchors = state
|
||||
.db
|
||||
.list_arweave_anchors(q.repo.as_deref(), limit)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
@@ -106,7 +60,6 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/arweave/anchors")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -285,22 +285,11 @@ pub async fn submit_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SubmitBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "claimed" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -336,22 +325,11 @@ pub async fn approve_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ApproveBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "submitted" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -394,22 +372,11 @@ pub async fn cancel_bounty(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "open" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -440,22 +407,11 @@ pub async fn dispute_bounty(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "claimed" && bounty.status != "submitted" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -500,68 +456,21 @@ pub async fn dispute_bounty(
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
|
||||
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
|
||||
/// all deduped repos, batch-load their visibility rules, keep only those that
|
||||
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
|
||||
/// per-repo authorization queries.
|
||||
///
|
||||
/// Both bounty stats handlers mount behind `optional_signature` (see
|
||||
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
|
||||
/// anonymous access. Every caller, signed or not, currently receives the
|
||||
/// anonymous-scoped aggregates (matching the #104 pattern); per-caller
|
||||
/// visibility can be threaded through here when that work lands.
|
||||
///
|
||||
/// A database error is returned to the caller, never collapsed to an empty
|
||||
/// set: an empty set would render as HTTP 200 with zero counts, which a client
|
||||
/// reads as confirmed absence of activity.
|
||||
async fn visible_repo_pairs(state: &AppState) -> Result<Vec<(String, String)>> {
|
||||
let rows = state.db.list_all_repos_deduped().await?;
|
||||
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
|
||||
let pairs = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
// mirror-rows-handled: a mirror with a canonical twin is deduped to the
|
||||
// canonical row and its rules; a mirror-only repo counts as public, the
|
||||
// same answer authorize_repo_read and /stats give. Pinned by
|
||||
// bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone.
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
|
||||
})
|
||||
.map(|r| {
|
||||
(
|
||||
crate::db::normalize_owner_key(&r.owner_did).to_string(),
|
||||
r.name.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// GET /api/v1/bounties/stats
|
||||
///
|
||||
/// Aggregates are restricted to anonymously-listable repos so private-repo
|
||||
/// bounty activity is not exposed (#477). The visible-repo set is resolved
|
||||
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
|
||||
/// passed into the filtered aggregate queries.
|
||||
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
|
||||
let visible = visible_repo_pairs(&state).await?;
|
||||
|
||||
let open = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("open", &visible)
|
||||
.await?;
|
||||
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
|
||||
let claimed = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("claimed", &visible)
|
||||
.await?;
|
||||
.count_bounties_by_status("claimed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let completed = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("completed", &visible)
|
||||
.await?;
|
||||
.count_bounties_by_status("completed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let leaders = state.db.bounty_leaderboard_visible(10, &visible).await?;
|
||||
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
|
||||
let leaderboard = leaders
|
||||
.into_iter()
|
||||
.map(|(did, cnt, total)| AgentBountyEntry {
|
||||
@@ -580,14 +489,11 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
|
||||
}
|
||||
|
||||
/// GET /api/v1/agents/{did}/bounties
|
||||
///
|
||||
/// Per-agent earnings restricted to anonymously-listable repos (#477).
|
||||
pub async fn agent_bounty_stats(
|
||||
State(state): State<AppState>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let visible = visible_repo_pairs(&state).await?;
|
||||
let (count, total) = state.db.agent_bounty_stats_visible(&did, &visible).await?;
|
||||
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
|
||||
Ok(Json(serde_json::json!({
|
||||
"did": did,
|
||||
"completed_bounties": count,
|
||||
|
||||
@@ -52,7 +52,6 @@ pub async fn list_certs(
|
||||
"node_did": c.node_did,
|
||||
"signature": c.signature,
|
||||
"issued_at": c.issued_at,
|
||||
"version": c.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -93,6 +92,5 @@ pub async fn get_cert(
|
||||
"node_did": cert.node_did,
|
||||
"signature": cert.signature,
|
||||
"issued_at": cert.issued_at,
|
||||
"version": cert.version,
|
||||
})))
|
||||
}
|
||||
@@ -11,8 +11,7 @@ use crate::state::AppState;
|
||||
|
||||
/// Hard ceiling on rows any ref-update feed returns in one request. Shared by the
|
||||
/// shared collector's clamp and the per-handler request caps so they can't drift.
|
||||
/// `pub(crate)` so the GraphQL complexity meter can price against the same bound.
|
||||
pub(crate) const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
|
||||
/// Collect up to `limit` ref-update rows visible to `caller`, newest first,
|
||||
/// paging past rows the feed gate drops. Filtering after a plain SQL `LIMIT`
|
||||
@@ -438,7 +437,6 @@ mod ref_updates_feed_tests {
|
||||
node_did: "did:key:z6MkNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: Utc::now().to_rfc3339(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2130,77 +2130,14 @@ async fn gate_and_serve(
|
||||
|
||||
/// GET /api/v1/ipfs/pins
|
||||
///
|
||||
/// Returns all CIDs that have been pinned from git objects received via push.
|
||||
/// Each entry includes the git SHA-256 hex and the timestamp when it was
|
||||
/// pinned. The wire contract is nullable by design:
|
||||
///
|
||||
/// - `cid` / `local_cid` — node-local resolver keys: the raw CIDv1 the
|
||||
/// `GET /ipfs/{cid}` endpoint serves. Non-null ONLY when the stored value
|
||||
/// parses as a raw CIDv1 AND writer-owned local provenance confirms the
|
||||
/// bytes went through this node's IPFS daemon. Otherwise null, even when
|
||||
/// the database holds some other CID-shaped string for the row.
|
||||
/// - `pinata_cid` — the provider identifier Pinata returned (dag-pb/UnixFS,
|
||||
/// not a node resolver key). The only usable identifier for remote-only
|
||||
/// rows; `gl` consumers must read this field, never `cid`, for those.
|
||||
/// - `local_pinned` — `true` iff the local-IPFS writer path
|
||||
/// (`record_pinned_cid_with_source`) pushed the bytes into the local
|
||||
/// daemon. Writer-owned; never inferred from CID shape. A row can carry
|
||||
/// `local_pinned = true` with a null `cid` when its stored key predates
|
||||
/// the raw-key contract (legacy provider key awaiting repair).
|
||||
/// - `pinata_pinned` — `true` iff the row has a non-null `pinata_cid`.
|
||||
///
|
||||
/// Rows with neither a local nor a Pinata CID are omitted so the response
|
||||
/// only contains rows with at least one backend. Remote-only rows stay
|
||||
/// visible through `pinata_cid`/`pinata_pinned`; nothing is hidden.
|
||||
///
|
||||
/// #218 review P2: the response surfaces writer-owned provenance so a
|
||||
/// `gl` consumer can distinguish local-only, remote-only, and dual rows
|
||||
/// without re-inferring semantics from nullability.
|
||||
pub async fn list_pins(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if auth.is_none() {
|
||||
return Err(crate::error::AppError::Unauthorized(
|
||||
"authentication required for pin listing".into(),
|
||||
));
|
||||
}
|
||||
/// Returns all CIDs that have been pinned to the local IPFS node from git
|
||||
/// objects received via push. Each entry includes the git SHA-256 hex, the
|
||||
/// CIDv1 string, and the timestamp when it was pinned.
|
||||
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let pins = state.db.list_pinned_cids().await?;
|
||||
|
||||
let pins: Vec<serde_json::Value> = pins
|
||||
.into_iter()
|
||||
.filter(|p| p.cid.is_some() || p.pinata_cid.is_some())
|
||||
.map(|p| {
|
||||
// A stored `cid` is advertised as a node-local resolver key
|
||||
// only when it passes the raw-CIDv1 contract AND local
|
||||
// provenance is confirmed. The historical `cid` column has
|
||||
// held more than one namespace (Kubo dag-pb, Pinata CIDv0),
|
||||
// and the `/ipfs/{cid}` resolver recomputes the raw CID
|
||||
// from object bytes and rejects anything else — so a
|
||||
// legacy provider key in `cid`/`local_cid` would promise a
|
||||
// local object the node immediately refuses to serve.
|
||||
// Such rows stay listed (with their provenance flags) but
|
||||
// carry no local key until repair rewrites them.
|
||||
let local_key = match &p.cid {
|
||||
Some(c) if gitlawb_core::cid::is_raw_cidv1(c) && p.local_ipfs_provenance => {
|
||||
Some(c.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
serde_json::json!({
|
||||
"sha256_hex": p.sha256_hex,
|
||||
"cid": local_key.clone(),
|
||||
"local_cid": local_key,
|
||||
"pinata_cid": p.pinata_cid,
|
||||
"local_pinned": p.local_ipfs_provenance,
|
||||
"pinata_pinned": p.pinata_cid.is_some(),
|
||||
"pinned_at": p.pinned_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"pins": pins,
|
||||
"count": pins.len(),
|
||||
@@ -2489,7 +2426,6 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/ipfs/pins")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -2514,369 +2450,6 @@ mod closed_pool_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #218 review P2: the `list_pins` API response surfaces writer-owned
|
||||
/// provenance (`local_pinned`, `pinata_pinned`) so a `gl` consumer
|
||||
/// can distinguish local-only, remote-only, and dual rows without
|
||||
/// re-inferring semantics from nullability. `cid`/`local_cid` carry a
|
||||
/// value ONLY for rows whose stored key parses as a raw CIDv1 with
|
||||
/// confirmed local provenance; anything else (Pinata-only rows,
|
||||
/// legacy provider keys) exposes its usable identifier through
|
||||
/// `pinata_cid` alone, and every non-null local key must resolve
|
||||
/// through `GET /ipfs/{cid}`.
|
||||
#[sqlx::test]
|
||||
async fn list_pins_reports_writer_owned_provenance_for_all_shapes(pool: sqlx::PgPool) {
|
||||
use sqlx::Row as _;
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let db = &state.db;
|
||||
|
||||
// Raw resolver keys must be REAL raw CIDv1 strings: the handler
|
||||
// gates `cid`/`local_cid` on `is_raw_cidv1`, so placeholder text
|
||||
// would fail the gate for the wrong reason. Provider CIDs stay
|
||||
// opaque Qm… strings (never syntax-checked, only echoed).
|
||||
fn raw_cid_for(content: &[u8]) -> String {
|
||||
gitlawb_core::cid::Cid::from_git_object_bytes(content).to_string()
|
||||
}
|
||||
|
||||
// Two real blobs on disk in one public repo, so the local and
|
||||
// dual rows below can be followed through `GET /ipfs/{cid}`.
|
||||
// The resolver locates repos through `repo_store.acquire`, not
|
||||
// the row's `disk_path`, so the recipe overwrites the acquired
|
||||
// bare path with a real clone: workdir commit of both files,
|
||||
// cloned --bare into the store path, oids via rev-parse, raw
|
||||
// keys recomputed from the file bytes exactly as the serve
|
||||
// path does.
|
||||
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
let content1 = b"pins follow-through one\n";
|
||||
let content2 = b"pins follow-through two\n";
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let work = tmp.path().join("work-pins-list");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::write(work.join("one.txt"), content1).unwrap();
|
||||
std::fs::write(work.join("two.txt"), content2).unwrap();
|
||||
run_git(
|
||||
&["init", "-q", "--object-format=sha256", "-b", "main"],
|
||||
&work,
|
||||
);
|
||||
run_git(&["config", "user.email", "t@t"], &work);
|
||||
run_git(&["config", "user.name", "t"], &work);
|
||||
run_git(&["add", "."], &work);
|
||||
run_git(&["commit", "-qm", "seed"], &work);
|
||||
let oid_of = |spec: &str| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["rev-parse", spec])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.expect("git rev-parse runs");
|
||||
assert!(out.status.success(), "rev-parse {spec} failed");
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
let sha_local = oid_of("HEAD:one.txt");
|
||||
let sha_dual = oid_of("HEAD:two.txt");
|
||||
let raw1 = raw_cid_for(content1);
|
||||
let raw4 = raw_cid_for(content2);
|
||||
db.upsert_mirror_repo(
|
||||
"zPinsListOwner",
|
||||
"pins-list",
|
||||
"/unused-pins-list",
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let rec = db
|
||||
.get_repo("zPinsListOwner", "pins-list")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("mirror repo row");
|
||||
let bare = state
|
||||
.repo_store
|
||||
.acquire(&rec.owner_did, &rec.name)
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_dir_all(&bare);
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap(),
|
||||
],
|
||||
tmp.path(),
|
||||
);
|
||||
|
||||
// The combination table for what the response must carry:
|
||||
//
|
||||
// shape | local_pinned | pinata_pinned | local_cid | pinata_cid
|
||||
// -------------------+--------------+---------------+-----------+-----------
|
||||
// local-only | true | false | raw | null
|
||||
// pinata-only (raw) | false | true | null | provider
|
||||
// pinata-only (null) | false | true | null | raw(=provider)
|
||||
// dual | true | true | raw | provider
|
||||
// legacy provider key| true | false | null | null
|
||||
//
|
||||
// The pinata-only (raw) row keeps a genuine raw key in the
|
||||
// column but must NOT surface it: provenance is unconfirmed,
|
||||
// so no local field may carry it. The legacy row predates the
|
||||
// raw-key contract (dag-pb key, provenance claimed) and must
|
||||
// surface no local key until repair rewrites it — while the
|
||||
// row itself (and its `local_pinned` flag) stays visible.
|
||||
//
|
||||
// Distinct shas (the table is keyed on sha256_hex) keep the
|
||||
// rows independent.
|
||||
|
||||
// (1) local-only: real local pin via the production writer.
|
||||
db.record_pinned_cid_with_source(&sha_local, &raw1, &rec.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// (2) pinata-only (raw != provider): the post-v27/v30 row shape
|
||||
// produced by `record_pinata_cid`. The stored raw key is genuine
|
||||
// but local provenance is unconfirmed, so neither local field
|
||||
// may carry it; the provider field is the usable identifier.
|
||||
let sha_pinata_raw = "sha_p2_pinata_only_distinct_cids";
|
||||
let raw2 = raw_cid_for(b"pins pinata-only bytes\n");
|
||||
let pinata2 = "QmPinataProviderCidForRawOnlyRow";
|
||||
assert_ne!(raw2, pinata2);
|
||||
db.record_pinata_cid(
|
||||
sha_pinata_raw,
|
||||
&raw2,
|
||||
pinata2,
|
||||
Some("repo-p2-pinata-raw"),
|
||||
i64::MAX,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// (3) pinata-only (raw == provider): `record_pinata_cid` stores
|
||||
// `cid = NULL` so the resolver key isn't aliased to a dag-pb
|
||||
// provider CID that doesn't hash to the raw bytes (#173).
|
||||
let sha_pinata_null = "sha_p2_pinata_only_null_cid";
|
||||
let same = "QmPinataOnlyCidRawEqualsProvider";
|
||||
db.record_pinata_cid(
|
||||
sha_pinata_null,
|
||||
same,
|
||||
same,
|
||||
Some("repo-p2-pinata-null"),
|
||||
i64::MAX,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// (4) dual: local first (sets the flag and `cid`), then Pinata
|
||||
// (sets `pinata_cid` and preserves `cid` and `local_ipfs_provenance`).
|
||||
let pinata4 = "QmPinataProviderCidForDualRow";
|
||||
db.record_pinned_cid_with_source(&sha_dual, &raw4, &rec.id)
|
||||
.await
|
||||
.unwrap();
|
||||
db.record_pinata_cid(&sha_dual, &raw4, pinata4, Some(&rec.id), i64::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// (5) legacy provider key: a pre-fix row whose `cid` is a dag-pb
|
||||
// provider identifier with claimed local provenance. Production
|
||||
// writers can no longer produce this shape, so it is seeded with
|
||||
// raw SQL — the point is that the projection must not trust column
|
||||
// shape as proof of a raw local key.
|
||||
let sha_legacy = "sha_p2_legacy_provider_key";
|
||||
sqlx::query(
|
||||
"INSERT INTO pinned_cids (sha256_hex, cid, pinned_at, pinata_cid, repo_id, local_ipfs_provenance)
|
||||
VALUES ($1, $2, $3, NULL, $4, TRUE)",
|
||||
)
|
||||
.bind(sha_legacy)
|
||||
.bind("QmLegacyProviderKeyPredatesRawContract")
|
||||
.bind("2026-07-01T00:00:00Z")
|
||||
.bind(&rec.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Hit the production handler end-to-end through a one-shot router
|
||||
// request, so the test exercises the actual response shape
|
||||
// (not a unit test on PinnedCidRecord fields).
|
||||
let resp = Router::new()
|
||||
.route("/api/v1/ipfs/pins", axum::routing::get(list_pins))
|
||||
.layer(axum::Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:zPinListTest".to_string(),
|
||||
)))
|
||||
.with_state(state.clone())
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/v1/ipfs/pins")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), axum::http::StatusCode::OK);
|
||||
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");
|
||||
let pins = v
|
||||
.get("pins")
|
||||
.and_then(|p| p.as_array())
|
||||
.expect("pins array");
|
||||
assert_eq!(
|
||||
pins.len(),
|
||||
5,
|
||||
"all five shapes must appear in the response (every row has at least one of cid/pinata_cid set)"
|
||||
);
|
||||
|
||||
// Build a map sha -> pin object for ergonomic assertions.
|
||||
let by_sha: std::collections::HashMap<String, &Value> = pins
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p.get("sha256_hex")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
p,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Helper: assert a single pin's fields.
|
||||
let assert_pin = |sha: &str,
|
||||
local_pinned: bool,
|
||||
pinata_pinned: bool,
|
||||
local_cid: Option<&str>,
|
||||
pinata_cid: Option<&str>| {
|
||||
let pin = by_sha.get(sha).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"pin row for {sha} missing; got shas {:?}",
|
||||
by_sha.keys().collect::<Vec<_>>()
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
pin.get("local_pinned").and_then(|v| v.as_bool()),
|
||||
Some(local_pinned),
|
||||
"{sha}: local_pinned mismatch",
|
||||
);
|
||||
assert_eq!(
|
||||
pin.get("pinata_pinned").and_then(|v| v.as_bool()),
|
||||
Some(pinata_pinned),
|
||||
"{sha}: pinata_pinned mismatch",
|
||||
);
|
||||
assert_eq!(
|
||||
pin.get("local_cid").and_then(|v| v.as_str()),
|
||||
local_cid,
|
||||
"{sha}: local_cid mismatch",
|
||||
);
|
||||
assert_eq!(
|
||||
pin.get("pinata_cid").and_then(|v| v.as_str()),
|
||||
pinata_cid,
|
||||
"{sha}: pinata_cid mismatch",
|
||||
);
|
||||
// Round-3 P2: `cid` is the LOCAL resolver key, full
|
||||
// stop. It is the raw CID when the row has a local pin
|
||||
// (local-only or dual shape) and `None` for Pinata-only
|
||||
// rows. The previous contract aliased the Pinata provider
|
||||
// CID into `cid` for Pinata-only rows, which made
|
||||
// `gl ipfs list` advertise a CID the node's own
|
||||
// `/ipfs/{cid}` resolver cannot serve (404). The
|
||||
// provenance split is exposed via `local_cid`,
|
||||
// `pinata_cid`, and the boolean `local_pinned` /
|
||||
// `pinata_pinned` flags.
|
||||
let cid_value = pin.get("cid").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let expected_cid = local_cid
|
||||
.map(|s| serde_json::Value::String(s.to_string()))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
assert_eq!(cid_value, expected_cid, "{sha}: cid mismatch");
|
||||
};
|
||||
|
||||
// (1) local-only
|
||||
assert_pin(&sha_local, true, false, Some(raw1.as_str()), None);
|
||||
// (2) pinata-only (raw != provider): the stored raw key is
|
||||
// genuine but provenance is unconfirmed, so NO local field may
|
||||
// carry it — remote-only state is visible through `pinata_cid`
|
||||
// alone.
|
||||
assert_pin(sha_pinata_raw, false, true, None, Some(pinata2));
|
||||
// (3) pinata-only (raw == provider) — cid NULL, pinata_cid set
|
||||
assert_pin(sha_pinata_null, false, true, None, Some(same));
|
||||
// (4) dual
|
||||
assert_pin(&sha_dual, true, true, Some(raw4.as_str()), Some(pinata4));
|
||||
// (5) legacy provider key: no local field carries the dag-pb
|
||||
// key, but the row and its claimed provenance stay visible so
|
||||
// the repair gap is observable rather than hidden.
|
||||
assert_pin(sha_legacy, true, false, None, None);
|
||||
|
||||
// Sanity: the `local_ipfs_provenance` column itself is what
|
||||
// powers `local_pinned`, so the response field must agree
|
||||
// with the database column. Reading directly avoids any
|
||||
// confusion if the writer path changes.
|
||||
let rows = sqlx::query("SELECT sha256_hex, local_ipfs_provenance FROM pinned_cids")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut db_provenance: std::collections::HashMap<String, bool> = Default::default();
|
||||
for r in rows {
|
||||
let sha: String = r.get("sha256_hex");
|
||||
let p: bool = r.get("local_ipfs_provenance");
|
||||
db_provenance.insert(sha, p);
|
||||
}
|
||||
for (sha, expected) in [
|
||||
(sha_local.as_str(), true),
|
||||
(sha_pinata_raw, false),
|
||||
(sha_pinata_null, false),
|
||||
(sha_dual.as_str(), true),
|
||||
(sha_legacy, true),
|
||||
] {
|
||||
assert_eq!(
|
||||
db_provenance.get(sha).copied(),
|
||||
Some(expected),
|
||||
"db column for {sha} does not match expected writer-owned provenance"
|
||||
);
|
||||
}
|
||||
|
||||
// Follow-through: every non-null local key in the response must
|
||||
// resolve through `GET /ipfs/{cid}`. The local-only and dual rows
|
||||
// above name real blobs on disk in a public repo, so both must
|
||||
// serve their exact bytes; the Pinata-only and legacy rows carry
|
||||
// no local key and have nothing to follow.
|
||||
let follow_router = Router::new()
|
||||
.route("/ipfs/{cid}", axum::routing::get(get_by_cid))
|
||||
.with_state(state.clone());
|
||||
for (raw, content) in [(&raw1, &content1[..]), (&raw4, &content2[..])] {
|
||||
let resp = follow_router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri(format!("/ipfs/{raw}"))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
axum::http::StatusCode::OK,
|
||||
"advertised local key {raw} must resolve"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
assert_eq!(
|
||||
body.as_ref(),
|
||||
content,
|
||||
"resolved bytes for {raw} must match the pinned blob"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #251 / CodeRabbit nit: cover `get_by_cid`'s DB-error conversion path — a
|
||||
/// valid CID must still yield 503 on a closed pool.
|
||||
#[sqlx::test]
|
||||
@@ -8785,7 +8358,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn get_by_cid_per_source_cap_sheds_same_source_admits_other() {
|
||||
let mut state = crate::test_support::test_state_lazy();
|
||||
state.db.pool().close().await;
|
||||
// Global pool has room; the per-source cap is 1.
|
||||
state.git_ipfs_walk_semaphore = Arc::new(Semaphore::new(8));
|
||||
state.git_ipfs_walk_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100);
|
||||
@@ -8812,23 +8384,16 @@ mod tests {
|
||||
"a source at its per-source /ipfs walk cap must shed 503 with global capacity free"
|
||||
);
|
||||
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(body["error"], "overloaded");
|
||||
|
||||
// A DIFFERENT source is NOT shed by the per-source cap: it clears admission and
|
||||
// proceeds to the closed DB, which has a distinct db_unavailable error code.
|
||||
// proceeds (then errors on the lazy DB, which is not a 503).
|
||||
let resp = ipfs_router(state)
|
||||
.oneshot(get_cid(&cid, Some(other)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
body["error"],
|
||||
crate::error::DB_UNAVAILABLE_CODE,
|
||||
"a different source must clear admission and reach the closed database"
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"a different source must not be shed by the per-source cap"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ pub struct LabelRequest {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Canonicalize and validate a label string. `add_label` and `remove_label`
|
||||
/// must agree on the form stored in the DB: `add_label` already trims and
|
||||
/// lowercases before storing, so `remove_label` has to do the same to find
|
||||
/// the row. Validation is shared so neither handler can drift on charset or
|
||||
/// length (#344).
|
||||
fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
|
||||
let label = raw.trim().to_lowercase();
|
||||
/// POST /api/v1/repos/:owner/:repo/labels
|
||||
pub async fn add_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<LabelRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let label = req.label.trim().to_lowercase();
|
||||
if label.is_empty() || label.len() > 50 {
|
||||
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
|
||||
}
|
||||
@@ -32,17 +33,6 @@ fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
|
||||
"label must contain only alphanumeric characters, hyphens, and colons".into(),
|
||||
));
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/labels
|
||||
pub async fn add_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<LabelRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let label = canonicalize_label(&req.label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
@@ -69,8 +59,6 @@ pub async fn remove_label(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, label)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let label = canonicalize_label(&label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
@@ -78,10 +66,7 @@ pub async fn remove_label(
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let removed = state.db.remove_label(&record.id, &label).await?;
|
||||
if !removed {
|
||||
return Err(AppError::NotFound(format!("label '{label}'")));
|
||||
}
|
||||
state.db.remove_label(&record.id, &label).await?;
|
||||
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ pub mod replicas;
|
||||
pub mod repos;
|
||||
pub mod resolve;
|
||||
pub mod stars;
|
||||
pub mod task_cursor;
|
||||
pub mod tasks;
|
||||
pub mod visibility;
|
||||
pub mod webhooks;
|
||||
@@ -69,25 +68,27 @@ pub(crate) async fn authorize_repo_read(
|
||||
/// `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 {
|
||||
// One implementation, shared with `gl` and `git-remote-gitlawb` through core:
|
||||
// every boundary that compares a DID applies the same collapse rule.
|
||||
gitlawb_core::ucan::push::did_key_eq(a, b)
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
fn key_id(d: &str) -> &str {
|
||||
d.strip_prefix("did:key:").unwrap_or(d)
|
||||
}
|
||||
let (ka, kb) = (key_id(a), key_id(b));
|
||||
// After stripping `did:key:`, a value still containing ':' is a non-key full
|
||||
// DID — do not let it match a bare `did:key` id.
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
|
||||
/// renders it as `AppError::Forbidden`; a handler that emits its own
|
||||
/// `(StatusCode, Json)` envelope instead of `AppError` (e.g.
|
||||
/// `api::tasks::create_task`) must use this constant too, so two copies of one
|
||||
/// gate cannot drift in observable output.
|
||||
pub(crate) const REPO_OWNER_ONLY_MESSAGE: &str = "only the repo owner can perform this action";
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
/// and the author check (close policy) share one normalization.
|
||||
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
|
||||
if did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(REPO_OWNER_ONLY_MESSAGE.into()))
|
||||
Err(AppError::Forbidden(
|
||||
"only the repo owner can perform this action".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,11 +242,6 @@ mod authz_guard {
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
// #496: create_task is signer-self AND owner-when-repo-scoped — a
|
||||
// repo_id naming a hosted repo admits tasks only from its owner.
|
||||
// Both halves are pinned: the did_matches row guards the signer
|
||||
// binding, this one the repo-ownership gate.
|
||||
(tasks, "create_task", "require_repo_owner("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(tasks, "complete_task", "did_matches("),
|
||||
(tasks, "fail_task", "did_matches("),
|
||||
@@ -345,11 +341,7 @@ mod authz_guard {
|
||||
(
|
||||
"create_task",
|
||||
"createTask",
|
||||
&[
|
||||
"require_signer(ctx)?",
|
||||
"did_matches(",
|
||||
"require_repo_owner(",
|
||||
],
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
),
|
||||
(
|
||||
"claim_task",
|
||||
|
||||
@@ -70,8 +70,6 @@ pub struct PeerResponse {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)";
|
||||
|
||||
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
|
||||
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
|
||||
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
|
||||
@@ -84,7 +82,7 @@ pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) U
|
||||
/// sits at a prefix-length-dependent offset (RFC 6052 §2.2) — so they return
|
||||
/// `None`. Any caller that needs them blocked must reject the wider
|
||||
/// `64:ff9b::/32` itself; `is_public_http_url` does this in its native-v6 arm.
|
||||
pub(crate) fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
|
||||
return Some(v4);
|
||||
}
|
||||
@@ -104,120 +102,10 @@ pub(crate) fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether an IP literal is outside the local/private ranges that an
|
||||
/// attacker-controlled outbound URL must never reach. Kept separate from URL
|
||||
/// parsing so callers that resolve a DNS name themselves can apply the same
|
||||
/// classification to every answer and then pin the approved addresses.
|
||||
pub(crate) fn is_public_ip(ip: std::net::IpAddr) -> bool {
|
||||
// Reject loopback/unspecified on the literal as given — catches `::1` and
|
||||
// `::` before the IPv4-folding below (`::1`.to_ipv4() would otherwise map
|
||||
// to a non-loopback `0.0.0.1`).
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
|
||||
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
|
||||
// loopback/private addresses smuggled in via an IPv6 encoding, then
|
||||
// re-check loopback/unspecified.
|
||||
let ip = match ip {
|
||||
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
|
||||
v4 => v4,
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
|
||||
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
|
||||
// 0.0.0.0 itself is already caught by the is_unspecified check).
|
||||
if v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|
||||
|| o[0] == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
|
||||
// (site-local, deprecated by RFC 3879; the link-local mask does
|
||||
// not cover it).
|
||||
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] & 0xffc0) == 0xfec0 {
|
||||
return false;
|
||||
}
|
||||
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
|
||||
// decodable well-known /96 — e.g. the RFC 8215 local-use
|
||||
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
|
||||
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
|
||||
// Rather than risk a wrong decode across every prefix length we
|
||||
// reject the whole NAT64 space here. The well-known /96 was already
|
||||
// folded to its v4 above and never reaches this arm.
|
||||
if s[0] == 0x0064 && s[1] == 0xff9b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Mirror-upstream variant of [`is_public_ip`]: in addition to the shared
|
||||
/// peer classification, rejects IANA special-purpose ranges that are
|
||||
/// non-global, locally meaningful, or unsafe as owner-controlled outbound
|
||||
/// fetch endpoints. For IP-literal upstreams, configure-time URL validation
|
||||
/// and the fetch-time egress policy share this classifier, so a stored
|
||||
/// literal can never fail the worker it is scheduled for. Hostname upstreams
|
||||
/// are only shape-checked at configure time: each fetch resolves DNS and
|
||||
/// applies this classifier to the answers, so a hostname whose answers turn
|
||||
/// non-public still fails at fetch time.
|
||||
pub(crate) fn mirror_upstream_ip_permitted(ip: std::net::IpAddr) -> bool {
|
||||
if !is_public_ip(ip) {
|
||||
return false;
|
||||
}
|
||||
let classified = match ip {
|
||||
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
|
||||
_ => ip,
|
||||
};
|
||||
match classified {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let [a, b, c, _] = v4.octets();
|
||||
// IANA special-purpose ranges that are not globally reachable but
|
||||
// may be routed inside labs, clouds, or operator networks. They are
|
||||
// unsuitable for an owner-controlled outbound fetch even though
|
||||
// `Ipv4Addr::is_private` does not classify them as RFC1918.
|
||||
!(a >= 224
|
||||
|| (a == 192 && b == 0 && c == 0)
|
||||
|| (a == 192 && b == 0 && c == 2)
|
||||
|| (a == 192 && b == 88 && c == 99)
|
||||
|| (a == 198 && matches!(b, 18 | 19))
|
||||
|| (a == 198 && b == 51 && c == 100)
|
||||
|| (a == 203 && b == 0 && c == 113))
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// IANA special-purpose ranges that are non-global, locally
|
||||
// meaningful, or unsafe to treat as ordinary forge endpoints.
|
||||
// Reject the complete IETF protocol-assignment /23 rather than
|
||||
// trying to maintain its narrow globally reachable exceptions;
|
||||
// a public forge has no reason to depend on those anycast ranges.
|
||||
!(v6.is_multicast()
|
||||
|| (s[0] & 0xffc0) == 0xfec0
|
||||
|| (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0)
|
||||
|| (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1)
|
||||
|| (s[0] == 0x2001 && (s[1] & 0xfe00) == 0)
|
||||
|| (s[0] == 0x2001 && s[1] == 0x0db8)
|
||||
|| (s[0] == 0x3fff && (s[1] & 0xf000) == 0)
|
||||
|| s[0] == 0x5f00)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
|
||||
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
|
||||
/// and `localhost` / `.localhost` / `.local` / `.internal` hostnames. Used at
|
||||
/// announce time and by the boot-time prune of already-poisoned rows.
|
||||
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
|
||||
/// and by the boot-time prune of already-poisoned rows.
|
||||
pub fn is_public_http_url(raw: &str) -> bool {
|
||||
let url = match reqwest::Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
@@ -237,7 +125,6 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
host.truncate(host.trim_end_matches('.').len());
|
||||
if host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
{
|
||||
@@ -247,9 +134,60 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
// before parsing as an IP.
|
||||
let ip_candidate = host.trim_start_matches('[').trim_end_matches(']');
|
||||
if let Ok(ip) = ip_candidate.parse::<std::net::IpAddr>() {
|
||||
// IP literals share the single DNS/literal classification in
|
||||
// `is_public_ip`; hostname-only rules stay local to this function.
|
||||
return is_public_ip(ip);
|
||||
// Reject loopback/unspecified on the literal as given — catches `::1`
|
||||
// and `::` before the IPv4-folding below (`::1`.to_ipv4() would
|
||||
// otherwise map to a non-loopback `0.0.0.1`).
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
|
||||
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
|
||||
// loopback/private addresses smuggled in via an IPv6 encoding, then
|
||||
// re-check loopback/unspecified.
|
||||
let ip = match ip {
|
||||
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
|
||||
v4 => v4,
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
|
||||
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
|
||||
// 0.0.0.0 itself is already caught by the is_unspecified check).
|
||||
if v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|
||||
|| o[0] == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// fc00::/7 (unique-local), fe80::/10 (link-local), or fec0::/10
|
||||
// (site-local, deprecated by RFC 3879 and so never a legitimate
|
||||
// peer address, but still routable on networks that kept it).
|
||||
if (s[0] & 0xfe00) == 0xfc00
|
||||
|| (s[0] & 0xffc0) == 0xfe80
|
||||
|| (s[0] & 0xffc0) == 0xfec0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
|
||||
// decodable well-known /96 — e.g. the RFC 8215 local-use
|
||||
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
|
||||
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
|
||||
// Rather than risk a wrong decode across every prefix length we
|
||||
// reject the whole NAT64 space here. The well-known /96 was
|
||||
// already folded to its v4 above and never reaches this arm.
|
||||
if s[0] == 0x0064 && s[1] == 0xff9b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !host.contains('.') {
|
||||
// A name with no dot at all. The `.local` / `.internal` rules above are
|
||||
// suffix checks, so a single label slips both, and the resolver decides
|
||||
@@ -322,9 +260,9 @@ pub async fn announce(
|
||||
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
|
||||
// the real peers under junk so node-origin repos stop replicating.
|
||||
if !is_public_http_url(&req.http_url) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"http_url {PUBLIC_HTTP_URL_REQUIREMENT}"
|
||||
)));
|
||||
return Err(AppError::BadRequest(
|
||||
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject self-announcements: a peer row whose http_url is our own public
|
||||
@@ -603,7 +541,6 @@ mod tests {
|
||||
fn rejects_loopback_private_and_internal() {
|
||||
for bad in [
|
||||
"http://localhost:7545",
|
||||
"http://node.localhost:7545",
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://localhost:22/",
|
||||
"http://0.0.0.0:7545",
|
||||
@@ -675,8 +612,6 @@ mod tests {
|
||||
fn rejects_deprecated_site_local_v6() {
|
||||
// fec0::/10, deprecated by RFC 3879. The link-local mask (0xffc0 ==
|
||||
// 0xfe80) does not cover it: 0xfec0 & 0xffc0 is 0xfec0.
|
||||
assert!(!super::is_public_ip("fec0::1".parse().unwrap()));
|
||||
assert!(!super::is_public_ip("feff:ffff::1".parse().unwrap()));
|
||||
for bad in ["http://[fec0::1]/", "http://[feff:ffff::1]:7545"] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
@@ -685,14 +620,11 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_repeated_trailing_root_dots() {
|
||||
// Stripping a single root dot leaves `localhost.`, which matches
|
||||
// neither the equality nor the suffix check. The subdomain form now
|
||||
// exercises the `.localhost` suffix rejection together with the extra
|
||||
// trailing dot.
|
||||
for bad in [
|
||||
"http://localhost../",
|
||||
"http://localhost...:7545",
|
||||
"http://node.localhost../",
|
||||
] {
|
||||
// neither the equality nor the suffix check. The subdomain form
|
||||
// (`node.localhost..`) is not asserted here: `.localhost` is not yet a
|
||||
// rejected suffix on this base, so that case belongs to whichever
|
||||
// change adds it rather than to this one.
|
||||
for bad in ["http://localhost../", "http://localhost...:7545"] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
@@ -1650,31 +1582,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn announce_rejects_localhost_subdomains_with_an_accurate_error(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(
|
||||
&did,
|
||||
&announce_body(&did, "https://node.localhost:7545"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, error, message) = status_and_error(resp).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error, "bad_request");
|
||||
assert_eq!(
|
||||
message,
|
||||
"http_url must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, &did).await.is_none(),
|
||||
"a rejected .localhost announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenario 1, and the first test the keyid branch has ever had: a caller
|
||||
/// who proved control of one DID must not announce another's. Kills
|
||||
/// neutralizing the handler's keyid comparison, and kills demoting this
|
||||
|
||||
+272
-3832
File diff suppressed because it is too large.
Load diff
@@ -1,782 +0,0 @@
|
||||
//! Opaque, integrity-protected continuation tokens for the task read surfaces.
|
||||
//!
|
||||
//! The task list pages by keyset over `(created_at, id)`, but the rows a
|
||||
//! caller may *see* are a filtered subset of the rows the query has to
|
||||
//! *examine*. Handing the caller a raw `(created_at, id)` cursor therefore
|
||||
//! forced a choice between two broken options (#327 review): anchor the cursor
|
||||
//! on the last visible row, and a window of denied tasks longer than the scan
|
||||
//! budget stalls paging forever; or anchor it on the last examined row, and a
|
||||
//! denied read leaks the id and timestamp of a task `GET /tasks/{id}` other-
|
||||
//! wise 404s.
|
||||
//!
|
||||
//! A server-issued token removes the choice. The position it carries is the
|
||||
//! last *examined* candidate, so paging always advances, and the payload is
|
||||
//! opaque and MAC'd, so the caller learns nothing from it and cannot forge one
|
||||
//! naming a row of their choosing.
|
||||
//!
|
||||
//! The position must be *confidential*, not merely unforgeable: a token that
|
||||
//! merely signed a base64 payload would still let its holder read the id and
|
||||
//! timestamp of the denied row it names, which is the disclosure the token
|
||||
//! exists to prevent. The payload is therefore encrypted under a
|
||||
//! synthetic-IV construction (SIV): the tag is an HMAC over the filter and the
|
||||
//! plaintext, and it doubles as the IV seeding the keystream the plaintext is
|
||||
//! XORed with. That needs no randomness source and no dependency beyond the
|
||||
//! `hmac`/`sha2` pair already used for webhook signatures and blob recipient
|
||||
//! tags, and it is decrypt-last: nothing is parsed until the tag verifies.
|
||||
//!
|
||||
//! Making the token the *only* accepted cursor also fixes the ordering-domain
|
||||
//! bug the same review found. `agent_tasks.created_at` is TEXT and compared as
|
||||
//! TEXT, so `...Z` and `...+00:00` denote one instant but sort differently. A
|
||||
//! caller-typed timestamp could silently skip or repeat same-time rows. The
|
||||
//! token instead carries the stored string verbatim, so the value compared is
|
||||
//! always a value the server wrote.
|
||||
//!
|
||||
//! A token is bound to the *caller* as well as the filter. The position it
|
||||
//! names is the last examined candidate, not the last visible one, so it
|
||||
//! encodes how far a scan got under one caller's visibility. Resuming it as a
|
||||
//! different caller would start the scan past rows that caller is entitled to
|
||||
//! read, silently dropping them from the answer (#327 review). The presenting
|
||||
//! caller's normalized DID (empty and flagged absent when anonymous) is
|
||||
//! therefore part of the MAC input, so a token presented by anyone else fails
|
||||
//! to verify rather than under-reporting.
|
||||
//!
|
||||
//! Wire form: `v1.<iv>.<body>`, both parts base64url unpadded: the 16-byte
|
||||
//! synthetic IV (the truncated MAC, which also seeds the keystream) and the
|
||||
//! padded, encrypted JSON payload.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
const CURSOR_PREFIX: &str = "v1";
|
||||
const KEY_DERIVATION_LABEL: &[u8] = b"gitlawb/tasks-cursor-key/v1";
|
||||
const TAG_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-tag/v1";
|
||||
const STREAM_DOMAIN: &[u8] = b"gitlawb/tasks-cursor-stream/v1";
|
||||
/// Truncated MAC length, and the synthetic IV width. 128 bits is far beyond
|
||||
/// forgery reach for a token that carries no authority of its own (every page
|
||||
/// re-runs the visibility gate against the presenting caller), and keeps the
|
||||
/// token short enough to sit in a query string.
|
||||
const TAG_LEN: usize = 16;
|
||||
|
||||
/// Padding bucket for the serialized cursor payload, in bytes. The keystream
|
||||
/// XORs the plaintext byte for byte, so token length would otherwise track
|
||||
/// payload length and leak the named row's field widths to a holder who
|
||||
/// cannot decrypt anything. 128 comfortably covers the widest realistic
|
||||
/// payload: a 35-char RFC3339 timestamp (`to_rfc3339()` renders 29 chars at
|
||||
/// millisecond precision and 32 at microsecond, 35 only with all nine
|
||||
/// fractional digits), a 36-char server-minted UUID, and the fixed JSON
|
||||
/// envelope plus a 10-digit expiry, which together total 101 bytes. Should a future payload ever exceed the bucket, the length
|
||||
/// rounds up to the next multiple instead of failing, so `encode` never
|
||||
/// breaks — it just mints a longer token.
|
||||
const PAYLOAD_BUCKET: usize = 128;
|
||||
|
||||
/// How long an issued cursor stays acceptable. Keyset positions never go stale
|
||||
/// on their own — `created_at`/`id` are immutable — so this is not a
|
||||
/// correctness bound. It bounds how long a token stays valid across a node
|
||||
/// restart-and-rotate and keeps an abandoned page from being resumed
|
||||
/// indefinitely.
|
||||
const CURSOR_TTL_SECS: i64 = 24 * 60 * 60;
|
||||
|
||||
/// Node-keyed MAC key for continuation tokens, derived from the node keypair
|
||||
/// seed so it needs no configuration and survives restarts of the same node.
|
||||
/// Derived rather than used directly so a token forgery oracle could not
|
||||
/// bear on the signing key itself.
|
||||
#[derive(Clone)]
|
||||
pub struct TaskCursorKey([u8; 32]);
|
||||
|
||||
impl std::fmt::Debug for TaskCursorKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("TaskCursorKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskCursorKey {
|
||||
pub fn derive(node_seed: &[u8; 32]) -> Self {
|
||||
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
|
||||
mac.update(KEY_DERIVATION_LABEL);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&mac.finalize().into_bytes());
|
||||
Self(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyset position a token carries: the last candidate row the previous
|
||||
/// request examined, whether or not the caller was allowed to see it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TaskPosition {
|
||||
pub created_at: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl TaskPosition {
|
||||
pub fn new(created_at: impl Into<String>, id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
created_at: created_at.into(),
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_pair(&self) -> (&str, &str) {
|
||||
(self.created_at.as_str(), self.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CursorPayload<'a> {
|
||||
/// `created_at` of the last examined candidate, verbatim as stored.
|
||||
t: &'a str,
|
||||
/// `id` of the last examined candidate.
|
||||
i: &'a str,
|
||||
/// Unix expiry.
|
||||
e: i64,
|
||||
}
|
||||
|
||||
/// The filter a page was issued under. A cursor is only meaningful against the
|
||||
/// same `status`/`assignee_did` filter that produced it: resuming a filtered
|
||||
/// scan from an unfiltered position (or the reverse) silently skips rows.
|
||||
/// Bound into the MAC rather than stored in the payload, so it costs no token
|
||||
/// length and cannot be edited without invalidating the tag.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TaskFilter<'a> {
|
||||
pub status: Option<&'a str>,
|
||||
pub assignee_did: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// The visibility identity a token was issued to. Normalized through
|
||||
/// `normalize_owner_key` so the two spellings of one `did:key` identity
|
||||
/// (`did:key:X` and bare `X`) bind identically, matching `did_matches` on the
|
||||
/// read path: a caller who authenticates with the other spelling of their own
|
||||
/// DID must be able to resume their own page.
|
||||
fn caller_binding(caller: Option<&str>) -> &str {
|
||||
caller.map(crate::db::normalize_owner_key).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The assignee filter a token was issued under. Normalized through
|
||||
/// `normalize_owner_key` so the two spellings of one `did:key` identity
|
||||
/// (`did:key:X` and bare `X`) bind identically, matching `list_tasks_keyset`
|
||||
/// in SQL: minting under `did:key:X` and resuming with bare `X` (or the reverse)
|
||||
/// hits the same rows and must verify under the MAC.
|
||||
fn assignee_binding(assignee: Option<&str>) -> &str {
|
||||
assignee.map(crate::db::normalize_owner_key).unwrap_or("")
|
||||
}
|
||||
|
||||
fn cursor_mac(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
plaintext: &[u8],
|
||||
) -> HmacSha256 {
|
||||
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
|
||||
mac.update(TAG_DOMAIN);
|
||||
// Length-prefix every field so no two distinct filter/caller/plaintext
|
||||
// tuples can produce the same MAC input.
|
||||
for field in [
|
||||
filter.status.unwrap_or("").as_bytes(),
|
||||
assignee_binding(filter.assignee_did).as_bytes(),
|
||||
caller_binding(caller).as_bytes(),
|
||||
plaintext,
|
||||
] {
|
||||
mac.update(&(field.len() as u64).to_be_bytes());
|
||||
mac.update(field);
|
||||
}
|
||||
// Presence is distinct from emptiness for all three optional fields, so an
|
||||
// anonymous token cannot collide with one issued to a caller whose
|
||||
// normalized DID is the empty string.
|
||||
mac.update(&[
|
||||
u8::from(filter.status.is_some()),
|
||||
u8::from(filter.assignee_did.is_some()),
|
||||
u8::from(caller.is_some()),
|
||||
]);
|
||||
mac
|
||||
}
|
||||
|
||||
/// Synthetic IV: the authentication tag over the filter and plaintext, which
|
||||
/// also seeds the keystream. Deterministic by construction, so no randomness
|
||||
/// source is needed and two tokens for the same page are byte-identical.
|
||||
fn siv(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
plaintext: &[u8],
|
||||
) -> [u8; TAG_LEN] {
|
||||
let mut out = [0u8; TAG_LEN];
|
||||
out.copy_from_slice(
|
||||
&cursor_mac(key, filter, caller, plaintext)
|
||||
.finalize()
|
||||
.into_bytes()[..TAG_LEN],
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// XOR `buf` with the keystream for `iv`. Its own inverse, so encrypt and
|
||||
/// decrypt are the same call.
|
||||
fn apply_keystream(key: &TaskCursorKey, iv: &[u8; TAG_LEN], buf: &mut [u8]) {
|
||||
for (block_index, chunk) in buf.chunks_mut(32).enumerate() {
|
||||
let mut mac = HmacSha256::new_from_slice(&key.0).expect("HMAC accepts any key length");
|
||||
mac.update(STREAM_DOMAIN);
|
||||
mac.update(iv);
|
||||
mac.update(&(block_index as u64).to_be_bytes());
|
||||
let block = mac.finalize().into_bytes();
|
||||
for (byte, k) in chunk.iter_mut().zip(block.iter()) {
|
||||
*byte ^= k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a token resuming at `position` for `filter`, usable only by `caller`.
|
||||
///
|
||||
/// The serialized payload is padded with trailing whitespace to a fixed
|
||||
/// bucket before the SIV tag is computed: the keystream preserves plaintext
|
||||
/// length, so an unpadded payload would let the token's length leak the
|
||||
/// named row's field widths (a 25-char `to_rfc3339()` rendering mints a
|
||||
/// shorter token than a 35-char one). `decode` tolerates the padding because
|
||||
/// `serde_json` ignores trailing whitespace, and the tag covers the padded
|
||||
/// bytes, so a token minted before padding landed still verifies.
|
||||
pub fn encode(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
position: &TaskPosition,
|
||||
) -> String {
|
||||
let mut payload = serde_json::to_vec(&CursorPayload {
|
||||
t: &position.created_at,
|
||||
i: &position.id,
|
||||
e: chrono::Utc::now().timestamp() + CURSOR_TTL_SECS,
|
||||
})
|
||||
.expect("cursor payload is plain strings and an integer");
|
||||
let padded_len = payload.len().next_multiple_of(PAYLOAD_BUCKET);
|
||||
payload.resize(padded_len, b' ');
|
||||
let iv = siv(key, filter, caller, &payload);
|
||||
apply_keystream(key, &iv, &mut payload);
|
||||
format!(
|
||||
"{CURSOR_PREFIX}.{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(iv),
|
||||
URL_SAFE_NO_PAD.encode(&payload)
|
||||
)
|
||||
}
|
||||
|
||||
/// One rejection message for every way a token can fail to verify. A caller
|
||||
/// who mangled a token, replayed an expired one, or tried to move one to a
|
||||
/// different filter or a different presenting identity learns only that the
|
||||
/// cursor is not usable — never which of those it was, and never anything
|
||||
/// about the row it named.
|
||||
const INVALID_CURSOR: &str = "invalid or expired cursor";
|
||||
|
||||
fn reject() -> AppError {
|
||||
AppError::BadRequest(INVALID_CURSOR.into())
|
||||
}
|
||||
|
||||
/// Verify a token against `filter` and the presenting `caller`, and return the
|
||||
/// position it carries.
|
||||
pub fn decode(
|
||||
key: &TaskCursorKey,
|
||||
filter: TaskFilter<'_>,
|
||||
caller: Option<&str>,
|
||||
token: &str,
|
||||
) -> crate::error::Result<TaskPosition> {
|
||||
let mut parts = token.split('.');
|
||||
let (Some(version), Some(iv_b64), Some(body_b64), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return Err(reject());
|
||||
};
|
||||
if version != CURSOR_PREFIX {
|
||||
return Err(reject());
|
||||
}
|
||||
let iv_bytes = URL_SAFE_NO_PAD.decode(iv_b64).map_err(|_| reject())?;
|
||||
let mut plaintext = URL_SAFE_NO_PAD.decode(body_b64).map_err(|_| reject())?;
|
||||
let iv: [u8; TAG_LEN] = iv_bytes.try_into().map_err(|_| reject())?;
|
||||
|
||||
apply_keystream(key, &iv, &mut plaintext);
|
||||
// Authenticate before parsing: until the tag matches, `plaintext` is just
|
||||
// attacker-chosen bytes run through a keystream. `verify_truncated_left`
|
||||
// is the constant-time compare, so a forgery attempt cannot be steered by
|
||||
// timing the first differing byte.
|
||||
cursor_mac(key, filter, caller, &plaintext)
|
||||
.verify_truncated_left(&iv)
|
||||
.map_err(|_| reject())?;
|
||||
|
||||
let decoded: CursorPayload<'_> = serde_json::from_slice(&plaintext).map_err(|_| reject())?;
|
||||
if decoded.e < chrono::Utc::now().timestamp() {
|
||||
return Err(reject());
|
||||
}
|
||||
Ok(TaskPosition::new(decoded.t, decoded.i))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> TaskCursorKey {
|
||||
TaskCursorKey::derive(&[7u8; 32])
|
||||
}
|
||||
|
||||
fn unfiltered() -> TaskFilter<'static> {
|
||||
TaskFilter {
|
||||
status: None,
|
||||
assignee_did: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_position_verbatim() {
|
||||
let k = key();
|
||||
// A stored timestamp the server wrote, fractional digits and all.
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), None, &pos);
|
||||
assert_eq!(decode(&k, unfiltered(), None, &token).unwrap(), pos);
|
||||
// A sub-bucket payload pads to exactly one bucket: the padding
|
||||
// claim is an exact width, and every other width assertion here is
|
||||
// bucket-relative, so only an absolute check catches a pad that
|
||||
// skips "next multiple" or a drifted bucket constant.
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
assert_eq!(
|
||||
raw.len(),
|
||||
PAYLOAD_BUCKET,
|
||||
"normal token must pad to exactly one bucket"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the token is that the caller may hold it without
|
||||
/// learning the denied row it names. A signed-but-plaintext payload would
|
||||
/// pass every other test here and still fail this one.
|
||||
#[test]
|
||||
fn token_does_not_expose_the_row_it_names() {
|
||||
let k = key();
|
||||
let token = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00+00:00", "denied-task-id"),
|
||||
);
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
let as_text = String::from_utf8_lossy(&raw);
|
||||
for secret in ["denied-task-id", "2026-01-03", "\"t\"", "\"i\""] {
|
||||
assert!(
|
||||
!as_text.contains(secret),
|
||||
"token body must not carry {secret:?} in the clear: {as_text:?}"
|
||||
);
|
||||
}
|
||||
// Also assert it is not merely reordered or whitespace-mangled JSON.
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&raw).is_err(),
|
||||
"token body must not be parseable as JSON"
|
||||
);
|
||||
}
|
||||
|
||||
/// The keystream preserves plaintext length, so without the fixed-width
|
||||
/// padding a token naming a 35-char timestamp would be measurably longer
|
||||
/// than one naming a 32-char one: the holder would learn the withheld
|
||||
/// row's field widths without decrypting anything. Mint the same shape
|
||||
/// both ways and require identical token length.
|
||||
#[test]
|
||||
fn token_length_does_not_vary_with_position_width() {
|
||||
let k = key();
|
||||
// Ids are server-minted fixed-length UUIDs; the timestamp is the
|
||||
// width that varies in production (`to_rfc3339()` renders 35 chars
|
||||
// with nanoseconds, 25 without fractional seconds).
|
||||
let id = "b3f7a1c2-4d5e-4f6a-8b9c-0d1e2f3a4b5c";
|
||||
let narrow = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00+00:00", id),
|
||||
);
|
||||
let wide = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id),
|
||||
);
|
||||
assert_eq!(
|
||||
narrow.len(),
|
||||
wide.len(),
|
||||
"tokens must not leak the named row's field widths: {narrow} vs {wide}"
|
||||
);
|
||||
// The padded token still decodes to the exact stored string.
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), None, &wide).unwrap(),
|
||||
TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", id)
|
||||
);
|
||||
}
|
||||
|
||||
/// A token minted before the fixed-width padding landed (unpadded payload,
|
||||
/// tag over the raw bytes) must still verify: `decode` MACs whatever
|
||||
/// plaintext the keystream recovers and `serde_json` tolerates the
|
||||
/// missing trailing whitespace. This pins the back-compat claim in the
|
||||
/// `encode` docstring with a frozen v1 artifact, so a change to the v1
|
||||
/// recipe pins (domains, MAC input, keystream, segment layout, field
|
||||
/// names) breaks this test instead of silently re-deriving under the
|
||||
/// new internals. It does not pin the padding: `decode` never consults
|
||||
/// `PAYLOAD_BUCKET` and the fixture is unpadded, so the padding-width
|
||||
/// claim belongs to `oversized_payload_rounds_up_to_next_bucket`.
|
||||
///
|
||||
/// Fixture expiry is fixed at 2100-01-01 (`4102444800`): it must stay in
|
||||
/// the future whenever the test runs.
|
||||
#[test]
|
||||
fn unpadded_legacy_token_still_verifies() {
|
||||
let k = key();
|
||||
let filter = unfiltered();
|
||||
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", "legacy-task");
|
||||
// Pre-padding recipe minted once and frozen: serialize with no
|
||||
// padding, tag, and encrypt. Deterministic by construction (SIV needs
|
||||
// no randomness), so this literal is stable across runs.
|
||||
let token = "v1.ILJyy7JZScm_h-HM1-CggA.l4kJUoHTbqSGTVKL0DP4hORnbOS1_gHYV2fwJYXd2QtM-6TBMCKh-wvluIiaEYC9rUSUPVSteAKkp7jBCmK_bR2D";
|
||||
assert_eq!(
|
||||
decode(&k, filter, None, token).unwrap(),
|
||||
position,
|
||||
"pre-padding token must verify under the current decode"
|
||||
);
|
||||
// The fixture is a genuine pre-padding artifact, so its decoded
|
||||
// body must stay below one bucket: a literal re-minted under the
|
||||
// padded recipe would retire this back-compat pin silently.
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
assert!(
|
||||
raw.len() < PAYLOAD_BUCKET,
|
||||
"legacy fixture must be the unpadded shape: {} bytes",
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The `PAYLOAD_BUCKET` docstring promises an oversized payload rounds up
|
||||
/// to the next multiple instead of failing. Mint a position whose id
|
||||
/// pushes the serialized payload past one bucket and require the token
|
||||
/// to round-trip (at two buckets).
|
||||
#[test]
|
||||
fn oversized_payload_rounds_up_to_next_bucket() {
|
||||
let k = key();
|
||||
// ~140-char id: payload crosses the 128-byte bucket.
|
||||
let long_id = "a".repeat(140);
|
||||
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", long_id.clone());
|
||||
let probe = serde_json::to_vec(&CursorPayload {
|
||||
t: &position.created_at,
|
||||
i: &position.id,
|
||||
e: 1_700_000_000,
|
||||
})
|
||||
.expect("cursor payload is plain strings and an integer");
|
||||
assert!(
|
||||
probe.len() > PAYLOAD_BUCKET,
|
||||
"fixture payload must exceed one bucket: {} bytes",
|
||||
probe.len()
|
||||
);
|
||||
assert!(
|
||||
probe.len() <= 2 * PAYLOAD_BUCKET,
|
||||
"fixture payload must fit in two buckets: {} bytes",
|
||||
probe.len()
|
||||
);
|
||||
let token = encode(&k, unfiltered(), None, &position);
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), None, &token).unwrap(),
|
||||
position,
|
||||
"oversized payload must round-trip at two buckets"
|
||||
);
|
||||
// The padded body must be exactly two buckets, not merely longer
|
||||
// than one: the padding claim is an exact width, not a direction.
|
||||
let body = token.split('.').nth(2).expect("token has a body part");
|
||||
let raw = URL_SAFE_NO_PAD.decode(body).unwrap();
|
||||
assert_eq!(
|
||||
raw.len(),
|
||||
2 * PAYLOAD_BUCKET,
|
||||
"oversized payload must pad to exactly two buckets"
|
||||
);
|
||||
}
|
||||
|
||||
/// Callers paste the token straight into a query string, so it must carry
|
||||
/// no character that needs percent-encoding.
|
||||
#[test]
|
||||
fn token_is_url_safe_verbatim() {
|
||||
let token = encode(
|
||||
&key(),
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("did:key:z6MkAssignee"),
|
||||
},
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00.123456789+00:00", "task-a"),
|
||||
);
|
||||
assert!(
|
||||
token
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')),
|
||||
"token must be query-safe without encoding: {token}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Flipping any byte of a token must fail the tag, not decode to a
|
||||
/// different position: the keystream is malleable on its own, the tag is
|
||||
/// what stops a chosen-position forgery.
|
||||
#[test]
|
||||
fn rejects_any_tampered_byte() {
|
||||
let k = key();
|
||||
let token = encode(
|
||||
&k,
|
||||
unfiltered(),
|
||||
None,
|
||||
&TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a"),
|
||||
);
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
for part in [1usize, 2] {
|
||||
for position in [0usize, 3] {
|
||||
let mut bytes = parts[part].as_bytes().to_vec();
|
||||
bytes[position] = if bytes[position] == b'A' { b'B' } else { b'A' };
|
||||
let mut mangled: Vec<String> = parts.iter().map(|p| p.to_string()).collect();
|
||||
mangled[part] = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
decode(&k, unfiltered(), None, &mangled.join(".")).is_err(),
|
||||
"byte {position} of part {part} must not be malleable"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Sanity: the untouched token still decodes, so the loop above is not
|
||||
// passing because every token is rejected.
|
||||
assert!(decode(&k, unfiltered(), None, &token).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_token_from_another_node() {
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&TaskCursorKey::derive(&[1u8; 32]), unfiltered(), None, &pos);
|
||||
assert!(decode(
|
||||
&TaskCursorKey::derive(&[2u8; 32]),
|
||||
unfiltered(),
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cursor_moved_to_a_different_filter() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: None,
|
||||
},
|
||||
None,
|
||||
&pos,
|
||||
);
|
||||
assert!(decode(&k, unfiltered(), None, &token).is_err());
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("claimed"),
|
||||
assignee_did: None
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: None
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
/// A filter of `Some("")` must not verify a token minted with `None`.
|
||||
#[test]
|
||||
fn distinguishes_absent_filter_from_empty_filter() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), None, &pos);
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some(""),
|
||||
assignee_did: None
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// A cursor names how far a scan got under one caller's visibility. Moved
|
||||
/// to a different presenting identity it would start that caller's scan
|
||||
/// past rows they are entitled to read, so it must fail to verify rather
|
||||
/// than silently under-report (#327 review).
|
||||
#[test]
|
||||
fn rejects_cursor_moved_to_a_different_caller() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let alice = Some("did:key:z6MkAlice");
|
||||
let bob = Some("did:key:z6MkBob");
|
||||
|
||||
let anon_token = encode(&k, unfiltered(), None, &pos);
|
||||
assert!(
|
||||
decode(&k, unfiltered(), alice, &anon_token).is_err(),
|
||||
"an anonymously minted cursor must not resume an authenticated scan"
|
||||
);
|
||||
assert!(decode(&k, unfiltered(), None, &anon_token).is_ok());
|
||||
|
||||
let alice_token = encode(&k, unfiltered(), alice, &pos);
|
||||
assert!(
|
||||
decode(&k, unfiltered(), bob, &alice_token).is_err(),
|
||||
"one caller's cursor must not resume another caller's scan"
|
||||
);
|
||||
assert!(
|
||||
decode(&k, unfiltered(), None, &alice_token).is_err(),
|
||||
"an authenticated cursor must not resume an anonymous scan"
|
||||
);
|
||||
assert!(decode(&k, unfiltered(), alice, &alice_token).is_ok());
|
||||
}
|
||||
|
||||
/// The binding is on identity, not spelling: `did:key:X` and bare `X` are
|
||||
/// one caller everywhere else on the read path (`did_matches`), so a caller
|
||||
/// who presents the other form of their own DID must keep their own page.
|
||||
#[test]
|
||||
fn caller_binding_is_normalized_across_did_key_spellings() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), Some("did:key:z6MkAlice"), &pos);
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), Some("z6MkAlice"), &token).unwrap(),
|
||||
pos
|
||||
);
|
||||
// A different method sharing the base58 tail is a different principal.
|
||||
assert!(decode(&k, unfiltered(), Some("did:web:z6MkAlice"), &token).is_err());
|
||||
}
|
||||
|
||||
/// The filter binding is on identity, not spelling: `assignee_did` matches
|
||||
/// normalized keys in SQL (`normalize_owner_key`), so minting under
|
||||
/// `did:key:X` and resuming under bare `X` (or vice versa) must succeed,
|
||||
/// while `did:web:X` remains distinct.
|
||||
#[test]
|
||||
fn assignee_filter_binding_is_normalized_across_did_key_spellings() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let filter_did_key = TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("did:key:z6MkAssignee"),
|
||||
};
|
||||
let filter_bare = TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("z6MkAssignee"),
|
||||
};
|
||||
let filter_web = TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("did:web:z6MkAssignee"),
|
||||
};
|
||||
|
||||
let token_from_did_key = encode(&k, filter_did_key, None, &pos);
|
||||
assert_eq!(
|
||||
decode(&k, filter_bare, None, &token_from_did_key).unwrap(),
|
||||
pos
|
||||
);
|
||||
assert!(decode(&k, filter_web, None, &token_from_did_key).is_err());
|
||||
|
||||
let token_from_bare = encode(&k, filter_bare, None, &pos);
|
||||
assert_eq!(
|
||||
decode(&k, filter_did_key, None, &token_from_bare).unwrap(),
|
||||
pos
|
||||
);
|
||||
assert!(decode(&k, filter_web, None, &token_from_bare).is_err());
|
||||
}
|
||||
|
||||
/// Anonymous is a distinct binding from a caller whose normalized DID is
|
||||
/// the empty string, the same way `Some("")` is distinct from `None` for
|
||||
/// the filter fields.
|
||||
#[test]
|
||||
fn distinguishes_anonymous_from_empty_caller() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(&k, unfiltered(), None, &pos);
|
||||
assert!(decode(&k, unfiltered(), Some(""), &token).is_err());
|
||||
}
|
||||
|
||||
/// Length-prefixing must stop a status/assignee pair from being re-split.
|
||||
#[test]
|
||||
fn rejects_field_boundary_shift() {
|
||||
let k = key();
|
||||
let pos = TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a");
|
||||
let token = encode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pend"),
|
||||
assignee_did: Some("ing"),
|
||||
},
|
||||
None,
|
||||
&pos,
|
||||
);
|
||||
assert!(decode(
|
||||
&k,
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: Some("")
|
||||
},
|
||||
None,
|
||||
&token
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired_token() {
|
||||
let k = key();
|
||||
// Minted the same way `encode` does, but already past its expiry.
|
||||
let mut payload = serde_json::to_vec(&CursorPayload {
|
||||
t: "2026-01-03T00:00:00+00:00",
|
||||
i: "task-a",
|
||||
e: chrono::Utc::now().timestamp() - 1,
|
||||
})
|
||||
.unwrap();
|
||||
let iv = siv(&k, unfiltered(), None, &payload);
|
||||
apply_keystream(&k, &iv, &mut payload);
|
||||
let expired = format!(
|
||||
"v1.{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(iv),
|
||||
URL_SAFE_NO_PAD.encode(&payload)
|
||||
);
|
||||
let err = decode(&k, unfiltered(), None, &expired).unwrap_err();
|
||||
assert!(err.to_string().contains(INVALID_CURSOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_shapes() {
|
||||
let k = key();
|
||||
for bad in [
|
||||
"",
|
||||
"v1",
|
||||
"v1.",
|
||||
"v1.abc",
|
||||
"v2.abc.def",
|
||||
"v1.abc.def.ghi",
|
||||
"v1.!!!.def",
|
||||
] {
|
||||
assert!(
|
||||
decode(&k, unfiltered(), None, bad).is_err(),
|
||||
"must reject {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every rejection path renders the same text, so a caller cannot tell a
|
||||
/// forged token from an expired one from a filter mismatch.
|
||||
#[test]
|
||||
fn every_rejection_is_indistinguishable() {
|
||||
let k = key();
|
||||
for bad in ["v1.abc.def", "not-a-cursor", "v1..", "v9.a.b"] {
|
||||
assert_eq!(
|
||||
decode(&k, unfiltered(), None, bad).unwrap_err().to_string(),
|
||||
format!("invalid request: {INVALID_CURSOR}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
-2888
File diff suppressed because it is too large.
Load diff
@@ -46,10 +46,9 @@ pub async fn create_webhook(
|
||||
// endpoints (SSRF). Delivery runs on the shared no-redirect client
|
||||
// (main.rs), which closes the 3xx-to-internal bounce.
|
||||
if !crate::api::peers::is_public_http_url(&req.url) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"webhook URL {}",
|
||||
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
|
||||
)));
|
||||
return Err(AppError::BadRequest(
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
|
||||
@@ -142,19 +141,7 @@ pub async fn delete_webhook(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{create_webhook, CreateWebhookRequest};
|
||||
use crate::api::peers::{is_public_http_url, PUBLIC_HTTP_URL_REQUIREMENT};
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::RepoRecord;
|
||||
use crate::error::AppError;
|
||||
use crate::test_support::test_state;
|
||||
use crate::api::peers::is_public_http_url;
|
||||
|
||||
// create_webhook gates req.url through is_public_http_url. Pin the exact
|
||||
// SSRF targets from issue #81 so the webhook path can never regress to the
|
||||
@@ -165,7 +152,6 @@ mod tests {
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost/",
|
||||
"http://node.localhost/",
|
||||
"http://10.0.0.5/",
|
||||
"http://[::1]/",
|
||||
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
|
||||
@@ -183,53 +169,4 @@ mod tests {
|
||||
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545/"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn webhook_localhost_rejection_uses_shared_public_url_contract(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate().did().to_string();
|
||||
let repo_id = Uuid::new_v4().to_string();
|
||||
state
|
||||
.db
|
||||
.create_repo(&RepoRecord {
|
||||
id: repo_id.clone(),
|
||||
name: "webhook-contract".to_string(),
|
||||
owner_did: owner.clone(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
disk_path: "/tmp/webhook-contract.git".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = create_webhook(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.clone())),
|
||||
Path((owner, "webhook-contract".to_string())),
|
||||
Json(CreateWebhookRequest {
|
||||
url: "https://node.localhost/hook".to_string(),
|
||||
secret: None,
|
||||
events: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(AppError::BadRequest(message)) => assert_eq!(
|
||||
message,
|
||||
format!("webhook URL {PUBLIC_HTTP_URL_REQUIREMENT}")
|
||||
),
|
||||
Err(error) => panic!("unexpected webhook rejection: {error}"),
|
||||
Ok(_) => panic!("a .localhost webhook must be rejected"),
|
||||
}
|
||||
assert!(
|
||||
state.db.list_webhooks(&repo_id).await.unwrap().is_empty(),
|
||||
"a rejected .localhost webhook must leave no row behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,74 +17,16 @@ use crate::state::AppState;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// A UCAN that passed full chain validation, with the root issuer the chain
|
||||
/// rests on. Inserted into request extensions by [`require_ucan_chain`] when
|
||||
/// `X-Ucan` is present; absent when the header is.
|
||||
///
|
||||
/// `root` is carried rather than recomputed so the chain is walked once per
|
||||
/// request. Holding this is not itself an authorization decision — a caller must
|
||||
/// still compare `root` against an identity it independently trusts, because
|
||||
/// `did:key` is self-certifying and anyone can mint a chain that verifies.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VerifiedUcan {
|
||||
pub ucan: Ucan,
|
||||
pub root: Did,
|
||||
}
|
||||
|
||||
/// Whether `caller` is authorized to push to `record`.
|
||||
///
|
||||
/// The repo owner, or a caller presenting a verified UCAN whose chain roots at
|
||||
/// that owner and which carries `git/push` for this repo.
|
||||
///
|
||||
/// `verified` is optional because `X-Ucan` is: a push carrying no token reaches
|
||||
/// the same owner-only decision it always did. The owner check is unconditional
|
||||
/// and runs first, so this can only ever turn a refusal into an acceptance,
|
||||
/// never the reverse.
|
||||
pub fn caller_authorized_to_push(
|
||||
record: &crate::db::RepoRecord,
|
||||
caller: &str,
|
||||
verified: Option<&VerifiedUcan>,
|
||||
) -> bool {
|
||||
/// 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)
|
||||
|| verified.is_some_and(|v| ucan_grants_push(record, v))
|
||||
}
|
||||
|
||||
/// Whether a verified UCAN authorizes a push to `record`.
|
||||
///
|
||||
/// Three conditions, all required:
|
||||
/// 1. The chain roots at this repo's owner. This is the trust anchor — the
|
||||
/// repo record is data the node holds independently of the token, so a
|
||||
/// self-minted chain cannot satisfy it.
|
||||
/// 2. Every link is bounded. There is no revocation path, so an unbounded link
|
||||
/// is a permanent grant.
|
||||
/// 3. Every link — the leaf and every proof behind it — carries an
|
||||
/// unconstrained push-class capability naming this repository. That is
|
||||
/// [`gitlawb_core::ucan::Ucan::chain_grants_push_to`], the same rule
|
||||
/// `gl ucan import` applies before storing a token and `git-remote-gitlawb`
|
||||
/// applies before minting an invocation, so what one boundary accepts the
|
||||
/// next does not refuse.
|
||||
///
|
||||
/// (3) walks the whole chain on purpose. Refusing a wildcard leaf alone did
|
||||
/// nothing: `is_attenuated_by` accepts a concrete child under a `*` parent, and
|
||||
/// that narrowing was exactly what the helper performed, so one owner-issued
|
||||
/// `with: "*"` proof let a delegate mint a concrete leaf for ANY repository the
|
||||
/// owner had — or created later — and both the chain check and the owner-root
|
||||
/// check passed. A delegation's scope is fixed when it is issued.
|
||||
pub fn ucan_grants_push(record: &crate::db::RepoRecord, verified: &VerifiedUcan) -> bool {
|
||||
if !crate::api::did_matches(&verified.root.to_string(), &record.owner_did) {
|
||||
return false;
|
||||
}
|
||||
// A write capability must lapse on its own. `exp` is optional in the format and
|
||||
// there is no revocation path, so a chain with an unbounded link is a permanent
|
||||
// grant: once the token leaks, the owner cannot withdraw it short of rotating
|
||||
// the DID the repo is keyed on. Refusing here is what makes "the damage window
|
||||
// is the token's expiry" a true statement rather than an aspiration.
|
||||
if !verified.ucan.chain_lifetime_is_bounded() {
|
||||
return false;
|
||||
}
|
||||
verified
|
||||
.ucan
|
||||
.chain_grants_push_to(&record.owner_did, &record.name)
|
||||
}
|
||||
|
||||
use gitlawb_core::http_sig::{
|
||||
@@ -413,7 +355,7 @@ fn validate_ucan_chain(
|
||||
token: &str,
|
||||
expected_aud: &Did,
|
||||
signer_did: &Did,
|
||||
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
let ucan = Ucan::decode(token).map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -441,14 +383,14 @@ fn validate_ucan_chain(
|
||||
)
|
||||
})?;
|
||||
|
||||
let root = ucan.verify_chain().map_err(|e| {
|
||||
ucan.verify_chain().map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(VerifiedUcan { ucan, root })
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
|
||||
@@ -501,18 +443,11 @@ pub async fn require_ucan_chain(
|
||||
}
|
||||
};
|
||||
|
||||
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
Ok(v) => v,
|
||||
Err((status, body)) => return (status, body).into_response(),
|
||||
};
|
||||
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
return (status, body).into_response();
|
||||
}
|
||||
|
||||
tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated");
|
||||
|
||||
// Park the verified token where a handler can reach it. Validation alone
|
||||
// grants nothing; the authorization decision is made downstream, by a caller
|
||||
// that knows which identity it trusts for the resource being touched.
|
||||
let mut request = request;
|
||||
request.extensions_mut().insert(verified);
|
||||
tracing::debug!(did = %signer_did, "UCAN chain validated");
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -548,37 +483,6 @@ mod tests {
|
||||
Ucan::bootstrap(node, agent_did).unwrap()
|
||||
}
|
||||
|
||||
/// The middleware validated a token and threw the result away, so no handler
|
||||
/// could ever read it and `Ucan::can` had no call site in the node. Validation
|
||||
/// must hand back both the token and the root the chain rests on.
|
||||
#[test]
|
||||
fn validate_ucan_chain_hands_back_the_root_and_the_token() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
|
||||
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
|
||||
.expect("wrap invocation");
|
||||
let token = invocation.encode().expect("encode");
|
||||
|
||||
let verified = validate_ucan_chain(&token, &node.did(), &agent.did())
|
||||
.expect("a well-formed owner-rooted invocation must validate");
|
||||
|
||||
assert_eq!(
|
||||
verified.root,
|
||||
owner.did(),
|
||||
"the root must be the owner, so a caller can anchor against the repo record"
|
||||
);
|
||||
assert_eq!(
|
||||
verified.ucan.payload.iss,
|
||||
agent.did(),
|
||||
"the token itself must come back so a caller can read its capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
|
||||
Ucan::delegate(
|
||||
agent,
|
||||
@@ -730,12 +634,10 @@ mod tests {
|
||||
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
|
||||
.expect("lazy pool creation should not fail");
|
||||
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
|
||||
let task_cursor_key = crate::api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
|
||||
let schema = Arc::new(graphql::build_schema(
|
||||
db.clone(),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
task_cursor_key.clone(),
|
||||
));
|
||||
crate::state::AppState {
|
||||
config: Arc::new(Config::parse_from(["gitlawb-node"])),
|
||||
@@ -747,7 +649,6 @@ mod tests {
|
||||
ref_update_tx: ref_tx,
|
||||
task_event_tx: task_tx,
|
||||
graphql_schema: schema,
|
||||
task_cursor_key,
|
||||
machine_id: None,
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
@@ -767,7 +668,6 @@ mod tests {
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
federated_rate_limiter: RateLimiter::new_bounded(12, Duration::from_secs(60), 10_000),
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
@@ -783,7 +683,6 @@ mod tests {
|
||||
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
|
||||
git_ipfs_walk_per_caller:
|
||||
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
|
||||
task_read_rate_limiter: RateLimiter::new(1200, Duration::from_secs(3600)),
|
||||
git_bin: "git".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -950,407 +849,3 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ucan_push_tests {
|
||||
use super::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
const OWNER_KEY: &str = "z6MkOwnerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/// `RepoRecord` does not derive `Default`, and adding the derive to a
|
||||
/// production DB type purely to serve a test is the wrong direction.
|
||||
fn repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
crate::db::RepoRecord {
|
||||
id: "repo-id".to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/unused".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token's own issuer and audience are irrelevant to this predicate: the
|
||||
/// middleware has already bound `iss` to the request signer and `aud` to this
|
||||
/// node. Only the capabilities and the chain's root matter here.
|
||||
fn verified(root: &str, caps_vec: Vec<Capability>) -> VerifiedUcan {
|
||||
verified_with_exp(
|
||||
root,
|
||||
caps_vec,
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
}
|
||||
|
||||
fn verified_with_exp(
|
||||
root: &str,
|
||||
caps_vec: Vec<Capability>,
|
||||
exp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> VerifiedUcan {
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let ucan = Ucan::issue(&agent, node.did(), caps_vec, exp).expect("issue");
|
||||
VerifiedUcan {
|
||||
ucan,
|
||||
root: root.parse().expect("root DID must parse"),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_full() -> String {
|
||||
format!("did:key:{OWNER_KEY}")
|
||||
}
|
||||
|
||||
fn push_cap_for(owner: &str, name: &str) -> Capability {
|
||||
Capability::new(format!("gitlawb://repos/{owner}/{name}"), caps::GIT_PUSH)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_push_when_the_chain_roots_at_the_owner_and_names_the_repo() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_a_bare_owner_key_against_a_full_did_record() {
|
||||
// Mirror rows store the bare key. A literal string compare would fail
|
||||
// here, denying a delegation that is in fact valid.
|
||||
let rec = repo(OWNER_KEY, "myrepo");
|
||||
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A perpetual grant is refused even when it is otherwise perfectly valid:
|
||||
/// owner-rooted, right repo, right action. Without revocation, an unbounded
|
||||
/// delegation cannot be withdrawn once it leaks.
|
||||
#[test]
|
||||
fn refuses_a_delegation_that_never_expires() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified_with_exp(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo")],
|
||||
None,
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_self_minted_root() {
|
||||
// The whole point: a token nobody delegated grants nothing, however
|
||||
// permissive its capabilities look.
|
||||
let stranger = Keypair::generate();
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(&stranger.did().to_string(), vec![Capability::new("*", "*")]);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_capability_for_a_different_repo() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "otherrepo")],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_capability_carrying_constraints() {
|
||||
// `nb` is not interpreted yet. An owner who writes {"refs": [...]} means
|
||||
// to restrict; honouring the capability while ignoring nb would grant
|
||||
// strictly more than they intended, so it authorizes nothing.
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo")
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }))],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_non_push_capability() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
caps::ISSUE_CREATE,
|
||||
)],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A resource wildcard must NOT authorize a push, even though attenuation
|
||||
/// accepts it. The helper narrows a `*` delegation before signing, but the node
|
||||
/// cannot rely on that: a delegate can sign an invocation that keeps the
|
||||
/// wildcard, and it would otherwise reach every repository the owner has — or
|
||||
/// will later create. This test previously asserted the opposite.
|
||||
#[test]
|
||||
fn refuses_a_resource_wildcard_and_honours_repo_admin() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
|
||||
assert!(
|
||||
!ucan_grants_push(&rec, &wildcard),
|
||||
"a wildcard resource must not authorize a push at the node"
|
||||
);
|
||||
|
||||
// The ACTION wildcard is a different axis and stays: attenuation bounds it,
|
||||
// and it still has to name a concrete repository.
|
||||
let action_wildcard = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
"*",
|
||||
)],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &action_wildcard));
|
||||
|
||||
let admin = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
caps::REPO_ADMIN,
|
||||
)],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &admin));
|
||||
}
|
||||
|
||||
/// The attack the wildcard refusal exists to stop: one `*` delegation reaching a
|
||||
/// repository it was never issued against, including one created afterwards.
|
||||
#[test]
|
||||
fn a_wildcard_delegation_cannot_reach_a_second_repository() {
|
||||
let other = repo(&owner_full(), "a-repo-created-later");
|
||||
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
|
||||
assert!(!ucan_grants_push(&other, &wildcard));
|
||||
}
|
||||
|
||||
/// The round-8 P1, executed rather than reasoned: an owner-issued `*` PROOF with
|
||||
/// a concrete leaf for a repository that did not exist at issuance. Refusing a
|
||||
/// wildcard *leaf* did nothing here — `is_attenuated_by` accepts a concrete child
|
||||
/// under a `*` parent, and that narrowing is exactly what `build_invocation` did,
|
||||
/// so the first-party helper was the working mint path.
|
||||
#[test]
|
||||
fn a_wildcard_proof_cannot_reach_a_repo_created_later() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let parent = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let later = format!("gitlawb://repos/{}/a-repo-created-later", owner.did());
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&later, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("the chain still verifies");
|
||||
let rec = repo(&owner.did().to_string(), "a-repo-created-later");
|
||||
assert!(
|
||||
!ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
),
|
||||
"a wildcard proof must not authorize a repo it never named"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipping flow must keep working: a proof that names the repository
|
||||
/// authorizes a push to it. Guards against fixing the wildcard by refusing
|
||||
/// everything with a `prf`.
|
||||
#[test]
|
||||
fn a_concrete_proof_still_authorizes_the_repo_it_names() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
|
||||
let parent = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("chain verifies");
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// A `*` two links up. The immediate proof names the repository, so a walk that
|
||||
/// checks only one level of `prf` stays green here — this is the case that
|
||||
/// makes the recursion load-bearing rather than incidental.
|
||||
#[test]
|
||||
fn a_wildcard_grandparent_cannot_reach_the_repo_through_a_concrete_parent() {
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
|
||||
let grandparent = Ucan::issue(
|
||||
&owner,
|
||||
intermediary.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let parent = Ucan::delegate(
|
||||
&intermediary,
|
||||
agent.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&grandparent,
|
||||
)
|
||||
.unwrap();
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("chain verifies");
|
||||
assert_eq!(root, owner.did());
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(
|
||||
!ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
),
|
||||
"a wildcard anywhere in the chain must deny, not just in the immediate proof"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the same three-link shape with every link concrete must still grant, so
|
||||
/// the walk is refusing the wildcard and not the depth.
|
||||
#[test]
|
||||
fn a_three_link_concrete_chain_still_authorizes() {
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
|
||||
|
||||
let grandparent = Ucan::issue(&owner, intermediary.did(), cap(), Some(hour)).unwrap();
|
||||
let parent =
|
||||
Ucan::delegate(&intermediary, agent.did(), cap(), Some(hour), &grandparent).unwrap();
|
||||
let invocation = Ucan::delegate(&agent, node.did(), cap(), Some(hour), &parent).unwrap();
|
||||
|
||||
let root = invocation.verify_chain().unwrap();
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// The round-ten P1, pinned where it bites. The middleware verifies the chain
|
||||
/// on the `X-Ucan` header of any signed request before the scope walk runs,
|
||||
/// so a depth bound that lived only in `chain_grants_push_to` protected
|
||||
/// nothing: a hand-built header reached the unbounded recursion first. A
|
||||
/// chain at `MAX_CHAIN_DEPTH` links validates; one link more is refused here,
|
||||
/// with 401 and a message that names depth, before any authorization runs.
|
||||
#[test]
|
||||
fn validate_ucan_chain_stops_at_the_depth_bound() {
|
||||
use gitlawb_core::ucan::MAX_CHAIN_DEPTH;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
|
||||
|
||||
// owner -> k1 -> ... -> signer: MAX_CHAIN_DEPTH - 1 links, all concrete,
|
||||
// all signed, all expiring. Only the length is in question.
|
||||
let mut signer = Keypair::generate();
|
||||
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour)).unwrap();
|
||||
for _ in 2..MAX_CHAIN_DEPTH {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
|
||||
// Wrapped into an invocation for the node: exactly MAX_CHAIN_DEPTH links.
|
||||
let at_bound = Ucan::delegate(&signer, node.did(), cap(), Some(hour), &cur).unwrap();
|
||||
let verified = validate_ucan_chain(&at_bound.encode().unwrap(), &node.did(), &signer.did())
|
||||
.expect("a chain at the bound must still validate");
|
||||
assert_eq!(verified.root, owner.did());
|
||||
|
||||
// One more link and it is past the bound.
|
||||
let next = Keypair::generate();
|
||||
let deeper = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
|
||||
let past_bound = Ucan::delegate(&next, node.did(), cap(), Some(hour), &deeper).unwrap();
|
||||
let (status, body) =
|
||||
match validate_ucan_chain(&past_bound.encode().unwrap(), &node.did(), &next.did()) {
|
||||
Ok(_) => panic!("a chain past the bound must be refused, not walked"),
|
||||
Err(refusal) => refusal,
|
||||
};
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let message = body.0["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("deeper than"),
|
||||
"the refusal must name depth as the reason: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_malformed_resource_uri() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
for bad in [
|
||||
"",
|
||||
"myrepo",
|
||||
"https://repos/x/myrepo",
|
||||
"gitlawb://repos/myrepo",
|
||||
] {
|
||||
let v = verified(&owner_full(), vec![Capability::new(bad, caps::GIT_PUSH)]);
|
||||
assert!(!ucan_grants_push(&rec, &v), "{bad} must not grant push");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-536
@@ -11,108 +11,10 @@ use uuid::Uuid;
|
||||
use crate::db::RefCertificate;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Issue a signed ref-update certificate for a successful push. The
|
||||
/// live receive-pack handler calls this on every successful push.
|
||||
/// Build the v1 signing payload — the EXACT bytes the node signs and
|
||||
/// gl's `verify_signature` reconstructs. This is the single source
|
||||
/// of truth shared by `issue_ref_certificate` and the frozen
|
||||
/// canonical-form tests below; drift here is what would break every
|
||||
/// existing cert.
|
||||
///
|
||||
/// Field order and key set are fixed: a default `serde_json::Value`
|
||||
/// serializes `Map<String, Value>` with sorted keys, so any change to
|
||||
/// the literal is observable as a different byte sequence on the
|
||||
/// wire.
|
||||
///
|
||||
/// v1 is the unversioned legacy shape: no `version` key in the signed
|
||||
/// JSON. From v2 onward the version belongs INSIDE the signed bytes
|
||||
/// (see `v2_signing_payload`): leaving it as an unsigned sibling
|
||||
/// would let a future v2 cert be downgraded to v1 and still verify
|
||||
/// cleanly, since the bytes would be identical.
|
||||
pub(crate) fn v1_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the v2 signing payload — the future shape for versioned
|
||||
/// certs. Identical to v1 except the wire `version` is part of the
|
||||
/// signed bytes, so stripping or flipping the `version` column
|
||||
/// invalidates the signature instead of verifying cleanly under the
|
||||
/// other version's path.
|
||||
///
|
||||
/// NOT yet issued: `issue_ref_certificate` still stamps v1 because
|
||||
/// the shipped `gl` verifier only supports v1. This builder exists
|
||||
/// to pin the downgrade-resistant shape now, before any v2 cert is
|
||||
/// ever signed.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn v2_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
"version": 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a signed ref-update certificate for a successful push.
|
||||
///
|
||||
/// `cert_id` is the deterministic id derived from `(request_id,
|
||||
/// ref_name)` (see [`crate::db::ref_cert_id_for`]). It is required so
|
||||
/// the recovery drain and the live handler produce the same primary
|
||||
/// key: a live push followed by a recovery pass collapses to a
|
||||
/// single cert row, and a re-push to the same `(repo, ref)` updates
|
||||
/// the existing row's `old_sha` / `new_sha` / `pusher_did` /
|
||||
/// `issued_at` / `signature` to the new transition while preserving
|
||||
/// the original `id` (the `insert_ref_certificate` upsert is
|
||||
/// keyed on `(repo_id, ref_name)` and only updates fields when the
|
||||
/// new `issued_at` is strictly greater).
|
||||
///
|
||||
/// #26 Split PR 1 P1-B: the live handler routes through this
|
||||
/// function (the upsert). After the reviewer-1 round-2 fix, the
|
||||
/// recovery drain also routes through this function (P1: refresh a
|
||||
/// stale cert), so both paths use the same deterministic `cert_id`
|
||||
/// and the same upsert. A re-pass is always safe:
|
||||
///
|
||||
/// - Live handler → live upsert: re-push updates the row, preserves
|
||||
/// the original `id`. The contract pinned by
|
||||
/// `insert_ref_certificate_upserts_on_repo_ref` is restored.
|
||||
/// - Live handler → recovery: live's `ON CONFLICT (id) DO UPDATE`
|
||||
/// preserves the original `id`; the recovery's same upsert is
|
||||
/// a no-op for an equal-`issued_at` re-run and a refresh for a
|
||||
/// strictly-newer one.
|
||||
/// - Recovery → live handler: the recovery wrote a row with the
|
||||
/// deterministic `id`; the live upsert (which preserves `id` and
|
||||
/// only updates other fields when `issued_at` is strictly newer)
|
||||
/// is a no-op for an equal-`issued_at` re-run and a refresh for
|
||||
/// a strictly-newer one.
|
||||
#[allow(dead_code)] // round-trip test in db/mod.rs pins the upsert contract; the live path and the drain use issue_ref_certificate_with_issued_at
|
||||
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
|
||||
/// persists the certificate, and returns it.
|
||||
pub async fn issue_ref_certificate(
|
||||
state: &AppState,
|
||||
repo_id: &str,
|
||||
@@ -120,106 +22,26 @@ pub async fn issue_ref_certificate(
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
cert_id: &str,
|
||||
) -> Result<RefCertificate> {
|
||||
issue_ref_certificate_with_issued_at(
|
||||
state, repo_id, ref_name, old_sha, new_sha, pusher_did, cert_id, None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// #26 Split PR 1 round 4 — variant that lets the caller stamp the
|
||||
/// cert's `issued_at` with a transition-time timestamp instead of
|
||||
/// `Utc::now()`. The recovery drain passes the persisted
|
||||
/// `row.created_at` so a replay after a later live cert does not
|
||||
/// outrank the live cert in the `EXCLUDED.issued_at >
|
||||
/// ref_certificates.issued_at` upsert guard.
|
||||
///
|
||||
/// The live handler uses the default `issue_ref_certificate` (no
|
||||
/// override), which keeps `Utc::now()` — the reviewer's invariant
|
||||
/// is that `issued_at` reflects the transition time, and for a
|
||||
/// live push the transition time and the wall-clock are the same.
|
||||
///
|
||||
/// `issued_at_override` is honored verbatim; passing a value not in
|
||||
/// RFC 3339 form is a logic bug (the upsert will mis-order), so
|
||||
/// callers must use the row's persisted `created_at`.
|
||||
///
|
||||
/// # Clippy allow — too many arguments
|
||||
/// This is the explicit "stamp a transition-time `issued_at`"
|
||||
/// variant of `issue_ref_certificate`. The drain
|
||||
/// (`durable_outbox::derive_one`) is the in-crate caller; the
|
||||
/// test `replay_of_stale_row_does_not_overwrite_live_cert_b` pins
|
||||
/// the contract that a recovery replay's `issued_at` does NOT
|
||||
/// outrank a later live cert. Adding a struct-arg would be a
|
||||
/// larger refactor for two callers (live + drain) and obscure the
|
||||
/// parallel to `issue_ref_certificate` (which is `#[allow]`'d for
|
||||
/// the same reason historically).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn issue_ref_certificate_with_issued_at(
|
||||
state: &AppState,
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
cert_id: &str,
|
||||
issued_at_override: Option<String>,
|
||||
) -> Result<RefCertificate> {
|
||||
let cert = build_ref_certificate(
|
||||
state,
|
||||
repo_id,
|
||||
ref_name,
|
||||
old_sha,
|
||||
new_sha,
|
||||
pusher_did,
|
||||
Some(cert_id.to_string()),
|
||||
issued_at_override,
|
||||
)
|
||||
.await?;
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
|
||||
/// Shared cert construction: build the JSON payload, sign it with the
|
||||
/// node key, and assemble the `RefCertificate` row. `cert_id_override`
|
||||
/// lets the recovery path plug in a deterministic id; the live path
|
||||
/// passes `None` and gets a fresh UUID. `issued_at_override` lets
|
||||
/// the recovery path stamp the cert with the original transition
|
||||
/// time so the upsert's `issued_at > issued_at` guard correctly
|
||||
/// orders transitions regardless of write order.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_ref_certificate(
|
||||
state: &AppState,
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
cert_id_override: Option<String>,
|
||||
issued_at_override: Option<String>,
|
||||
) -> Result<RefCertificate> {
|
||||
let node_did = state.node_did.to_string();
|
||||
// P1 (reviewer-1 round 4): when the caller passes a transition-
|
||||
// time `issued_at` (the recovery drain passes `row.created_at`),
|
||||
// use it verbatim so the upsert's per-column guard
|
||||
// `EXCLUDED.issued_at > ref_certificates.issued_at` correctly
|
||||
// orders transitions regardless of write order. The live handler
|
||||
// passes `None` and gets `Utc::now()` — for a live push the
|
||||
// transition time and the wall-clock are the same.
|
||||
let issued_at = issued_at_override.unwrap_or_else(|| Utc::now().to_rfc3339());
|
||||
let issued_at = Utc::now().to_rfc3339();
|
||||
|
||||
// Build the canonical signing payload via the shared builder so
|
||||
// the frozen-vector test below and the live signer cannot drift
|
||||
// apart — a regression in either side fails both.
|
||||
let payload = v1_signing_payload(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher_did, &node_did, &issued_at,
|
||||
);
|
||||
// Build the canonical signing payload.
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
});
|
||||
let payload_bytes = serde_json::to_vec(&payload)?;
|
||||
|
||||
let signature = state.node_keypair.sign_b64(&payload_bytes);
|
||||
|
||||
let id = cert_id_override.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let cert = RefCertificate {
|
||||
id,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo_id.to_string(),
|
||||
ref_name: ref_name.to_string(),
|
||||
old_sha: old_sha.to_string(),
|
||||
@@ -228,353 +50,9 @@ async fn build_ref_certificate(
|
||||
node_did,
|
||||
signature,
|
||||
issued_at,
|
||||
// #26 Split PR 3: the wire-format version. New certs stamp
|
||||
// v1 — the pre-versioning 7-field payload with no `version`
|
||||
// key in the signed JSON — because the shipped `gl`
|
||||
// verifier only supports v1. Stamping v2 here would issue
|
||||
// certificates the shipped client refuses (round-3
|
||||
// finding: node pinned `version == 2` while gl pinned
|
||||
// "2 is refused", with nothing exercising the
|
||||
// composition). v2 is reserved for the future shape
|
||||
// defined by `v2_signing_payload`, which carries the
|
||||
// version INSIDE the signed bytes; land the v2 verify
|
||||
// path before stamping v2.
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// Persist and return the row as it exists in the database (on a
|
||||
// conflict the existing row survives when it is newer).
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod v1_payload_tests {
|
||||
//! #26 Split PR 3 — cert payload version compat.
|
||||
//!
|
||||
//! The v1 cert payload is the frozen canonical byte form the
|
||||
//! node signs. gl's `verify_signature` reconstructs the same
|
||||
//! payload and verifies. Any drift in field order, key set, or
|
||||
//! whitespace breaks every existing cert. The tests here pin
|
||||
//! the v1 payload shape, the round-trip, the cert id
|
||||
//! determinism per the v1 version, and the v2 read forward
|
||||
//! compat (an unknown version refuses to verify rather than
|
||||
//! guessing).
|
||||
//!
|
||||
//! Reviewer 1 finding: the live signer (`issue_ref_certificate`)
|
||||
//! and the frozen-vector test (`v1_payload_matches_frozen_canonical_form`)
|
||||
//! previously held two separate `serde_json::json!` literals,
|
||||
//! so a regression in the signer could not fail this test.
|
||||
//! Both now go through the shared `v1_signing_payload` builder.
|
||||
use super::{v1_signing_payload, v2_signing_payload};
|
||||
use crate::db::RefCertificate;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
/// v1 payload is the same shape the legacy code signed, byte
|
||||
/// for byte. Reverting this assertion to the v2 shape (which
|
||||
/// includes a `version` key) is what the versioned format
|
||||
/// forbids: an old client reading a v1 cert must not see a
|
||||
/// `version` key in the signed JSON.
|
||||
///
|
||||
/// The literal is built via the shared `v1_signing_payload`
|
||||
/// helper that `issue_ref_certificate` also uses, so the live
|
||||
/// signer and the frozen vector share one source of truth.
|
||||
#[test]
|
||||
fn v1_payload_matches_frozen_canonical_form() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let frozen = format!(
|
||||
r#"{{"new":"{}","node":"did:key:z6MkNode","old":"{}","pusher":"did:key:z6MkPusher","ref":"refs/heads/main","repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}}"#,
|
||||
"a".repeat(40),
|
||||
"0".repeat(40),
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
|
||||
}
|
||||
|
||||
/// Round-trip: sign the v1 payload with a keypair, build a
|
||||
/// RefCertificate with version: 1, and verify the structure.
|
||||
/// This is the construction the live code does on every push.
|
||||
///
|
||||
/// Uses the shared builder for both halves so a regression in
|
||||
/// the live signer (adding/removing/renaming a field) cannot
|
||||
/// pass this test by accident.
|
||||
#[test]
|
||||
fn v1_ref_certificate_structure_is_well_formed() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let payload_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let sig = kp.sign_b64(&payload_bytes);
|
||||
let cert = RefCertificate {
|
||||
id: "cert-id".into(),
|
||||
repo_id: "repo-1".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a".repeat(40),
|
||||
pusher_did: "did:key:z6MkPusher".into(),
|
||||
node_did: node_did.clone(),
|
||||
signature: sig.clone(),
|
||||
issued_at: "2026-07-22T00:00:00+00:00".into(),
|
||||
version: 1,
|
||||
};
|
||||
assert_eq!(cert.version, 1, "new certs carry version: 1");
|
||||
// The signed payload reconstructs identically: gl's
|
||||
// verify_signature would build the same JSON, hash the
|
||||
// same bytes, and verify the same signature.
|
||||
let reconstructed = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let reconstructed_bytes = serde_json::to_vec(&reconstructed).unwrap();
|
||||
assert_eq!(
|
||||
reconstructed_bytes, payload_bytes,
|
||||
"the round-trip serialization must be byte-identical; \
|
||||
this is what makes gl's verify_signature succeed"
|
||||
);
|
||||
// Round 3 (P3 reviewer): byte-equality alone never calls
|
||||
// `identity::verify`, so a bad signature helper would pass.
|
||||
// Verify the computed signature against the reconstructed
|
||||
// bytes — the same check gl performs.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &reconstructed_bytes, &sig_bytes)
|
||||
.expect("well-formed v1 cert signature must verify");
|
||||
}
|
||||
|
||||
/// Reviewer 1 finding: the live signer must share the
|
||||
/// canonical-form literal so a regression in either side
|
||||
/// fails both. This test pins the shared builder's output
|
||||
/// against the literal a future v2 byte stream would have
|
||||
/// to break — if someone adds a `version` key to
|
||||
/// `v1_signing_payload`, every existing cert breaks verify,
|
||||
/// and this test is one of the canaries that fires.
|
||||
#[test]
|
||||
fn v1_signing_payload_has_no_version_key() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let obj = payload.as_object().expect("payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"v1 signing payload must not carry a `version` key — adding \
|
||||
one changes the bytes and breaks every existing cert's \
|
||||
signature. v2 certs build a different payload, not this one"
|
||||
);
|
||||
// Pin the exact set so a future field addition is caught.
|
||||
let expected: std::collections::BTreeSet<&str> =
|
||||
["new", "node", "old", "pusher", "ref", "repo_id", "ts"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
|
||||
assert_eq!(actual, expected, "v1 payload key set is frozen");
|
||||
}
|
||||
|
||||
/// Round 3 (P2 reviewer): from v2 onward the version belongs
|
||||
/// INSIDE the signed bytes. v1 stays the unversioned legacy
|
||||
/// shape; v2 carries `"version": 2` in the payload so a
|
||||
/// version flip without re-signing invalidates the signature
|
||||
/// instead of verifying cleanly under the other path.
|
||||
#[test]
|
||||
fn v2_signing_payload_binds_version_and_resists_downgrade() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let ts = "2026-07-22T00:00:00+00:00";
|
||||
let v1 = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2 = v2_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2_obj = v2.as_object().expect("v2 payload is a JSON object");
|
||||
assert_eq!(
|
||||
v2_obj.get("version"),
|
||||
Some(&serde_json::json!(2)),
|
||||
"v2 payload must carry the version inside the signed bytes"
|
||||
);
|
||||
let v1_bytes = serde_json::to_vec(&v1).unwrap();
|
||||
let v2_bytes = serde_json::to_vec(&v2).unwrap();
|
||||
assert_ne!(
|
||||
v1_bytes, v2_bytes,
|
||||
"v1 and v2 payloads must differ, otherwise a version flip is cryptographically invisible"
|
||||
);
|
||||
|
||||
// A signature over v1 must NOT verify as v2 and vice versa.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_v1 = kp.sign_b64(&v1_bytes);
|
||||
let sig_v1_bytes: [u8; 64] = URL_SAFE_NO_PAD.decode(&sig_v1).unwrap().try_into().unwrap();
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v1_bytes, &sig_v1_bytes).is_ok(),
|
||||
"v1 signature must verify under v1 bytes"
|
||||
);
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v2_bytes, &sig_v1_bytes).is_err(),
|
||||
"a v1 signature presented as v2 (downgrade/upgrade flip) must not verify"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live issuer stamps the version the shipped client can
|
||||
/// verify. The gl client refuses to verify v2 certs explicitly;
|
||||
/// this test pins that the version the ISSUING PATH claims
|
||||
/// agrees with the payload shape it actually signs.
|
||||
///
|
||||
/// Round 2: the prior form built a `RefCertificate` literal with
|
||||
/// `version: 1` and asserted `version == 1` — true no matter what the
|
||||
/// signer does, so flipping the issuer's version (or its payload shape)
|
||||
/// left it green. This one drives the actual
|
||||
/// `issue_ref_certificate` so a regression in the issuer flips the test.
|
||||
/// The DB row is created against `test_support::test_state`, which
|
||||
/// gives us a real `AppState` with a real node keypair.
|
||||
///
|
||||
/// Round 3: the issuer stamps v1 because the shipped `gl`
|
||||
/// verifier only supports v1 (stamping v2 shipped certs the
|
||||
/// client refuses). The binding to the live issuer is kept —
|
||||
/// flipping the stamp still turns this test red — without
|
||||
/// changing what production issues.
|
||||
#[sqlx::test]
|
||||
#[allow(clippy::async_yields_async)]
|
||||
async fn issuer_stamps_v1_over_v1_payload(pool: sqlx::PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let repo_id = "repo-issuer-observe";
|
||||
let ref_name = "refs/heads/main";
|
||||
let old = "0".repeat(40);
|
||||
let new = "a".repeat(40);
|
||||
let pusher = "did:key:z6MkPusher";
|
||||
let cert_id = "cert-issuer-observe";
|
||||
|
||||
// Drive the live issuer and read the persisted row back by
|
||||
// id, so the test covers the stored `version` column and
|
||||
// not just the in-process return value.
|
||||
let cert = crate::cert::issue_ref_certificate(
|
||||
&state, repo_id, ref_name, &old, &new, pusher, cert_id,
|
||||
)
|
||||
.await
|
||||
.expect("issue_ref_certificate must succeed");
|
||||
|
||||
// The cert returned by the issuer is the source of truth
|
||||
// for both the version claim and the signed bytes.
|
||||
assert_eq!(cert.repo_id, repo_id);
|
||||
assert_eq!(cert.ref_name, ref_name);
|
||||
|
||||
// Cross-check 1: the issuer's claimed version must match
|
||||
// what the live function stamps. This assertion is bound
|
||||
// to the live function, not a hand-built literal:
|
||||
// flipping the stamp in `issue_ref_certificate` breaks it
|
||||
// through the actual call path.
|
||||
assert_eq!(
|
||||
cert.version, 1,
|
||||
"the live issuer must stamp v1 until a v2 verifier ships; \
|
||||
flipping the stamp breaks this assertion through the actual call path"
|
||||
);
|
||||
|
||||
// Cross-check 1b: the persisted row carries the same stamp.
|
||||
let stored = state
|
||||
.db
|
||||
.get_ref_certificate(&cert.id)
|
||||
.await
|
||||
.expect("persisted cert must be readable")
|
||||
.expect("persisted cert must exist");
|
||||
assert_eq!(
|
||||
stored.version, cert.version,
|
||||
"the stored version column must match the issued claim"
|
||||
);
|
||||
|
||||
// Cross-check 2: the signature on the cert must verify
|
||||
// against the v1 payload builder's bytes, not against some
|
||||
// other shape the issuer might have introduced. This is
|
||||
// what gl's `verify_signature` does for a v1 cert: rebuild
|
||||
// the payload from the cert's own fields and verify the
|
||||
// signature. If the issuer ever signs a different shape
|
||||
// while still claiming v1, every shipped client breaks —
|
||||
// and so does this.
|
||||
let rebuilt = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let vk = cert
|
||||
.node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &serde_json::to_vec(&rebuilt).unwrap(), &sig_bytes)
|
||||
.expect("a v1 cert's signature must verify against the v1 payload shape");
|
||||
|
||||
// Cross-check 3: the v1 signed payload must NOT contain a
|
||||
// version key — v2 is the future shape that adds one inside
|
||||
// the signed bytes (see `v2_signing_payload`). The v1
|
||||
// payload (which the cert is signed over) is frozen at 7
|
||||
// fields. A change that adds `version` to the v1 payload
|
||||
// breaks every existing cert and this test.
|
||||
let obj = rebuilt
|
||||
.as_object()
|
||||
.expect("rebuilt v1 payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"the v1 signing payload must not carry a version key — \
|
||||
adding one changes the bytes and breaks every existing \
|
||||
cert's signature"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,6 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_PUBLIC_URL")]
|
||||
pub public_url: Option<String>,
|
||||
|
||||
/// Base URL for the web view of repos on this node (e.g. https://gitlawb.com).
|
||||
/// When set, `GET /` advertises it as `web_url` so the CLI can print a
|
||||
/// working `View:` link. Omit for nodes that have no web front-end.
|
||||
#[arg(long, env = "GITLAWB_WEB_URL")]
|
||||
pub web_url: Option<String>,
|
||||
|
||||
/// Comma-separated list of bootstrap peer URLs to announce to on startup
|
||||
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
|
||||
pub bootstrap_peers: Vec<String>,
|
||||
@@ -152,57 +146,6 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)]
|
||||
pub auto_sync: bool,
|
||||
|
||||
/// Periodically fetch configured INBOUND repositories from their external
|
||||
/// HTTPS upstream. Disabled by default: enabling this also requires
|
||||
/// `GITLAWB_ENFORCE_OWNER_PUSH=true`, so an upstream-authoritative mirror
|
||||
/// cannot accept writes from arbitrary signed identities between fetches.
|
||||
#[arg(long, env = "GITLAWB_UPSTREAM_MIRROR_ENABLED", default_value_t = false)]
|
||||
pub upstream_mirror_enabled: bool,
|
||||
|
||||
/// Pause between complete upstream-mirror scans. A scan is sequential and
|
||||
/// paginated; the pause begins only after the prior scan has finished, so a
|
||||
/// slow forge cannot create overlapping worker cycles.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_UPSTREAM_MIRROR_INTERVAL_SECS",
|
||||
default_value_t = 300,
|
||||
value_parser = clap::value_parser!(u64).range(5..=86_400)
|
||||
)]
|
||||
pub upstream_mirror_interval_secs: u64,
|
||||
|
||||
/// Maximum wall-clock time for one external `git fetch`. The child runs in
|
||||
/// its own process group and is fully reaped on timeout. The preflight
|
||||
/// URL-rewrite and local-config probes run on their own short bounds and
|
||||
/// do not consume this budget.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_UPSTREAM_MIRROR_FETCH_TIMEOUT_SECS",
|
||||
default_value_t = 600,
|
||||
value_parser = clap::value_parser!(u64).range(1..=GIT_SERVICE_TIMEOUT_SECS_MAX)
|
||||
)]
|
||||
pub upstream_mirror_fetch_timeout_secs: u64,
|
||||
|
||||
/// Number of durable INBOUND rows read per database page. Every page is
|
||||
/// processed before the scan sleeps; this bounds query memory without
|
||||
/// permanently starving repositories beyond the first page.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_UPSTREAM_MIRROR_PAGE_SIZE",
|
||||
default_value_t = 100,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..=1_000)
|
||||
)]
|
||||
pub upstream_mirror_page_size: usize,
|
||||
/// Enable the periodic reconciliation sweep that re-derives pin/seal sets
|
||||
/// and fills durability gaps. Defaults to true; set to false to disable
|
||||
/// the sweep even when a pin backend (IPFS/Pinata) is configured.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_RECONCILIATION_SWEEP",
|
||||
default_value_t = true,
|
||||
action = clap::ArgAction::Set
|
||||
)]
|
||||
pub reconciliation_sweep: bool,
|
||||
|
||||
/// Irys URL for Arweave permanent anchoring.
|
||||
/// Leave empty to disable. Use https://devnet.irys.xyz for free devnet.
|
||||
#[arg(long, env = "GITLAWB_IRYS_URL", default_value = "")]
|
||||
@@ -284,29 +227,6 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
|
||||
pub shutdown_grace_secs: u64,
|
||||
|
||||
/// Maximum number of creation requests (repo create, issue create, PR
|
||||
/// create, register, fork) allowed per DID within the rate-limit window.
|
||||
/// 0 is rejected at parse time: it would silently disable the limiter
|
||||
/// (RateLimiter treats max_requests == 0 as allow-all), not block every
|
||||
/// creation request.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_RATE_LIMIT_MAX",
|
||||
default_value_t = 10,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub rate_limit_max_requests: u32,
|
||||
|
||||
/// Rate-limit window size, in seconds, over which
|
||||
/// `rate_limit_max_requests` applies. 0 is rejected at parse time: every
|
||||
/// timestamp would prune immediately, silently disabling the limiter.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_RATE_LIMIT_WINDOW_SECS",
|
||||
default_value_t = 3600,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub rate_limit_window_secs: u64,
|
||||
/// Maximum wall-clock time a single served git operation (upload-pack /
|
||||
/// receive-pack through `run_git_service`) may run before it is aborted and
|
||||
/// its process group torn down, in seconds. Bounds a git that neither
|
||||
@@ -836,60 +756,6 @@ pub struct Config {
|
||||
value_parser = clap::builder::RangedU64ValueParser::<u64>::new().range(0..=86_400)
|
||||
)]
|
||||
pub pin_repair_sweep_delay_secs: u64,
|
||||
|
||||
/// #26 Split PR 1 step 4 — receive-pack queue retention window.
|
||||
/// Terminal `complete` and `rejected_at_git` rows older than
|
||||
/// this are eligible for the periodic purge. `quarantined`
|
||||
/// rows are never purged on a timer. The v30 partial index
|
||||
/// `idx_receive_pack_requests_completed_at` keeps the scan
|
||||
/// cheap regardless of the value.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_QUEUE_RETENTION_DAYS",
|
||||
default_value_t = 7,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<i64>::new().range(1..=365)
|
||||
)]
|
||||
pub queue_retention_days: i64,
|
||||
|
||||
/// #26 Split PR 1 step 4 — receive-pack queue purge batch size.
|
||||
/// Each periodic purge pass deletes at most this many terminal
|
||||
/// rows per batch. The drain and the purge share the same
|
||||
/// `DRAIN_PER_PASS_LIMIT` budget; see the spawn function in
|
||||
/// `main.rs` for the wiring.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_QUEUE_PURGE_BATCH",
|
||||
default_value_t = 1000,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<i64>::new().range(1..=100_000)
|
||||
)]
|
||||
pub queue_purge_batch: i64,
|
||||
|
||||
/// #26 Split PR 1 step 5 — effects-executor retry bound. The
|
||||
/// drain flips a request to `quarantined` after this many
|
||||
/// `EffectsOutcome::Retry` returns, closing the
|
||||
/// infinite-retry DoS window. Default 8 matches the spec; set
|
||||
/// lower in tests.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_EFFECTS_MAX_ATTEMPTS",
|
||||
default_value_t = 8,
|
||||
value_parser = clap::builder::RangedU64ValueParser::<i32>::new().range(1..=1000)
|
||||
)]
|
||||
pub effects_max_attempts: i32,
|
||||
/// Per-client-IP rate limit for the anonymous task read routes
|
||||
/// (`GET /api/v1/tasks`, `GET /api/v1/tasks/{id}`), in requests per hour.
|
||||
/// Both are publicly reachable (`optional_signature`). `GET /api/v1/tasks`
|
||||
/// runs `collect_visible_tasks` and returns a visibility-filtered page;
|
||||
/// `GET /api/v1/tasks/{id}` runs `get_visible_task` and returns an opaque 404
|
||||
/// when the task is hidden or missing. These reads can require task,
|
||||
/// repository, and visibility-rule queries even when no task is returned,
|
||||
/// so the brake bounds the cost of anonymous probes. Keyed on the resolved
|
||||
/// client IP via `GITLAWB_TRUSTED_PROXY`. `0` disables. Default: 1200 (a list page
|
||||
/// followed by per-task reads is a normal client pattern, so this sits above
|
||||
/// the `/ipfs` budget). GraphQL `tasks` / `task` queries, including WebSocket
|
||||
/// operations, share this per-IP budget with REST task reads.
|
||||
#[arg(long, env = "GITLAWB_TASK_READ_RATE_LIMIT", default_value_t = 1200)]
|
||||
pub task_read_rate_limit: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -937,13 +803,7 @@ impl Config {
|
||||
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
|
||||
/// catches combinations that ship a denial-of-service under otherwise-valid
|
||||
/// values. Call once at startup and fail fast on `Err`.
|
||||
///
|
||||
/// `&mut self` is required so the validator can normalize `web_url` to
|
||||
/// its canonical form after validation: the configured value is
|
||||
/// advertised on public `GET /`, and any consumer reading
|
||||
/// `config.web_url` later must see the same string that was actually
|
||||
/// validated.
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Concurrent git writes pin the dedicated lock pool for their whole
|
||||
// duration (connection-affine advisory lock in acquire_write). The main
|
||||
// pool no longer carries that occupancy.
|
||||
@@ -960,13 +820,6 @@ impl Config {
|
||||
Self::DB_LOCK_POOL_NON_PUSH_HEADROOM
|
||||
));
|
||||
}
|
||||
if self.upstream_mirror_enabled && !self.enforce_owner_push {
|
||||
return Err(
|
||||
"GITLAWB_UPSTREAM_MIRROR_ENABLED requires GITLAWB_ENFORCE_OWNER_PUSH=true: \
|
||||
an upstream-authoritative mirror must not accept writes from arbitrary signed identities"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
|
||||
if (self.db_max_connections as u64) < main_floor {
|
||||
return Err(format!(
|
||||
@@ -1067,150 +920,14 @@ impl Config {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// GITLAWB_WEB_URL is advertised in GET / so the CLI can print a working
|
||||
// View: link. Clap maps "" to Some("") which would produce a broken URL;
|
||||
// reject early instead of serving a malformed link. Non-blank values must
|
||||
// additionally parse as an absolute http(s) URL — the CLI appends
|
||||
// `/{owner}/{repo}` to it, so scheme-less hosts ("gitlawb.com") and other
|
||||
// garbage would render links no browser can follow.
|
||||
if let Some(raw) = &self.web_url {
|
||||
match validate_web_url(raw) {
|
||||
Ok(()) => {}
|
||||
Err(reason) => {
|
||||
return Err(format!(
|
||||
"GITLAWB_WEB_URL {reason} — \
|
||||
set it to an absolute URL like https://gitlawb.com or leave it unset."
|
||||
));
|
||||
}
|
||||
}
|
||||
// Normalize to one canonical form so every consumer (the regular
|
||||
// and degraded `GET /` handlers, anyone who reads
|
||||
// `config.web_url` directly) sees the same string that was
|
||||
// actually validated. The CLI's own defensive trim would paper
|
||||
// over the divergence on the `View:` line, but that hides the
|
||||
// bug from any other consumer that parses the value verbatim.
|
||||
let canonical = raw
|
||||
.trim()
|
||||
.parse::<url::Url>()
|
||||
.map(|u| u.to_string())
|
||||
.map_err(|_| "is not a valid absolute URL".to_string())?;
|
||||
if canonical != *raw {
|
||||
self.web_url = Some(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a `web_url` value for use as a browser-reachable base URL.
|
||||
/// Blank values are rejected (clap maps `--web-url ""` to `Some("")`), and
|
||||
/// non-blank values must parse as absolute `http`/`https` URLs with no
|
||||
/// query, fragment, or userinfo — the CLI treats this as a string prefix
|
||||
/// to append paths to, so anything else produces links no browser can
|
||||
/// follow (`?a=1/owner/repo` puts the repo path inside the query string);
|
||||
/// and userinfo is rejected outright because the configured value is
|
||||
/// published on public `GET /` and reprinted on the CLI's `View:` line,
|
||||
/// so an accidentally configured password would land in public metadata,
|
||||
/// terminal scrollback, and CI logs.
|
||||
pub(crate) fn validate_web_url(raw: &str) -> Result<(), String> {
|
||||
if raw.trim().is_empty() {
|
||||
return Err("must not be empty or whitespace-only".into());
|
||||
}
|
||||
let parsed: url::Url = raw
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "is not a valid absolute URL".to_string())?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => return Err(format!("must use http or https, got '{other}:'")),
|
||||
}
|
||||
// url::Url cannot represent a URL without a host for http/https schemes, so
|
||||
// reaching here guarantees an absolute browser-usable base.
|
||||
if parsed.query().is_some() {
|
||||
return Err(
|
||||
"must not contain a query string — it is used as a path prefix for View links".into(),
|
||||
);
|
||||
}
|
||||
if parsed.fragment().is_some() {
|
||||
return Err(
|
||||
"must not contain a fragment — it is used as a path prefix for View links".into(),
|
||||
);
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(
|
||||
"must not contain a username or password — it is advertised publicly and printed on the View: line"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Bidi/control bytes can hide behind percent-encoding: a literal scan of
|
||||
// the wire form misses `.../%E2%80%AE` even though a browser decodes it
|
||||
// into a reordering character on the rendered View: link. Check the
|
||||
// decoded host and path before the value is advertised on GET / (INV-6).
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
let decoded_path = percent_encoding::percent_decode_str(parsed.path()).decode_utf8_lossy();
|
||||
if host
|
||||
.chars()
|
||||
.chain(decoded_path.chars())
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
|
||||
{
|
||||
return Err(
|
||||
"must not contain control or bidi characters, even percent-encoded — the View: line is terminal output"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upstream_mirror_worker_is_default_off_and_bounded() {
|
||||
let mut config = Config::parse_from(["gitlawb-node"]);
|
||||
assert!(!config.upstream_mirror_enabled);
|
||||
assert_eq!(config.upstream_mirror_interval_secs, 300);
|
||||
assert_eq!(config.upstream_mirror_fetch_timeout_secs, 600);
|
||||
assert_eq!(config.upstream_mirror_page_size, 100);
|
||||
config.validate().unwrap();
|
||||
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--upstream-mirror-interval-secs", "4",])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--upstream-mirror-page-size", "0",]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_mirror_worker_requires_owner_push_enforcement() {
|
||||
// enforce_owner_push defaults true on main, so the only unsafe shape
|
||||
// is the explicit opt-out: mirror enabled plus enforcement refused.
|
||||
let mut unsafe_config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--upstream-mirror-enabled",
|
||||
"--enforce-owner-push",
|
||||
"false",
|
||||
]);
|
||||
let error = unsafe_config.validate().unwrap_err();
|
||||
assert!(error.contains("GITLAWB_ENFORCE_OWNER_PUSH=true"));
|
||||
|
||||
Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--upstream-mirror-enabled",
|
||||
"--enforce-owner-push",
|
||||
"true",
|
||||
])
|
||||
.validate()
|
||||
.unwrap();
|
||||
// The default posture alone also validates: enforcement is on.
|
||||
Config::parse_from(["gitlawb-node", "--upstream-mirror-enabled"])
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_pool_size_defaults_to_40_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
@@ -1439,30 +1156,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_lifecycle_knobs_default_conservatively() {
|
||||
let c = Config::parse_from(["gitlawb-node"]);
|
||||
// 7-day retention matches the v30 partial index comment
|
||||
// and the spec at .gravirei/plans/state-model-durable-post-receive.md.
|
||||
assert_eq!(c.queue_retention_days, 7);
|
||||
// 1000 rows per pass matches DRAIN_PER_PASS_LIMIT in durable_outbox.
|
||||
assert_eq!(c.queue_purge_batch, 1000);
|
||||
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--queue-retention-days", "30"])
|
||||
.queue_retention_days,
|
||||
30
|
||||
);
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--queue-retention-days", "0"]).is_err());
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--queue-retention-days", "366"]).is_err());
|
||||
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--queue-purge-batch", "500"]).queue_purge_batch,
|
||||
500
|
||||
);
|
||||
assert!(Config::try_parse_from(["gitlawb-node", "--queue-purge-batch", "0"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipfs_walk_per_source_defaults_and_rejects_out_of_range() {
|
||||
assert_eq!(Config::parse_from(["gitlawb-node"]).ipfs_walk_per_source, 4);
|
||||
@@ -1873,7 +1566,7 @@ mod tests {
|
||||
.expect("default config must validate");
|
||||
|
||||
// An under-sized lock pool relative to the push cap is rejected.
|
||||
let mut under_lock = Config::parse_from([
|
||||
let under_lock = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"16",
|
||||
@@ -1886,7 +1579,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Exactly at the push cap without non-push headroom is rejected.
|
||||
let mut push_only = Config::parse_from([
|
||||
let push_only = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"32",
|
||||
@@ -1899,7 +1592,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
|
||||
let mut split = Config::parse_from([
|
||||
let split = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"16",
|
||||
@@ -1914,153 +1607,13 @@ mod tests {
|
||||
);
|
||||
|
||||
// Main pool still needs the app headroom floor.
|
||||
let mut under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
assert!(
|
||||
under_main.validate().is_err(),
|
||||
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_url_rejects_empty_and_whitespace() {
|
||||
// Unset is fine.
|
||||
Config::parse_from(["gitlawb-node"])
|
||||
.validate()
|
||||
.expect("no web_url must validate");
|
||||
|
||||
// A real URL validates.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("https://gitlawb.com".into());
|
||||
assert!(cfg.validate().is_ok());
|
||||
|
||||
// Empty string is rejected.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("".into());
|
||||
assert!(cfg.validate().is_err(), "empty web_url must be rejected");
|
||||
|
||||
// Whitespace-only is rejected.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" ".into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"whitespace-only web_url must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_url_rejects_non_absolute_or_non_browser_urls() {
|
||||
// Scheme-less host: parses nowhere, renders a broken View: link.
|
||||
for bad in [
|
||||
"gitlawb.com",
|
||||
"not a url",
|
||||
"ftp://files.example.com",
|
||||
"javascript:alert(1)",
|
||||
"https://", // no host
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(cfg.validate().is_err(), "web_url {bad:?} must be rejected");
|
||||
}
|
||||
|
||||
// Query strings and fragments corrupt the appended repo path, since
|
||||
// web_url is used as a raw string prefix (`?a=1` would swallow
|
||||
// `/{owner}/{repo}` into the query).
|
||||
for bad in [
|
||||
"https://gitlawb.com?a=1",
|
||||
"https://gitlawb.com/?utm_source=docs",
|
||||
"https://gitlawb.com#section",
|
||||
"https://gitlawb.com/#faq",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (query or fragment) must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// Surrounding whitespace around a valid URL is tolerated.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" https://gitlawb.com ".into());
|
||||
assert!(
|
||||
cfg.validate().is_ok(),
|
||||
"padded-but-valid web_url must validate"
|
||||
);
|
||||
|
||||
// Percent-encoded bidi must not boot: the wire form carries no Cf
|
||||
// code point, but a browser decodes %E2%80%AE into a reordering
|
||||
// character on the rendered View: link (INV-6). Literal bidi and
|
||||
// control bytes are rejected the same way.
|
||||
for bad in [
|
||||
"https://example.com/%E2%80%AE",
|
||||
"https://example.com/\u{202e}spoof",
|
||||
"https://example.com/%1b[31m",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (control or bidi, even encoded) must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// Non-default ports and paths are fine.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("http://localhost:8080/ui".into());
|
||||
assert!(cfg.validate().is_ok(), "port+path web_url must validate");
|
||||
|
||||
// Userinfo must be rejected: `web_url` is published on public
|
||||
// `GET /` and reprinted on the CLI's `View:` line, so an
|
||||
// accidentally configured password would land in public metadata,
|
||||
// terminal scrollback, and CI logs. Reject username-only and
|
||||
// user+password forms.
|
||||
for bad in [
|
||||
"https://[email protected]",
|
||||
"https://user:[email protected]",
|
||||
"https://:[email protected]",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (userinfo) must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `validate()` accepts whitespace-padded input but the stored value
|
||||
/// must match the canonical form actually validated, so every consumer
|
||||
/// (the regular and degraded `GET /` handlers, anyone who reads
|
||||
/// `config.web_url` directly) sees the same string. Without this, an
|
||||
/// operator that sets `GITLAWB_WEB_URL=" https://git.example "` boots
|
||||
/// a node that advertises `" https://git.example "` on public
|
||||
/// metadata — different from what was validated, different from what
|
||||
/// any other consumer would parse.
|
||||
#[test]
|
||||
fn web_url_is_normalized_to_canonical_form_after_validation() {
|
||||
// Padded input is rewritten to the canonical form. `url::Url`'s
|
||||
// own `to_string` is what we use for the canonicalization, and
|
||||
// it always appends a path-separator slash to a bare-host URL,
|
||||
// so the stored value is `https://git.example/` (which is also
|
||||
// what every browser would normalize the operator-typed form to).
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" https://git.example ".into());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(
|
||||
cfg.web_url.as_deref(),
|
||||
Some("https://git.example/"),
|
||||
"stored web_url must be the canonical form, not the operator-typed one"
|
||||
);
|
||||
|
||||
// Already-canonical input is left alone after a no-op
|
||||
// re-normalization — same form comes back out, so any consumer
|
||||
// reading the stored value sees a stable string across calls.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("https://git.example/".into());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(cfg.web_url.as_deref(), Some("https://git.example/"));
|
||||
}
|
||||
|
||||
/// The DECLARED default, read off the parser rather than out of a parse.
|
||||
///
|
||||
/// `Config::parse_from` consults the process environment, so on a host that
|
||||
@@ -2325,7 +1878,7 @@ mod tests {
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let key_path = link.join("p2p.key");
|
||||
|
||||
let mut config = Config::parse_from([
|
||||
let config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--p2p-port",
|
||||
"0",
|
||||
@@ -2420,7 +1973,7 @@ mod tests {
|
||||
let key_path = build(base.path());
|
||||
let before = snapshot(base.path());
|
||||
|
||||
let mut config = Config::parse_from([
|
||||
let config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--p2p-port",
|
||||
"7546",
|
||||
|
||||
+155
-8068
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,6 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use gitlawb_core::did::Did;
|
||||
@@ -128,60 +127,17 @@ fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&
|
||||
/// `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
|
||||
/// to anchor a manifest. Recipient identities are never stored or returned.
|
||||
///
|
||||
/// Nine args (the fence joins the seal's eight) but grouping them would churn
|
||||
/// both callers and the race/hung-git tests for no behavioral gain.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn encrypt_and_pin(
|
||||
ipfs_api: &str,
|
||||
repo_path: &Path,
|
||||
db: &Db,
|
||||
repo_id: &str,
|
||||
node_seed: &[u8; 32],
|
||||
git_bin: &str,
|
||||
batch_budget: Duration,
|
||||
recipients: &HashMap<String, BTreeSet<String>>,
|
||||
fence: Option<&crate::ipfs_pin::PolicyFence>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut sealed = Vec::new();
|
||||
let mut skipped_unresolvable = 0usize;
|
||||
// One shared read deadline for the whole batch, like `pin_new_objects`: a
|
||||
// hung git child is watchdog-reaped at this bound, so the outer
|
||||
// `PIN_PHASE_DEADLINE` timeout cannot be held open by a blocking read
|
||||
// (R1-P2). Each read runs under `spawn_blocking` — it is synchronous child
|
||||
// spawn + pipe drain + watchdog join.
|
||||
let read_deadline = std::time::Instant::now() + batch_budget;
|
||||
let total = recipients.len();
|
||||
for (attempted, (oid, dids)) in recipients.iter().enumerate() {
|
||||
// Batch budget gate (R2-P3), mirroring the public pin loops: an object
|
||||
// is never started with a remainder too small to cover a bounded read's
|
||||
// teardown. This is consistency (the seal is bounded by the outer
|
||||
// `PIN_PHASE_DEADLINE` either way), but it keeps the three loops from
|
||||
// drifting apart in how they report a truncated batch.
|
||||
if crate::ipfs_pin::batch_budget_gate(
|
||||
"encrypted-seal",
|
||||
read_deadline,
|
||||
sealed.len(),
|
||||
total - attempted,
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Policy fence (R1-P1): the recipients snapshot was derived before the
|
||||
// long withheld-blob walk; if a visibility rule moved while that walk
|
||||
// ran (a reader added or removed), stop sealing instead of pinning to a
|
||||
// stale recipient set. Checked FIRST so a changed policy costs nothing.
|
||||
if let Some(f) = fence {
|
||||
if !f.is_current().await {
|
||||
tracing::warn!(
|
||||
repo = %f.repo_id(),
|
||||
oid = %oid,
|
||||
"visibility policy changed after the recipients snapshot; stopping the seal loop"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -232,9 +188,7 @@ pub async fn encrypt_and_pin(
|
||||
}
|
||||
SealPlan::Seal { keys, tag } => (keys, tag),
|
||||
};
|
||||
let data = match read_object_bounded_spawn_blocking(git_bin, repo_path, oid, read_deadline)
|
||||
.await
|
||||
{
|
||||
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");
|
||||
@@ -252,22 +206,6 @@ pub async fn encrypt_and_pin(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Dispatch fence (R1-P1): re-read the policy epoch immediately before
|
||||
// the irreversible HTTP POST. The iteration-top check catches a narrow
|
||||
// that landed before work began; THIS check catches a narrow that landed
|
||||
// during the tag lookup, recipient resolution, git read, or seal — all
|
||||
// of which can take seconds. Without this, a reader removed during
|
||||
// preparation can still receive a newly published envelope.
|
||||
if let Some(f) = fence {
|
||||
if !f.is_current().await {
|
||||
tracing::warn!(
|
||||
repo = %f.repo_id(),
|
||||
oid = %oid,
|
||||
"visibility policy changed during encrypted seal preparation; aborting upload"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
Ok(_) => {
|
||||
@@ -299,33 +237,10 @@ pub async fn encrypt_and_pin(
|
||||
sealed
|
||||
}
|
||||
|
||||
/// Bounded, reaped git object read for the seal loop, run off the async thread:
|
||||
/// `read_object_bounded` is synchronous child spawn + pipe drain + watchdog
|
||||
/// join, so blocking the runtime task on it would let a hung git hold a worker
|
||||
/// thread (R1-P2). The `deadline` is the batch's shared read deadline; a child
|
||||
/// still alive at it is SIGTERM/SIGKILL group-reaped by the watchdog.
|
||||
async fn read_object_bounded_spawn_blocking(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
sha256_hex: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> anyhow::Result<Option<(String, Vec<u8>)>> {
|
||||
let git_bin = git_bin.to_string();
|
||||
let repo_path = repo_path.to_path_buf();
|
||||
let sha256_hex = sha256_hex.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::store::read_object_bounded(&git_bin, &repo_path, &sha256_hex, deadline)
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read_object spawn_blocking join failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A did:key encoding a small-order point. Well-formed, indistinguishable
|
||||
/// from a real one by eye, and refused at resolution since the choke-point
|
||||
@@ -560,304 +475,4 @@ mod tests {
|
||||
other => panic!("changed recipient set must re-seal; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader removed mid-seal must stop the seal loop (R1-P1 "race test for
|
||||
/// reader removal"): `encrypt_and_pin` re-checks the policy fence before
|
||||
/// each blob, so a `remove_visibility_rule` landing while the first seal is
|
||||
/// in flight aborts before a later blob is pinned to a stale recipient set.
|
||||
#[sqlx::test]
|
||||
async fn encrypt_and_pin_stops_sealing_when_reader_removed_mid_batch(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool);
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("seal-race.git");
|
||||
|
||||
// Three loose blobs, each withheld (path-scoped deny exists so the sweep
|
||||
// would have derived recipients for them).
|
||||
let oids: Vec<String> = {
|
||||
crate::git::store::init_bare(&repo_path).expect("init bare repo");
|
||||
(0..3)
|
||||
.map(|i| {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(["hash-object", "-w", "--stdin"])
|
||||
.current_dir(&repo_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn git hash-object");
|
||||
{
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin")
|
||||
.write_all(format!("secret blob {i}\n").as_bytes())
|
||||
.expect("write stdin");
|
||||
}
|
||||
let out = child.wait_with_output().expect("hash-object output");
|
||||
assert!(out.status.success());
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// A real repos row so the fence has an epoch and a reader can be removed.
|
||||
let now = chrono::Utc::now();
|
||||
let repo_id = uuid::Uuid::new_v4().to_string();
|
||||
db.create_repo(&crate::db::RepoRecord {
|
||||
id: repo_id.clone(),
|
||||
name: "seal-race-repo".into(),
|
||||
owner_did: "did:key:zSealRaceOwner".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: repo_path.display().to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("create repo");
|
||||
// A rule whose removal is the "reader removed" mutation: one reader per
|
||||
// blob, all under the same path glob.
|
||||
let reader = did_key(1);
|
||||
db.set_visibility_rule(
|
||||
&repo_id,
|
||||
"**/secret/*",
|
||||
crate::db::VisibilityMode::B,
|
||||
std::slice::from_ref(&reader),
|
||||
"did:key:zSealRaceOwner",
|
||||
)
|
||||
.await
|
||||
.expect("set rule");
|
||||
|
||||
// IPFS endpoint that delays the FIRST add 2s so the removal lands while
|
||||
// that seal is in flight, then answers immediately.
|
||||
let endpoint = delaying_cid_endpoint(vec![Duration::from_secs(2)]).await;
|
||||
|
||||
let recipients: HashMap<String, BTreeSet<String>> = oids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|oid| {
|
||||
let mut s = BTreeSet::new();
|
||||
s.insert(reader.clone());
|
||||
(oid, s)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let fence = crate::ipfs_pin::PolicyFence::capture(&db, &repo_id)
|
||||
.await
|
||||
.expect("fence captures");
|
||||
|
||||
let sealed = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
let seal_db = db.clone();
|
||||
let seal_repo = repo_path.clone();
|
||||
let seal_endpoint = endpoint.clone();
|
||||
let seal_repo_id = repo_id.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
encrypt_and_pin(
|
||||
&seal_endpoint,
|
||||
&seal_repo,
|
||||
&seal_db,
|
||||
&seal_repo_id,
|
||||
&SEED,
|
||||
"git",
|
||||
Duration::from_secs(60),
|
||||
&recipients,
|
||||
Some(&fence),
|
||||
)
|
||||
.await
|
||||
});
|
||||
// Let the first add start (endpoint sleeps 2s), then remove the
|
||||
// reader so the fence is stale before the loop checks again.
|
||||
// The window has to cover blob 1's encrypt phase: if the removal
|
||||
// lands before the loop reaches dispatch, the fence aborts with
|
||||
// nothing sealed, so give the in-flight add room to start under
|
||||
// a loaded box while staying inside the 2s endpoint delay.
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
db.remove_visibility_rule(&repo_id, "**/secret/*")
|
||||
.await
|
||||
.expect("remove rule");
|
||||
handle.await.expect("seal task")
|
||||
})
|
||||
.await
|
||||
.expect("wedge guard: the fence abort must not take 30s");
|
||||
|
||||
assert!(
|
||||
sealed.len() < oids.len(),
|
||||
"a reader removal landing mid-batch must abort before every blob is sealed: {}",
|
||||
sealed.len()
|
||||
);
|
||||
assert!(
|
||||
!sealed.is_empty(),
|
||||
"at least the blob already in flight before the removal completes"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hung git must not hold the seal loop past its read budget (R1-P2): the
|
||||
/// git read runs under `spawn_blocking` against `read_object_bounded`, so
|
||||
/// the watchdog reaps a wedged child at the batch deadline and the loop
|
||||
/// keeps its shape instead of blocking a runtime worker indefinitely.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn encrypt_and_pin_returns_by_budget_with_a_hung_git(pool: sqlx::PgPool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let db = crate::db::Db::for_testing(pool);
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("seal-hung.git");
|
||||
let oids: Vec<String> = {
|
||||
crate::git::store::init_bare(&repo_path).expect("init bare repo");
|
||||
(0..2)
|
||||
.map(|i| {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(["hash-object", "-w", "--stdin"])
|
||||
.current_dir(&repo_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn git hash-object");
|
||||
{
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin")
|
||||
.write_all(format!("secret blob {i}\n").as_bytes())
|
||||
.expect("write stdin");
|
||||
}
|
||||
let out = child.wait_with_output().expect("hash-object output");
|
||||
assert!(out.status.success());
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// A git that wedges forever, ignoring SIGTERM, so only the watchdog's
|
||||
// SIGKILL can reap it.
|
||||
let fake = tmp.path().join("hanging-git");
|
||||
std::fs::write(&fake, "#!/bin/sh\ntrap '' TERM\necho $$ > pid\nsleep 30\n").unwrap();
|
||||
let mut perm = std::fs::metadata(&fake).unwrap().permissions();
|
||||
perm.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake, perm).unwrap();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let repo_id = uuid::Uuid::new_v4().to_string();
|
||||
db.create_repo(&crate::db::RepoRecord {
|
||||
id: repo_id.clone(),
|
||||
name: "seal-hung-repo".into(),
|
||||
owner_did: "did:key:zSealHungOwner".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: repo_path.display().to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("create repo");
|
||||
db.set_visibility_rule(
|
||||
&repo_id,
|
||||
"**/secret/*",
|
||||
crate::db::VisibilityMode::B,
|
||||
&[did_key(1)],
|
||||
"did:key:zSealHungOwner",
|
||||
)
|
||||
.await
|
||||
.expect("set rule");
|
||||
|
||||
let recipients: HashMap<String, BTreeSet<String>> = oids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|oid| {
|
||||
let mut s = BTreeSet::new();
|
||||
s.insert(did_key(1));
|
||||
(oid, s)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Unreachable endpoint: even if a read somehow succeeded, the pin would
|
||||
// fail; the read itself is the thing under test.
|
||||
let started = std::time::Instant::now();
|
||||
let sealed = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
encrypt_and_pin(
|
||||
"http://127.0.0.1:9",
|
||||
&repo_path,
|
||||
&db,
|
||||
&repo_id,
|
||||
&SEED,
|
||||
fake.to_str().unwrap(),
|
||||
Duration::from_secs(2),
|
||||
&recipients,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a hung git must not hold the seal past the outer wedge guard");
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(10),
|
||||
"a hung git must be watchdog-reaped inside the read budget, not block the loop for ~10s+ (took {elapsed:?})"
|
||||
);
|
||||
assert!(
|
||||
sealed.is_empty(),
|
||||
"with a hung git no blob can be read, so nothing may be reported sealed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Local TCP endpoint that answers `{ "Hash": "QmMock" }` after an optional
|
||||
/// per-request delay, so a seal can be made to straddle a policy mutation.
|
||||
async fn delaying_cid_endpoint(delays: Vec<Duration>) -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let mut seen = 0usize;
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let delay = *delays
|
||||
.get(seen)
|
||||
.or_else(|| delays.last())
|
||||
.unwrap_or(&Duration::ZERO);
|
||||
seen += 1;
|
||||
tokio::spawn(async move {
|
||||
let mut acc = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = match sock.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
acc.extend_from_slice(&buf[..n]);
|
||||
if let Some(hdr_end) =
|
||||
acc.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
|
||||
{
|
||||
let headers = String::from_utf8_lossy(&acc[..hdr_end]).to_lowercase();
|
||||
let len: usize = headers
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("content-length:"))
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
if acc.len() >= hdr_end + len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
let body = br#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#;
|
||||
let _ = sock
|
||||
.write_all(
|
||||
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len())
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let _ = sock.write_all(body).await;
|
||||
let _ = sock.flush().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
endpoint
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ pub enum AppError {
|
||||
#[error("repo already exists: {0}")]
|
||||
RepoExists(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
@@ -104,7 +101,7 @@ pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
pub(crate) fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
sqlx::Error::PoolTimedOut
|
||||
@@ -192,7 +189,6 @@ impl IntoResponse for AppError {
|
||||
"repo_exists",
|
||||
format!("repository '{r}' already exists"),
|
||||
),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
|
||||
@@ -325,16 +321,6 @@ pub type Result<T> = std::result::Result<T, AppError>;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conflict_maps_to_409() {
|
||||
assert_eq!(
|
||||
AppError::Conflict("task not claimable".into())
|
||||
.into_response()
|
||||
.status(),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504_distinct_from_git_500() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -141,10 +141,19 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// List all refs that start with the given string; the glob also matches the exact
|
||||
// ref. The id handed back is always git's own refname, never the request input.
|
||||
// Try exact match first — fast path for callers passing the full UUID.
|
||||
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
|
||||
let prefix_glob = format!("{exact_ref}*");
|
||||
let check = Command::new("git")
|
||||
.args(["cat-file", "-e", &exact_ref])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file -e")?;
|
||||
if check.status.success() {
|
||||
return Ok(Some(id_or_prefix.to_string()));
|
||||
}
|
||||
|
||||
// Prefix search: list all refs that start with the given string.
|
||||
let prefix_glob = format!("refs/gitlawb/issues/{id_or_prefix}*");
|
||||
let list = Command::new("git")
|
||||
.args(["for-each-ref", "--format=%(refname)", &prefix_glob])
|
||||
.current_dir(repo_path)
|
||||
@@ -156,44 +165,25 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
|
||||
}
|
||||
|
||||
let output = String::from_utf8_lossy(&list.stdout);
|
||||
let matches: Vec<&str> = output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
|
||||
// An exact id wins over longer ids that share it as a prefix, but only if its
|
||||
// object exists: for-each-ref also lists a ref whose blob is missing, so a
|
||||
// dangling exact ref falls through to the prefix rules below.
|
||||
let exact = match matches.iter().find(|m| **m == exact_ref) {
|
||||
Some(m) => {
|
||||
let check = Command::new("git")
|
||||
.args(["cat-file", "-e", m])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file -e")?;
|
||||
check.status.success().then_some(*m)
|
||||
match matches.len() {
|
||||
0 => Ok(None),
|
||||
1 => {
|
||||
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
|
||||
let full_id = matches[0]
|
||||
.trim()
|
||||
.strip_prefix("refs/gitlawb/issues/")
|
||||
.unwrap_or(matches[0].trim())
|
||||
.to_string();
|
||||
Ok(Some(full_id))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let found = match exact {
|
||||
Some(m) => m,
|
||||
None => match matches.as_slice() {
|
||||
[] => return Ok(None),
|
||||
[m] => *m,
|
||||
_ => anyhow::bail!(
|
||||
"ambiguous issue prefix '{}': matches {} issues",
|
||||
id_or_prefix,
|
||||
matches.len()
|
||||
),
|
||||
},
|
||||
};
|
||||
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
|
||||
let full_id = found
|
||||
.strip_prefix("refs/gitlawb/issues/")
|
||||
.unwrap_or(found)
|
||||
.to_string();
|
||||
Ok(Some(full_id))
|
||||
_ => anyhow::bail!(
|
||||
"ambiguous issue prefix '{}': matches {} issues",
|
||||
id_or_prefix,
|
||||
matches.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close an issue by updating its status to "closed" in the git ref.
|
||||
@@ -277,43 +267,6 @@ mod tests {
|
||||
assert_eq!(resolved, Some(full_id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_exact_id_wins_over_longer_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
create_issue(dir.path(), "abc12345", r#"{"status":"open"}"#).unwrap();
|
||||
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open"}"#).unwrap();
|
||||
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
|
||||
assert_eq!(resolved, Some("abc12345".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_dangling_exact_id_does_not_win_over_longer_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
create_issue(dir.path(), "abc12345", r#"{"status":"open","n":1}"#).unwrap();
|
||||
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open","n":2}"#).unwrap();
|
||||
// Dangle the exact ref: the ref stays listed by for-each-ref, but its blob is gone.
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "refs/gitlawb/issues/abc12345"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8(out.stdout).unwrap();
|
||||
let hash = hash.trim();
|
||||
std::fs::remove_file(
|
||||
dir.path()
|
||||
.join(".git/objects")
|
||||
.join(&hash[..2])
|
||||
.join(&hash[2..]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_issue_id(dir.path(), "abc12345");
|
||||
assert!(result.unwrap_err().to_string().contains("ambiguous"));
|
||||
assert!(close_issue(dir.path(), "abc12345").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_prefix_matches_unique() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -185,13 +185,10 @@ fn rev_list_delta(
|
||||
/// List every object in the repository via
|
||||
/// `git cat-file --batch-all-objects --batch-check='%(objectname)'`.
|
||||
///
|
||||
/// This is the whole-repo enumeration the push path falls back to. It
|
||||
/// returns *all* objects (including unreachable/dangling ones), which is
|
||||
/// what the push path needs to catch stragglers — do not swap it for a
|
||||
/// reachability walk. The reconciliation sweep no longer uses it: sweep
|
||||
/// candidates come from the windowed walk (window commits plus walked
|
||||
/// pairs), which are reachable by construction, so dangling objects are
|
||||
/// absent rather than filtered.
|
||||
/// This is the whole-repo enumeration the push path falls back to and the
|
||||
/// reconciliation sweep relies on. It returns *all* objects (including
|
||||
/// unreachable/dangling ones), which is what the sweep needs to catch
|
||||
/// stragglers — do not swap it for a reachability walk.
|
||||
pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<Vec<String>> {
|
||||
let out = crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
@@ -217,7 +214,6 @@ pub fn list_all_objects(repo_path: &Path, git_bin: &str, deadline: Instant) -> R
|
||||
/// filter needs to tell blobs (content, withholdable) and trees (content,
|
||||
/// withholdable since #135/#173) from commits/tags (structural, never withheld)
|
||||
/// without typing the candidate list itself.
|
||||
#[allow(dead_code)] // used by tests and all_blob_oids
|
||||
pub fn list_all_objects_with_type(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
@@ -265,7 +261,6 @@ pub fn all_blob_oids(
|
||||
/// candidate blob or tree absent from the matching reachable visibility-allowed
|
||||
/// set; a dangling or withheld object is in this universe but not the allowed
|
||||
/// set, so it never replicates (#99, #172).
|
||||
#[allow(dead_code)] // production went to the typed sweep path; tests still use it
|
||||
pub fn all_blob_and_tree_oids(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
@@ -308,12 +303,10 @@ pub struct PinCandidateSet {
|
||||
/// Every degraded path is **logged**, not silent: a full-scan fallback, a
|
||||
/// failed full scan, and a panicked blocking task each emit a warning. On a
|
||||
/// failed full scan or a task panic the candidate set is empty (pin nothing
|
||||
/// this push); that is a durability gap the reconciliation sweep backstops
|
||||
/// when it is enabled and a pin backend is configured (a node running with the
|
||||
/// sweep disabled or with no IPFS/Pinata backend has no backstop), and it can
|
||||
/// never leak because the withheld/fail-closed filter still runs on whatever
|
||||
/// set is returned. `full_scan` rides on the returned set so the caller knows
|
||||
/// when the dangling-inclusive filter is required.
|
||||
/// this push); that is a durability gap the reconciliation sweep backstops, and
|
||||
/// it can never leak because the withheld/fail-closed filter still runs on
|
||||
/// whatever set is returned. `full_scan` rides on the returned set so the caller
|
||||
/// knows when the dangling-inclusive filter is required.
|
||||
///
|
||||
/// `scan_sem` is the post-receive scan admission pool (`git_encrypt_semaphore`,
|
||||
/// #174 F4): both git-spawning stages — the per-tip `cat-file` probe + delta
|
||||
|
||||
@@ -118,7 +118,7 @@ impl RepoStore {
|
||||
/// Test-only: every guard from this store parks in `release` right before the
|
||||
/// `pg_advisory_unlock` await, until `gate` is notified. Dropping the future
|
||||
/// while it is parked reproduces a client disconnect inside `release`.
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
pub fn with_pre_unlock_gate(mut self, gate: Arc<tokio::sync::Notify>) -> Self {
|
||||
self.pre_unlock_gate = Some(gate);
|
||||
self
|
||||
@@ -126,7 +126,7 @@ impl RepoStore {
|
||||
|
||||
/// Test-only: how many write guards from this store have reached the Tigris upload
|
||||
/// site. See [`RepoWriteGuard::release`].
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
pub fn tigris_upload_site_reached(&self) -> usize {
|
||||
self.upload_site_reached
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
@@ -1717,11 +1717,8 @@ fn validate_repo_name(repo_name: &str) -> Result<()> {
|
||||
if repo_name.is_empty() {
|
||||
anyhow::bail!("repo_name is empty");
|
||||
}
|
||||
// Byte bound shared with the API-side validator via `MAX_REPO_NAME_LEN`:
|
||||
// the two validators differ on charset by design, but must not drift on
|
||||
// length, or an API-admitted name fails here after the proof is spent.
|
||||
if repo_name.len() > crate::db::MAX_REPO_NAME_LEN {
|
||||
anyhow::bail!("repo_name exceeds {} chars", crate::db::MAX_REPO_NAME_LEN);
|
||||
if repo_name.len() > 100 {
|
||||
anyhow::bail!("repo_name exceeds 100 chars");
|
||||
}
|
||||
// Repo names are `[A-Za-z0-9._-]+` minus path-traversal traps.
|
||||
if repo_name.contains("..") {
|
||||
|
||||
@@ -220,7 +220,6 @@ pub fn response_served_pack(output: &[u8]) -> bool {
|
||||
///
|
||||
/// Accepts a push. The caller MUST verify HTTP Signature auth before
|
||||
/// calling this function.
|
||||
#[allow(dead_code)] // used by tests; production uses receive_pack_raw
|
||||
pub async fn receive_pack(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
@@ -245,219 +244,6 @@ pub async fn receive_pack(
|
||||
.body(Body::from(output))?)
|
||||
}
|
||||
|
||||
/// Run `git-receive-pack` and return the raw stdout bytes together with
|
||||
/// the process exit status. Unlike [`receive_pack`], this does NOT bail
|
||||
/// on a non-zero exit: the caller needs the stdout (which contains the
|
||||
/// report-status with per-ref ok/ng results) even when the process
|
||||
/// exits non-zero. A timeout still returns `Err`.
|
||||
///
|
||||
/// `reflog_action` (typically `gitlawb-request:<request_id>`) is
|
||||
/// exported as `GIT_REFLOG_ACTION` so the ref transaction writes
|
||||
/// request-bound landing evidence into the reflog. Recovery requires
|
||||
/// that evidence before auto-promoting a stranded row; without it a
|
||||
/// later request recreating the same old/new tuple is
|
||||
/// indistinguishable from the stranded one.
|
||||
#[allow(dead_code)]
|
||||
pub async fn receive_pack_raw(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
request_body: Bytes,
|
||||
timeout: Duration,
|
||||
admission: Option<AdmissionGuard>,
|
||||
) -> Result<(Vec<u8>, bool)> {
|
||||
receive_pack_raw_with_reflog(git_bin, repo_path, request_body, timeout, admission, None).await
|
||||
}
|
||||
|
||||
/// [`receive_pack_raw`] with explicit reflog binding.
|
||||
pub async fn receive_pack_raw_with_reflog(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
request_body: Bytes,
|
||||
timeout: Duration,
|
||||
admission: Option<AdmissionGuard>,
|
||||
reflog_action: Option<&str>,
|
||||
) -> Result<(Vec<u8>, bool)> {
|
||||
let mut command = Command::new(git_bin);
|
||||
command
|
||||
.arg("receive-pack")
|
||||
.arg("--stateless-rpc")
|
||||
.arg(repo_path);
|
||||
if let Some(action) = reflog_action {
|
||||
command.env("GIT_REFLOG_ACTION", action);
|
||||
}
|
||||
let (out, err, status, _admission) = drive_git_child_raw(
|
||||
command,
|
||||
request_body,
|
||||
timeout,
|
||||
"git-receive-pack",
|
||||
admission,
|
||||
)
|
||||
.await?;
|
||||
// On timeout, drive_git_child_raw returns Err — we never reach here.
|
||||
// On success/non-zero exit, we have the stdout and exit status.
|
||||
if !status.success() {
|
||||
let stderr = String::from_utf8_lossy(&err);
|
||||
tracing::warn!(stderr = %stderr, "git-receive-pack exited non-zero");
|
||||
}
|
||||
Ok((out, status.success()))
|
||||
}
|
||||
|
||||
/// Parse the git-receive-pack report-status output to determine per-ref
|
||||
/// success/failure. Returns `(unpack_ok, per_ref_results)` where
|
||||
/// `per_ref_results` is a list of `(ref_name, is_ok)`.
|
||||
///
|
||||
/// The report-status format (after the sideband framing) is:
|
||||
/// ```text
|
||||
/// unpack ok\n (or: unpack fail\n)
|
||||
/// ok <refname>\n (per successful ref)
|
||||
/// ng <refname> <reason>\n (per rejected ref)
|
||||
/// \n (empty line terminates)
|
||||
/// ```
|
||||
///
|
||||
/// Returns `None` if the output cannot be parsed (e.g. the client did
|
||||
/// not request report-status, or the output is truncated). In that
|
||||
/// case the caller should treat all refs as uncertain.
|
||||
pub fn parse_report_status(output: &[u8]) -> Option<(bool, Vec<(String, bool)>)> {
|
||||
let text = std::str::from_utf8(output).ok()?;
|
||||
// Strip sideband framing: each line starts with a pkt-line length
|
||||
// prefix and a channel byte (1 = stdout, 2 = stderr). The actual
|
||||
// data starts after the first `0000` flush packet or after we
|
||||
// strip sideband bytes.
|
||||
//
|
||||
// Framing completeness is part of authority, at whichever level the
|
||||
// framing lives. A double-framed capture carries the report stream
|
||||
// (itself pkt-line framed, flush-terminated) inside the sideband
|
||||
// envelope — real git output has been observed with NO outer flush,
|
||||
// so the outer level cannot require one; the inner frame's flush is
|
||||
// authoritative there. A single-framed (or unframed) payload has no
|
||||
// inner frame, so its only completion marker is the outer flush:
|
||||
// output ending after a complete `unpack ok` / `ok <ref>` packet but
|
||||
// before the outer `0000` is a truncated report and yields `None`
|
||||
// (uncertain), not a successful report.
|
||||
let (stripped, outer_flush) = strip_sideband(text, false)?;
|
||||
// The report-status vocabulary (`unpack ...`, `ok ...`, `ng ...`)
|
||||
// never opens with four hex digits, so a hex quartet here is inner
|
||||
// pkt-line framing, not report text.
|
||||
let stripped = if stripped.len() >= 4
|
||||
&& stripped.as_bytes()[..4]
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
strip_sideband(&stripped, true)?.0
|
||||
} else if !outer_flush {
|
||||
return None;
|
||||
} else {
|
||||
stripped
|
||||
};
|
||||
// The report is framed TWICE when the client negotiated side-band-64k,
|
||||
// which `git push` over smart HTTP does — so this is the common case, not
|
||||
// an exotic one. The outer frame is the side-band envelope; band 1 carries
|
||||
// the report-status stream, which is ITSELF pkt-line encoded. Real bytes
|
||||
// from git 2.50.1 rejecting one ref of a two-ref push:
|
||||
//
|
||||
// 0057\x01000eunpack ok\n0028ng refs/heads/main non-fast-forward\n...
|
||||
//
|
||||
// After one pass the first line reads `000eunpack ok`, which fails the
|
||||
// `unpack ok` check below and makes this return None — i.e. "no report",
|
||||
// which the caller treats as inconclusive and keeps every declared ref.
|
||||
// The per-ref gate would then be inert for exactly the pushes it exists to
|
||||
// filter. The second pass above already removed the inner pkt-line
|
||||
// framing where present (and required its flush); it is a no-op
|
||||
// on the single-framed shape, because plain report text does not begin
|
||||
// with four hex digits.
|
||||
let lines: Vec<&str> = stripped.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// First non-empty line is "unpack ok" or "unpack fail".
|
||||
let unpack_line = lines.iter().find(|l| !l.is_empty())?;
|
||||
let unpack_ok = if unpack_line.starts_with("unpack ok") {
|
||||
true
|
||||
} else if unpack_line.starts_with("unpack fail") {
|
||||
false
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
for line in &lines[1..] {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("ok ") {
|
||||
results.push((rest.to_string(), true));
|
||||
} else if let Some(rest) = line.strip_prefix("ng ") {
|
||||
// "ng <refname> <reason>" — skip the reason
|
||||
let ref_name = rest.split_whitespace().next()?.to_string();
|
||||
results.push((ref_name, false));
|
||||
}
|
||||
}
|
||||
|
||||
Some((unpack_ok, results))
|
||||
}
|
||||
|
||||
/// Strip git sideband framing from a pkt-line encoded output.
|
||||
/// Sideband-encoded lines start with a 4-hex-digit length, then a
|
||||
/// channel byte (0x01=stdout, 0x02=stderr), then payload. Returns the
|
||||
/// decoded payload plus whether the stream saw its terminating flush
|
||||
/// packet, or `None` if the framing is malformed. With `require_flush`,
|
||||
/// a stream that never sees its flush is also `None` — the caller uses
|
||||
/// this for the level whose completion marker is authoritative (see
|
||||
/// [`parse_report_status`]).
|
||||
fn strip_sideband(text: &str, require_flush: bool) -> Option<(String, bool)> {
|
||||
let mut output = String::new();
|
||||
let mut pos = 0;
|
||||
let bytes = text.as_bytes();
|
||||
let mut saw_flush = false;
|
||||
|
||||
loop {
|
||||
if pos == bytes.len() {
|
||||
break;
|
||||
}
|
||||
if pos + 4 > bytes.len() {
|
||||
// Truncated framing mid-header: fail closed.
|
||||
return None;
|
||||
}
|
||||
let len_str = std::str::from_utf8(&bytes[pos..pos + 4]).ok()?;
|
||||
let len = usize::from_str_radix(len_str, 16).ok()?;
|
||||
if len == 0 {
|
||||
// Flush packet — end of sideband stream
|
||||
saw_flush = true;
|
||||
break;
|
||||
}
|
||||
if len < 4 || pos + len > bytes.len() {
|
||||
// Truncated pkt-line extends past EOF: incomplete framing.
|
||||
return None;
|
||||
}
|
||||
let pkt_data = std::str::from_utf8(&bytes[pos + 4..pos + len]).ok()?;
|
||||
pos += len;
|
||||
|
||||
// Sideband: first byte is channel (1=stdout, 2=stderr)
|
||||
if let Some(payload) = pkt_data.strip_prefix('\x01') {
|
||||
output.push_str(payload);
|
||||
} else if pkt_data.starts_with('\x02') {
|
||||
// stderr — skip (git error messages)
|
||||
} else {
|
||||
// Not sideband encoded — pass through
|
||||
output.push_str(pkt_data);
|
||||
}
|
||||
}
|
||||
|
||||
if require_flush && !saw_flush {
|
||||
// Truncated at a packet boundary: every packet parsed, but the
|
||||
// stream never completed. Indistinguishable from a report git was
|
||||
// killed mid-write, so no authority.
|
||||
return None;
|
||||
}
|
||||
if output.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((output, saw_flush))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends SIGTERM to a child's whole process group on drop, unless disarmed first.
|
||||
///
|
||||
/// A served `git upload-pack`/`receive-pack` forks helpers such as `pack-objects`.
|
||||
@@ -792,121 +578,6 @@ async fn drive_git_child(
|
||||
Ok((out, admission))
|
||||
}
|
||||
|
||||
/// Like [`drive_git_child`], but returns stdout and stderr even on a
|
||||
/// non-zero exit status. Used by [`receive_pack_raw`] so the caller
|
||||
/// can parse the report-status output from a failed `git-receive-pack`.
|
||||
async fn drive_git_child_raw(
|
||||
mut command: Command,
|
||||
input: Bytes,
|
||||
timeout: Duration,
|
||||
what: &str,
|
||||
admission: Option<AdmissionGuard>,
|
||||
) -> Result<(
|
||||
Vec<u8>,
|
||||
Vec<u8>,
|
||||
std::process::ExitStatus,
|
||||
Option<AdmissionGuard>,
|
||||
)> {
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
|
||||
let mut stdin = child.stdin.take();
|
||||
let mut stdout = child.stdout.take().context("git stdout was not piped")?;
|
||||
let mut stderr = child.stderr.take().context("git stderr was not piped")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let pgid = child.id().map(|id| id as i32);
|
||||
#[cfg(unix)]
|
||||
let mut group_guard = KillGroupOnDrop {
|
||||
child: Some(child),
|
||||
pgid,
|
||||
admission,
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut err = Vec::new();
|
||||
|
||||
let interact = async {
|
||||
let write = async {
|
||||
match stdin.take() {
|
||||
Some(mut s) => s.write_all(&input).await,
|
||||
None => Ok(()),
|
||||
}
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let child_ref = group_guard.child_mut();
|
||||
#[cfg(not(unix))]
|
||||
let child_ref = &mut child;
|
||||
let (write_result, r_out, r_err, status) = tokio::join!(
|
||||
write,
|
||||
stdout.read_to_end(&mut out),
|
||||
stderr.read_to_end(&mut err),
|
||||
child_ref.wait(),
|
||||
);
|
||||
r_out?;
|
||||
r_err?;
|
||||
Ok::<_, anyhow::Error>((write_result, status?))
|
||||
};
|
||||
|
||||
let timed = tokio::time::timeout(timeout, interact).await;
|
||||
let (write_result, status, admission) = match timed {
|
||||
Ok(result) => {
|
||||
#[cfg(unix)]
|
||||
let admission = group_guard.disarm();
|
||||
let (write_result, status) = result?;
|
||||
(write_result, status, admission)
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
reap_group_on_timeout(group_guard.child_mut()).await;
|
||||
drop(group_guard.disarm());
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.start_kill();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
|
||||
drop(admission);
|
||||
}
|
||||
return Err(GitServiceTimeout.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Prefer the child's collected answer over a stdin-write error: an early
|
||||
// rejection (unpack failure, command-level deny) makes git print a complete
|
||||
// report-status and exit WITHOUT draining stdin, so a body larger than the
|
||||
// pipe buffer EPIPEs the write while the answer is already on stdout. The
|
||||
// child is reaped (the join above waited on it), so `out`/`err`/`status`
|
||||
// are complete and authoritative — returning Err here would discard them:
|
||||
// `receive_pack_raw_with_reflog` would propagate the write error and the
|
||||
// handler would mark every prepared row `uncertain` instead of driving
|
||||
// `cancelled` per the report. That durable fate is the effect: on a
|
||||
// non-zero exit the client still gets `Err(AppError::Git)` (the handler
|
||||
// maps the failed run itself, not this write), while on exit 0 an error
|
||||
// here would answer a push git actually landed with a server error. The
|
||||
// non-raw `drive_git_child` orders the same way (exit status and git's own
|
||||
// failure first, stdin-write error last — pinned by
|
||||
// `run_git_service_surfaces_git_stderr_over_a_stdin_epipe`), but it must
|
||||
// fail on non-zero because its callers do not parse output from a failed
|
||||
// child; the raw driver exists precisely to parse that output.
|
||||
if let Err(e) = &write_result {
|
||||
tracing::warn!(
|
||||
err = %e,
|
||||
what = %what,
|
||||
"git stdin write failed after child exit; surfacing collected output"
|
||||
);
|
||||
}
|
||||
|
||||
Ok((out, err, status, admission))
|
||||
}
|
||||
|
||||
/// Map a validated service name to its git subcommand. Only the two fixed
|
||||
/// subcommands can come out, so an unrecognized service can never reach git's argv.
|
||||
fn service_to_command(service: &str) -> Result<&'static str> {
|
||||
@@ -1197,86 +868,6 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Byte-for-byte capture from `git receive-pack` 2.50.1 rejecting one ref of
|
||||
/// a two-ref push, with side-band-64k negotiated — what `git push` over
|
||||
/// smart HTTP actually sends. The report is framed twice: the outer
|
||||
/// side-band envelope, then the report-status stream's own pkt-lines.
|
||||
///
|
||||
/// This fixture is the point of the test. A hand-written single-framed
|
||||
/// string parses fine with only one strip pass, so a fixture invented to
|
||||
/// match the parser hides the exact case the parser is for.
|
||||
const REAL_SIDEBAND_REPORT: &[u8] =
|
||||
b"0057\x01000eunpack ok\n0028ng refs/heads/main non-fast-forward\n0018ok refs/heads/third\n0000";
|
||||
|
||||
#[test]
|
||||
fn a_real_double_framed_sideband_report_is_read_not_treated_as_absent() {
|
||||
let (unpack_ok, refs) =
|
||||
parse_report_status(REAL_SIDEBAND_REPORT).expect("a real git report must parse");
|
||||
assert!(unpack_ok, "unpack line must be read through both frames");
|
||||
assert_eq!(
|
||||
refs,
|
||||
vec![
|
||||
("refs/heads/main".to_string(), false),
|
||||
("refs/heads/third".to_string(), true),
|
||||
],
|
||||
"the rejected ref must be distinguished from the accepted one"
|
||||
);
|
||||
}
|
||||
|
||||
/// The single-framed shape must keep working: the second pass has to be a
|
||||
/// no-op there, not a corruption. Plain report text does not begin with
|
||||
/// four hex digits, which is what makes that safe.
|
||||
#[test]
|
||||
fn a_single_framed_report_still_parses_after_the_second_pass() {
|
||||
// Framed programmatically rather than by a hand-counted hex prefix: a
|
||||
// wrong length makes the parser return None, which would look exactly
|
||||
// like the regression this pair of tests exists to catch.
|
||||
let payload = "\x01unpack ok\nok refs/heads/x\n";
|
||||
let single = format!("{:04x}{payload}0000", payload.len() + 4);
|
||||
let (unpack_ok, refs) =
|
||||
parse_report_status(single.as_bytes()).expect("single-framed must parse");
|
||||
assert!(unpack_ok);
|
||||
assert_eq!(refs, vec![("refs/heads/x".to_string(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_mid_packet_report_is_indeterminate_not_partial() {
|
||||
// Truncate a valid single-framed report mid-packet (overrun):
|
||||
// must return None so the handler persists the whole request as
|
||||
// indeterminate instead of committing a partial ok set.
|
||||
let payload = "\x01unpack ok\nok refs/heads/x\n";
|
||||
let single = format!("{:04x}{payload}0000", payload.len() + 4);
|
||||
let truncated = &single.as_bytes()[..single.len() - 6];
|
||||
assert!(
|
||||
parse_report_status(truncated).is_none(),
|
||||
"mid-packet truncation must be indeterminate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncation exactly at a packet boundary (every packet complete, but
|
||||
/// the outer flush never arrived — e.g. git killed after the last `ok`
|
||||
/// line) must also be indeterminate: the payload is complete but the
|
||||
/// frame is not, and framing completeness is part of authority. The
|
||||
/// mid-packet sibling above cannot catch this; only the flush gate can.
|
||||
/// Removing the `require_flush` enforcement returns `Some` and turns
|
||||
/// this red.
|
||||
#[test]
|
||||
fn truncated_at_packet_boundary_report_is_indeterminate_not_partial() {
|
||||
let payload = "\x01unpack ok\nok refs/heads/x\n";
|
||||
let single = format!("{:04x}{payload}0000", payload.len() + 4);
|
||||
let no_flush = &single.as_bytes()[..single.len() - 4];
|
||||
assert!(
|
||||
parse_report_status(no_flush).is_none(),
|
||||
"a report missing its outer flush must be indeterminate, \
|
||||
even with every packet intact"
|
||||
);
|
||||
// Sanity: the untruncated fixture still parses (flush present).
|
||||
assert!(
|
||||
parse_report_status(single.as_bytes()).is_some(),
|
||||
"the same bytes with the flush must parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_to_command_maps_allowlist_and_rejects_passthrough() {
|
||||
assert_eq!(
|
||||
@@ -3050,62 +2641,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The raw driver's counterpart: a rejection-shaped early exit where git
|
||||
// prints a COMPLETE report-status and exits without draining stdin, so a
|
||||
// body larger than the pipe buffer (~64 KiB) EPIPEs the write while the
|
||||
// answer is already on stdout. `receive_pack_raw` must surface that report
|
||||
// (Ok + stdout + exit status), not discard it as a stdin-write error — an
|
||||
// Err here loses parseable output: `receive_pack_raw_with_reflog`
|
||||
// propagates it and the handler marks every prepared row `uncertain`
|
||||
// instead of driving `cancelled` from the report (the durable fate is the
|
||||
// effect; the client-visible status on a failed run is `AppError::Git`
|
||||
// either way). Goes RED if the stdin-write check returns Err after the
|
||||
// join. Both exit codes are pinned: receive-pack rejections and successes
|
||||
// both carry reports the handler must parse.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn receive_pack_raw_surfaces_report_status_over_a_stdin_epipe() {
|
||||
for exit_code in [0, 1] {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("report.bin"), REAL_SIDEBAND_REPORT).unwrap();
|
||||
// Never reads stdin: the pending body blocks the pipe, then EPIPEs
|
||||
// once the fake exits. `cat` delivers the report first.
|
||||
let git_bin = write_fake_git(
|
||||
tmp.path(),
|
||||
&format!("#!/bin/sh\ncat \"$(dirname \"$0\")/report.bin\"\nexit {exit_code}\n"),
|
||||
);
|
||||
let body = Bytes::from(vec![0u8; 256 * 1024]);
|
||||
let (out, ok) = receive_pack_raw(
|
||||
git_bin.to_str().unwrap(),
|
||||
tmp.path(),
|
||||
body,
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"a report-status printed before exit must not be discarded as a \
|
||||
stdin-write error",
|
||||
);
|
||||
assert_eq!(
|
||||
ok,
|
||||
exit_code == 0,
|
||||
"the exit status rides the Ok return for both report shapes"
|
||||
);
|
||||
let (unpack_ok, results) = parse_report_status(&out)
|
||||
.expect("git's report survives the EPIPE and stays parseable");
|
||||
assert!(unpack_ok, "unpack ok parses");
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![
|
||||
("refs/heads/main".to_string(), false),
|
||||
("refs/heads/third".to_string(), true),
|
||||
],
|
||||
"the per-ref report reaches the handler instead of an HTTP error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── #192 F1: response_served_pack detector (real git output) ────────────
|
||||
|
||||
// A completing request (want+flush+done) yields NAK + a real packfile;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -627,9 +627,7 @@ impl TigrisClient {
|
||||
}
|
||||
|
||||
/// Compress a bare repo directory into a tar.zst byte vector.
|
||||
/// `pub(crate)` for sweep tests that seed a fake object store with a
|
||||
/// byte-identical archive (rather than duplicating the format).
|
||||
pub(crate) fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
let buf = Vec::new();
|
||||
let encoder = zstd::stream::Encoder::new(buf, 3)?; // level 3 = fast + decent ratio
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@ pub mod query;
|
||||
pub mod subscription;
|
||||
pub mod types;
|
||||
|
||||
use async_graphql::{Schema, SchemaBuilder};
|
||||
use async_graphql::Schema;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
@@ -14,22 +14,6 @@ use subscription::SubscriptionRoot;
|
||||
|
||||
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||
|
||||
// Keep ordinary schema discovery and application queries usable while
|
||||
// bounding validation work and the number of resolver selections per request.
|
||||
const GRAPHQL_MAX_COMPLEXITY: usize = 400;
|
||||
// The current public schema is shallow; this leaves headroom for composed
|
||||
// clients and the canonical getIntrospectionQuery without allowing
|
||||
// recursively nested documents to grow unchecked.
|
||||
const GRAPHQL_MAX_DEPTH: usize = 14;
|
||||
|
||||
fn apply_query_limits<Query, Mutation, Subscription>(
|
||||
builder: SchemaBuilder<Query, Mutation, Subscription>,
|
||||
) -> SchemaBuilder<Query, Mutation, Subscription> {
|
||||
builder
|
||||
.limit_complexity(GRAPHQL_MAX_COMPLEXITY)
|
||||
.limit_depth(GRAPHQL_MAX_DEPTH)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -76,7 +60,6 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
|
||||
// Curated client-safe variants — `Display` is intentional API text.
|
||||
safe @ (crate::error::AppError::RepoNotFound(_)
|
||||
| crate::error::AppError::RepoExists(_)
|
||||
| crate::error::AppError::Conflict(_)
|
||||
| crate::error::AppError::NotFound(_)
|
||||
| crate::error::AppError::Unauthorized(_)
|
||||
| crate::error::AppError::Forbidden(_)
|
||||
@@ -93,91 +76,21 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a failed `claim_task` for GraphQL exactly as REST's claim handler
|
||||
/// classifies it: a lost race is a client-safe conflict, a real sqlx fault
|
||||
/// stays opaque. Lives here rather than at the three call sites so the
|
||||
/// classification cannot drift between transports (#327 review), and so
|
||||
/// `every_graphql_map_err_uses_opaque_helpers` can keep whitelisting by name.
|
||||
pub(crate) fn graphql_claim_conflict(e: anyhow::Error) -> async_graphql::Error {
|
||||
graphql_app_err(crate::api::tasks::task_write_conflict(
|
||||
e,
|
||||
"task not claimable: not found or already claimed",
|
||||
))
|
||||
}
|
||||
|
||||
/// The `finish_task` half of [`graphql_claim_conflict`], covering both
|
||||
/// `completeTask` and `failTask`.
|
||||
pub(crate) fn graphql_finish_conflict(e: anyhow::Error) -> async_graphql::Error {
|
||||
graphql_app_err(crate::api::tasks::task_write_conflict(
|
||||
e,
|
||||
"task not found or not in claimed state",
|
||||
))
|
||||
}
|
||||
|
||||
pub struct TaskReadBrakeExtension;
|
||||
|
||||
impl async_graphql::extensions::ExtensionFactory for TaskReadBrakeExtension {
|
||||
fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
|
||||
Arc::new(TaskReadBrakeExtensionImpl)
|
||||
}
|
||||
}
|
||||
|
||||
use async_graphql::async_trait::async_trait;
|
||||
|
||||
struct TaskReadBrakeExtensionImpl;
|
||||
|
||||
#[async_trait]
|
||||
impl async_graphql::extensions::Extension for TaskReadBrakeExtensionImpl {
|
||||
async fn prepare_request(
|
||||
&self,
|
||||
ctx: &async_graphql::extensions::ExtensionContext<'_>,
|
||||
mut request: async_graphql::Request,
|
||||
next: async_graphql::extensions::NextPrepareRequest<'_>,
|
||||
) -> async_graphql::ServerResult<async_graphql::Request> {
|
||||
if let Some(session_brake) = ctx
|
||||
.session_data
|
||||
.get(&std::any::TypeId::of::<crate::rate_limit::TaskReadBrake>())
|
||||
.and_then(|d| d.downcast_ref::<crate::rate_limit::TaskReadBrake>())
|
||||
{
|
||||
request = request.data(crate::rate_limit::TaskReadBrake {
|
||||
limiter: session_brake.limiter.clone(),
|
||||
key: session_brake.key.clone(),
|
||||
request_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
});
|
||||
}
|
||||
if let Some(did) = ctx
|
||||
.session_data
|
||||
.get(&std::any::TypeId::of::<crate::auth::AuthenticatedDid>())
|
||||
.and_then(|d| d.downcast_ref::<crate::auth::AuthenticatedDid>())
|
||||
{
|
||||
request = request.data(did.clone());
|
||||
}
|
||||
next.run(ctx, request).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_schema(
|
||||
db: Arc<Db>,
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
task_cursor_key: crate::api::task_cursor::TaskCursorKey,
|
||||
) -> GitlawbSchema {
|
||||
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
|
||||
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||
.data(db)
|
||||
.data(ref_update_tx)
|
||||
.data(task_event_tx)
|
||||
.data(task_cursor_key)
|
||||
.extension(TaskReadBrakeExtension)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
use async_graphql::{EmptyMutation, EmptySubscription, Object, Value};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_opaques_sqlx_chain() {
|
||||
@@ -247,683 +160,9 @@ mod tests {
|
||||
assert!(!err.message.contains("failed to open"));
|
||||
}
|
||||
|
||||
fn production_test_schema() -> GitlawbSchema {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
production_test_schema_with_db(Arc::new(Db::for_testing(pool)))
|
||||
}
|
||||
|
||||
fn production_test_schema_with_db(db: Arc<Db>) -> GitlawbSchema {
|
||||
build_schema(
|
||||
db,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_update_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("refUpdates { repo }").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_event_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("taskEvents { taskId }").await;
|
||||
}
|
||||
|
||||
async fn assert_subscription_alias_budget(field: &str) {
|
||||
use futures::StreamExt;
|
||||
use std::time::Duration;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(
|
||||
Arc::new(Db::for_testing(pool)),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
|
||||
);
|
||||
for count in [7, 8] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let query = format!("subscription {{ {fields} }}");
|
||||
let mut responses = Box::pin(schema.execute_stream(query));
|
||||
if count == 8 {
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.expect("over-budget subscriptions must reject before waiting for events")
|
||||
.expect("the validation error must be returned");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
continue;
|
||||
}
|
||||
// Poll the accepted operation far enough to register its receivers.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(30), responses.next())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 7);
|
||||
let _ = ref_tx.send(RefUpdateBroadcast {
|
||||
repo: "owner/repo".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "1".repeat(40),
|
||||
pusher_did: "did:key:test".into(),
|
||||
node_did: "did:key:node".into(),
|
||||
timestamp: "2026-01-01T00:00:00Z".into(),
|
||||
owner_did: "did:key:test".into(),
|
||||
});
|
||||
let _ = task_tx.send(TaskEventBroadcast {
|
||||
task_id: "task".into(),
|
||||
old_status: "pending".into(),
|
||||
new_status: "claimed".into(),
|
||||
by_did: "did:key:test".into(),
|
||||
at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
drop(responses);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_mutation_budget_accepts_seven_and_rejects_eight(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let caller = "did:key:reviewer";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for index in 0..8 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-{index}"),
|
||||
repo_id: None,
|
||||
kind: "test".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: caller.into(),
|
||||
assignee_did: None,
|
||||
capability: "test".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = production_test_schema_with_db(db.clone());
|
||||
for count in [8, 7] {
|
||||
let fields = (0..count)
|
||||
.map(|n| {
|
||||
format!("r{n}: claimTask(id: \"task-{n}\", assigneeDid: \"{caller}\") {{ id }}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema
|
||||
.execute(
|
||||
async_graphql::Request::new(format!("mutation {{ {fields} }}"))
|
||||
.data(crate::auth::AuthenticatedDid(caller.into())),
|
||||
)
|
||||
.await;
|
||||
if count == 8 {
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(
|
||||
db.list_tasks_keyset(Some("pending"), None, 8, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
8
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_tasks_keyset(Some("claimed"), Some(caller), 8, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_task("task-7").await.unwrap().unwrap().status,
|
||||
"pending"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_list_cost_scales_with_the_same_alias_count(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
// Bounds are per-root named constants so the cases move with the
|
||||
// documented maximums instead of going stale as literals.
|
||||
for (field, selection, max) in [
|
||||
("refUpdates", "repo", MAX_VISIBLE_REF_UPDATES),
|
||||
("tasks", "items { id }", MAX_VISIBLE_TASK_PAGE_SIZE),
|
||||
(
|
||||
"reposPage",
|
||||
"nodes { name }",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE as i64,
|
||||
),
|
||||
] {
|
||||
// Two aliases at the maximum always exceed the budget.
|
||||
let fields = (0..2)
|
||||
.map(|n| format!("r{n}: {field}(limit: {max}) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
|
||||
// At limit 1 two aliases fit for the row-priced roots; reposPage
|
||||
// prices the full corpus scan, so a single alias is the most that
|
||||
// fits under the budget.
|
||||
let aliases = if field == "reposPage" { 1 } else { 2 };
|
||||
let fields = (0..aliases)
|
||||
.map(|n| format!("r{n}: {field}(limit: 1) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{field}: {:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
aliases
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_limits_reject_mutation_aliases_and_large_lists() {
|
||||
let schema = production_test_schema();
|
||||
// Limits are derived from the MAX_* constants so the boundaries move
|
||||
// with the documented maximums instead of going stale as literals.
|
||||
for (prefix, count, field) in [
|
||||
("mutation", 8, "claimTask(id: \"missing\", assigneeDid: \"did:key:test\") { id }".to_owned()),
|
||||
("mutation", 8, "createTask(delegatorDid: \"did:key:test\", input: { kind: \"test\", capability: \"test\" }) { id }".to_owned()),
|
||||
("mutation", 8, "completeTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("mutation", 8, "failTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("query", 8, "task(id: \"missing\") { id }".to_owned()),
|
||||
("query", 2, format!("refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }}")),
|
||||
("query", 2, format!("tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }}")),
|
||||
("query", 2, format!("reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ nodes {{ name }} }}")),
|
||||
("query", 8, "reposPage(limit: 1) { nodes { name } }".to_owned()),
|
||||
] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{prefix} {{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_repos_page_serves_the_documented_maximum(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
for selection in [
|
||||
"nodes { name }",
|
||||
"nodes { name ownerDid } hasNextPage endCursor",
|
||||
] {
|
||||
let response = schema
|
||||
.execute(format!(
|
||||
"{{ reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ {selection} }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response.data.into_json().unwrap()["reposPage"]["nodes"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_root_aliases_are_accepted() {
|
||||
struct Root(Arc<AtomicUsize>);
|
||||
#[Object]
|
||||
impl Root {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn repos(&self) -> Vec<Nested> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
vec![Nested]
|
||||
}
|
||||
}
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
Root(calls.clone()),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: repos {{ value }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expensive_root_aliases_are_rejected_before_database_access() {
|
||||
// Use the production builder with a lazy pool: rejection must precede DB access.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..8)
|
||||
.map(|n| format!("r{n}: repos {{ name }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
|
||||
assert_eq!(response.data, Value::Null);
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_schema_introspection_remains_available() {
|
||||
let schema = production_test_schema();
|
||||
let canonical_introspection = r#"
|
||||
query IntrospectionQuery {
|
||||
__schema {
|
||||
queryType { name }
|
||||
mutationType { name }
|
||||
subscriptionType { name }
|
||||
types {
|
||||
...FullType
|
||||
}
|
||||
directives {
|
||||
name
|
||||
description
|
||||
locations
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment FullType on __Type {
|
||||
kind
|
||||
name
|
||||
description
|
||||
fields(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
type {
|
||||
...TypeRef
|
||||
}
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
inputFields {
|
||||
...InputValue
|
||||
}
|
||||
interfaces {
|
||||
...TypeRef
|
||||
}
|
||||
enumValues(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
possibleTypes {
|
||||
...TypeRef
|
||||
}
|
||||
}
|
||||
|
||||
fragment InputValue on __InputValue {
|
||||
name
|
||||
description
|
||||
type { ...TypeRef }
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fragment TypeRef on __Type {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let response = schema.execute(canonical_introspection).await;
|
||||
assert!(
|
||||
response.errors.is_empty(),
|
||||
"canonical introspection query failed against production schema: {:?}",
|
||||
response.errors
|
||||
);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_and_tasks_single_alias_max_limit_complexity_contract() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
pool.close().await;
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(
|
||||
Arc::new(Db::for_testing(pool)),
|
||||
ref_tx,
|
||||
task_tx,
|
||||
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
|
||||
);
|
||||
|
||||
// refUpdates(limit: MAX_VISIBLE_REF_UPDATES):
|
||||
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
|
||||
// It passes validation and reaches the resolver (which yields db error on lazy pool).
|
||||
let single_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
assert!(
|
||||
single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2 -> cost 100 + 200 + 2 = 302 <= 400.
|
||||
// The documented max-200 request stays reachable with multiple fields
|
||||
// (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo refName }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
assert!(
|
||||
multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
|
||||
// tasks(limit: MAX_VISIBLE_TASK_PAGE_SIZE):
|
||||
// Selection items { id } has child_complexity 2, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 400.
|
||||
// It passes validation and reaches the resolver.
|
||||
let single_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ items {{ id }} }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
assert!(
|
||||
single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
|
||||
// Selection items { id status } has child_complexity 3, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 3 <= 400.
|
||||
// A request at the documented maximum stays reachable with multiple
|
||||
// fields (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ items {{ id status }} }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
assert!(
|
||||
multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_repos_aliases_exceed_the_budget() {
|
||||
// `repos` is priced as the full page it scans
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child), so two `repos { name }`
|
||||
// aliases cost 2 * 251 = 502 > 400 and the document is rejected before
|
||||
// any resolver runs. If the price reverts to `50 + child_complexity`
|
||||
// the aliases cost 2 * 51 = 102, validation passes, and the failure
|
||||
// mode changes (resolver DB errors on the lazy pool): this test goes
|
||||
// red on revert.
|
||||
let schema = production_test_schema();
|
||||
let response = schema
|
||||
.execute("{ r0: repos { name } r1: repos { name } }")
|
||||
.await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_repos_page_aliases_exceed_the_budget() {
|
||||
// `reposPage` is priced as the full corpus scan
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity), so seven
|
||||
// `reposPage(limit: 1) { nodes { name } }` aliases cost
|
||||
// 7 * (50 + 200 + 2) = 1764 > 400 and the document is rejected
|
||||
// before any resolver runs. Under the previous row-priced formula
|
||||
// the same aliases cost 7 * (50 + 1 + 2) = 371, validation passes,
|
||||
// and the failure mode changes (resolver DB errors on the lazy
|
||||
// pool): this test goes red on revert. Eight aliases are rejected
|
||||
// under both formulas, so seven is the load-bearing boundary.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: reposPage(limit: 1) {{ nodes {{ name }} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_fixed_base_is_charged() {
|
||||
// The 100 base in the refUpdates complexity formula charges for the
|
||||
// collector's fixed work (loading the deduped repo set, the
|
||||
// quarantined set, and the visibility rules, then walking up to 16
|
||||
// keyset pages) even at limit: 1. Four aliases at limit: 1 cost
|
||||
// 4 * (100 + 1 + 1) = 408 > 400 and must be rejected. If the base
|
||||
// disappears, the cost drops to 4 * (1 + 1) = 8 and this test fails.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..4)
|
||||
.map(|n| format!("r{n}: refUpdates(limit: 1) {{ repo }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Nested;
|
||||
|
||||
#[Object]
|
||||
impl Nested {
|
||||
async fn child(&self) -> Nested {
|
||||
Nested
|
||||
}
|
||||
|
||||
async fn value(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingQuery(Arc<AtomicUsize>);
|
||||
|
||||
#[Object]
|
||||
impl CountingQuery {
|
||||
async fn nested(&self) -> Nested {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
Nested
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_depth_limit_accepts_fourteen_and_rejects_fifteen() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
CountingQuery(Arc::clone(&calls)),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let query_at_depth = |depth: usize| {
|
||||
let selection = (2..depth).fold("value".to_string(), |selection, _| {
|
||||
format!("child {{ {selection} }}")
|
||||
});
|
||||
format!("{{ nested {{ {selection} }} }}")
|
||||
};
|
||||
|
||||
let accepted = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH)).await;
|
||||
|
||||
assert!(
|
||||
accepted.errors.is_empty(),
|
||||
"depth {GRAPHQL_MAX_DEPTH} should be accepted: {:?}",
|
||||
accepted.errors
|
||||
);
|
||||
assert_eq!(calls.swap(0, Ordering::Relaxed), 1);
|
||||
|
||||
let rejected = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH + 1)).await;
|
||||
|
||||
assert_eq!(rejected.data, Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_schema_enforces_depth_limit() {
|
||||
let schema = production_test_schema();
|
||||
// Depth 14 is accepted through the production build_schema path.
|
||||
let of_types_14 = (0..9).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query_14 =
|
||||
format!("{{ __schema {{ types {{ fields {{ type {{ {of_types_14} }} }} }} }} }}");
|
||||
let accepted = schema.execute(&query_14).await;
|
||||
assert!(accepted.errors.is_empty(), "{:?}", accepted.errors);
|
||||
|
||||
// Construct an introspection query of depth 15 using __schema.
|
||||
// 1: __schema
|
||||
// 2: types
|
||||
// 3: fields
|
||||
// 4: type
|
||||
// 5..14: ofType (10 times)
|
||||
// 15: name
|
||||
// Total depth = 15 > GRAPHQL_MAX_DEPTH (14).
|
||||
let of_types = (0..10).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query = format!("{{ __schema {{ types {{ fields {{ type {{ {of_types} }} }} }} }} }}");
|
||||
|
||||
let rejected = schema.execute(&query).await;
|
||||
assert_eq!(rejected.data, async_graphql::Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through one of the curated helpers in this module, or discard the error
|
||||
/// (`|_|`). Same source-scrape pattern as `api::authz_guard` (#255
|
||||
/// review). The list is deliberately a whitelist of names rather than a
|
||||
/// prefix match, so adding a new mapper is a decision a reviewer sees.
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
#[test]
|
||||
fn every_graphql_map_err_uses_opaque_helpers() {
|
||||
for (file, src) in [
|
||||
@@ -938,8 +177,6 @@ mod tests {
|
||||
let after = code[idx + ".map_err(".len()..].trim_start();
|
||||
let ok = after.starts_with("crate::graphql::graphql_db_err")
|
||||
|| after.starts_with("crate::graphql::graphql_app_err")
|
||||
|| after.starts_with("crate::graphql::graphql_claim_conflict")
|
||||
|| after.starts_with("crate::graphql::graphql_finish_conflict")
|
||||
|| after.starts_with("|_|")
|
||||
|| after.starts_with("|_ ");
|
||||
assert!(
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
impl MutationRoot {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn create_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -37,33 +36,6 @@ impl MutationRoot {
|
||||
}
|
||||
let delegator_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// #496: same write gate as the REST handler — a repo_id naming a
|
||||
// hosted, non-quarantined repo admits tasks only from its owner, so a
|
||||
// stranger cannot file under a foreign repo id. An id naming no hosted
|
||||
// repo (unknown, or quarantined — hidden as if it did not exist) is
|
||||
// stored verbatim as an opaque label; the gate does not bind an
|
||||
// unresolved id to a repo. Task reads are caller-independent on this
|
||||
// base (the tasks resolver is not repo-scoped), before and after this
|
||||
// change. As in tasks.rs, the success-vs-denial split between a hosted
|
||||
// repo id and an unknown one is itself a one-bit oracle — a stranger
|
||||
// holding a private repo's id learns it is hosted here — but denying
|
||||
// foreign repos requires drawing exactly that line.
|
||||
if let Some(repo_id) = input.repo_id.as_deref() {
|
||||
let record = db
|
||||
.get_repo_by_id(repo_id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if let Some(record) = record {
|
||||
let quarantined = db
|
||||
.is_repo_quarantined(&record.id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if !quarantined {
|
||||
crate::api::require_repo_owner(&record, caller)
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -80,17 +52,12 @@ impl MutationRoot {
|
||||
updated_at: now,
|
||||
deadline: input.deadline,
|
||||
};
|
||||
// Return the stored row, not the pre-insert input: create_task
|
||||
// normalizes the assignee (whitespace-only becomes NULL/open), and a
|
||||
// response that shows the raw input lies about the reservation.
|
||||
let stored = db
|
||||
.create_task(&task)
|
||||
db.create_task(&task)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(AgentTaskType::from(stored))
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn claim_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -106,41 +73,20 @@ impl MutationRoot {
|
||||
let assignee_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
crate::api::tasks::get_claimable_task(db, &id, caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
// Same classification REST's claim handler applies: a claim race is a
|
||||
// client-safe conflict, a genuine sqlx failure stays opaque. Routing
|
||||
// both transports through `task_write_conflict` is what stops a normal
|
||||
// race from reaching a GraphQL caller as a generic database error
|
||||
// (#327 review).
|
||||
let task = db
|
||||
.claim_task(&id, &assignee_did)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_claim_conflict)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(assignee_did.as_str())
|
||||
.to_string();
|
||||
crate::api::tasks::announce_task_event(
|
||||
db,
|
||||
tx,
|
||||
TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: assignee_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn complete_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -157,47 +103,32 @@ impl MutationRoot {
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: the task must be visible to the caller (returning
|
||||
// not found for invisible tasks so existence is not leaked), and only
|
||||
// the task's assignee may finish it.
|
||||
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
|
||||
// Authorize the actor: binding by_did to the signer is necessary but not
|
||||
// sufficient — only the task's assignee may finish it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(
|
||||
caller,
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can complete it",
|
||||
));
|
||||
}
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref(), caller)
|
||||
.finish_task(&id, "completed", input.result.as_deref())
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_finish_conflict)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(by_did.as_str())
|
||||
.to_string();
|
||||
crate::api::tasks::announce_task_event(
|
||||
db,
|
||||
tx,
|
||||
TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "completed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "completed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn fail_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -214,44 +145,29 @@ impl MutationRoot {
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: the task must be visible to the caller (returning
|
||||
// not found for invisible tasks so existence is not leaked), and only
|
||||
// the task's assignee may fail it.
|
||||
let existing = crate::api::tasks::get_visible_task(db, &id, Some(caller))
|
||||
// Authorize the actor: only the task's assignee may fail it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?
|
||||
.map_err(crate::graphql::graphql_db_err)?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(
|
||||
caller,
|
||||
crate::db::trim_assignee_did(existing.assignee_did.as_deref().unwrap_or_default()),
|
||||
) {
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can fail it",
|
||||
));
|
||||
}
|
||||
let reason = input.reason.unwrap_or_default();
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason), caller)
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_finish_conflict)?;
|
||||
let by_did = task
|
||||
.assignee_did
|
||||
.as_deref()
|
||||
.map(crate::db::trim_assignee_did)
|
||||
.unwrap_or(by_did.as_str())
|
||||
.to_string();
|
||||
crate::api::tasks::announce_task_event(
|
||||
db,
|
||||
tx,
|
||||
TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "failed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "claimed".to_string(),
|
||||
new_status: "failed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
}
|
||||
@@ -270,22 +186,6 @@ mod tests {
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// The `id` scalar returned by a successful `createTask` mutation.
|
||||
fn created_id(resp: &Response) -> String {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(task) = obj.get("createTask").expect("createTask key")
|
||||
else {
|
||||
panic!("createTask not an object");
|
||||
};
|
||||
let async_graphql::Value::String(id) = task.get("id").expect("id key") else {
|
||||
panic!("id not a string");
|
||||
};
|
||||
id.clone()
|
||||
}
|
||||
|
||||
/// N2: every GraphQL mutation requires a verified signer and binds the acting
|
||||
/// DID to it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// rejected; the matching signer passes the auth gate.
|
||||
@@ -349,7 +249,7 @@ mod tests {
|
||||
);
|
||||
if *name == "claimTask" {
|
||||
assert!(
|
||||
errs.contains("task not found"),
|
||||
errs.contains("task not claimable"),
|
||||
"claim race / missing task must keep its business message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
@@ -393,210 +293,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #327 review: `claimTask`/`completeTask`/`failTask` called
|
||||
/// `graphql_db_err` directly on db-layer business failures, so an ordinary
|
||||
/// claim race or stale finish reached GraphQL clients as a generic
|
||||
/// database error while REST clients got an actionable conflict. All three
|
||||
/// now route through the shared `task_write_conflict` classifier.
|
||||
///
|
||||
/// Deleting either `map_err` from a mutation turns this red: the message
|
||||
/// becomes the raw anyhow text instead of the fixed conflict form.
|
||||
#[sqlx::test]
|
||||
async fn task_write_races_surface_as_conflicts_not_db_errors(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let assignee = "did:key:zGQLRACEASSIGNEEAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let rival = "did:key:zGQLRACERIVALBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let delegator = "did:key:zGQLRACEDELEGATORCCCCCCCCCCCCCCCCCCCCC";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
state
|
||||
.db
|
||||
.create_repo(&crate::db::RepoRecord {
|
||||
id: "race-repo".into(),
|
||||
name: "race".into(),
|
||||
owner_did: delegator.into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/tmp/race-repo".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_task(&crate::db::AgentTask {
|
||||
id: "race-task".into(),
|
||||
repo_id: Some("race-repo".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: delegator.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The assignee wins the claim.
|
||||
let claim = |did: &str| {
|
||||
format!(r#"mutation {{ claimTask(id: "race-task", assigneeDid: "{did}") {{ id }} }}"#)
|
||||
};
|
||||
let resp = schema
|
||||
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(resp.errors.is_empty(), "first claim: {}", errors(&resp));
|
||||
|
||||
// The rival loses the race: ineligible caller receives opaque not-found.
|
||||
let resp = schema
|
||||
.execute(Request::new(claim(rival)).data(AuthenticatedDid(rival.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not found"),
|
||||
"ineligible caller after task is claimed must receive opaque not-found: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"ineligible claim is not a database failure: {errs}"
|
||||
);
|
||||
|
||||
// A second claim that reaches the conditional SQL write (e.g. assignee claiming
|
||||
// again on an already-claimed task) exercises the conflict mapper:
|
||||
let resp = schema
|
||||
.execute(Request::new(claim(assignee)).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not claimable: not found or already claimed"),
|
||||
"a lost claim race must render the fixed conflict message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a claim race is not a database failure: {errs}"
|
||||
);
|
||||
|
||||
// A stale finish on an already-completed task is the same class.
|
||||
let complete = format!(
|
||||
r#"mutation {{ completeTask(id: "race-task", byDid: "{assignee}", input: {{ result: "ok" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(resp.errors.is_empty(), "first complete: {}", errors(&resp));
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(&complete).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not found or not in claimed state"),
|
||||
"a stale complete must render the fixed conflict message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a stale complete is not a database failure: {errs}"
|
||||
);
|
||||
|
||||
let fail = format!(
|
||||
r#"mutation {{ failTask(id: "race-task", byDid: "{assignee}", input: {{ reason: "nope" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(&fail).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains("task not found or not in claimed state"),
|
||||
"a stale fail must render the fixed conflict message: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a stale fail is not a database failure: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same classification: a genuine SQL fault on the
|
||||
/// write itself must stay opaque. Without this, routing conflicts through
|
||||
/// `task_write_conflict` could be "fixed" by making every db failure a
|
||||
/// client-visible conflict message.
|
||||
#[sqlx::test]
|
||||
async fn task_write_sql_faults_stay_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let assignee = "did:key:zGQLOPAQUEASSIGNEEAAAAAAAAAAAAAAAAAAAAA";
|
||||
let delegator = "did:key:zGQLOPAQUEDELEGATORBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
state
|
||||
.db
|
||||
.create_task(&crate::db::AgentTask {
|
||||
id: "opaque-task".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: delegator.into(),
|
||||
assignee_did: Some(assignee.into()),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fault the write itself, not the schema. Dropping a column the
|
||||
// visibility pre-check also SELECTs would fail in `get_visible_task`
|
||||
// and never reach `graphql_claim_conflict`, so this test would pass
|
||||
// against a claim mapper that leaks (#327 review). A BEFORE UPDATE
|
||||
// trigger keeps every read valid and faults only inside
|
||||
// `Db::claim_task`.
|
||||
sqlx::raw_sql(
|
||||
"CREATE FUNCTION gl_test_fault_task_update() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN RAISE EXCEPTION 'gl_test_forced_update_fault'; END;
|
||||
$fn$;
|
||||
CREATE TRIGGER gl_test_fault_task_update
|
||||
BEFORE UPDATE ON agent_tasks
|
||||
FOR EACH ROW EXECUTE FUNCTION gl_test_fault_task_update();",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(
|
||||
Request::new(format!(
|
||||
r#"mutation {{ claimTask(id: "opaque-task", assigneeDid: "{assignee}") {{ id }} }}"#
|
||||
))
|
||||
.data(AuthenticatedDid(assignee.into())),
|
||||
)
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"a real sqlx fault on the write must stay opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("task not claimable"),
|
||||
"a write-time sqlx fault must not be reclassified as a claim race: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("gl_test_forced_update_fault") && !errs.contains("trigger"),
|
||||
"database text leaked through the conflict mapper: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
|
||||
/// assignee, not merely signing as the by_did you pass. A signer who is not
|
||||
/// the assignee is rejected even though the by_did binding passes; the
|
||||
@@ -619,7 +315,7 @@ mod tests {
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed task");
|
||||
@@ -636,69 +332,14 @@ mod tests {
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves on a repo-less task they cannot see:
|
||||
// invisible task returns "task not found" so existence is not leaked.
|
||||
// Stranger signs as themselves and passes byDid=self (so the signer
|
||||
// binding passes), but is not the assignee → rejected by authorization.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("task not found"),
|
||||
"an invisible task must return not found, got: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// Seed a task on a public repo that stranger CAN see, but is not assignee of:
|
||||
let pub_repo = crate::db::RepoRecord {
|
||||
id: "pub-r".into(),
|
||||
name: "pub-r".into(),
|
||||
owner_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/tmp/pub-r".into(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&pub_repo).await.expect("create repo");
|
||||
let pub_task = crate::db::AgentTask {
|
||||
id: "task-pub".into(),
|
||||
repo_id: Some("pub-r".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state
|
||||
.db
|
||||
.create_task(&pub_task)
|
||||
.await
|
||||
.expect("seed pub task");
|
||||
state
|
||||
.db
|
||||
.claim_task("task-pub", assignee)
|
||||
.await
|
||||
.expect("claim pub task");
|
||||
|
||||
let q_pub = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ completeTask(id: "task-pub", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
|
||||
)
|
||||
};
|
||||
let resp = schema
|
||||
.execute(Request::new(q_pub(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("assignee"),
|
||||
"a non-assignee signer on a visible task must be rejected: {}",
|
||||
"a non-assignee signer must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
@@ -712,341 +353,4 @@ mod tests {
|
||||
errors(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): createTask applies the same repo-ownership gate as the
|
||||
/// REST handler — a stranger naming a hosted repo is rejected without
|
||||
/// persisting anything, while the owner files. The quarantined and
|
||||
/// unknown-id arms are pinned too, including that each accepted write
|
||||
/// stores the supplied id verbatim, so this copy of the gate cannot regress
|
||||
/// silently.
|
||||
#[sqlx::test]
|
||||
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let owner = "did:key:zGQLTASKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zGQLTASKSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves (so the signer binding passes) but
|
||||
// names the victim's repo → rejected by the ownership gate, leaking
|
||||
// nothing about the repo.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
|
||||
"a non-owner must not file under a foreign repo id (shared denial text): {errs}"
|
||||
);
|
||||
|
||||
// The denial must not persist: no task row may carry the victim's
|
||||
// repo id afterwards.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks_keyset(None, None, 50, None)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
stored
|
||||
.iter()
|
||||
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
|
||||
"a denied write must not leave a task under the foreign repo id"
|
||||
);
|
||||
|
||||
// The owner files under their own repo id.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(owner)).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"the owner should file against their repo: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// Unknown repo ids stay accepted as opaque labels.
|
||||
let q_unknown = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{stranger}", input: {{ kind: "build", capability: "repo:write", repoId: "no-such-repo-id" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q_unknown).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"an id naming no hosted repo stays an opaque label: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the unresolved id must be stored verbatim, not
|
||||
// dropped or rewritten to a repo it never named.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some("no-such-repo-id"),
|
||||
"an unresolved id must be stored verbatim as a label"
|
||||
);
|
||||
|
||||
// A quarantined repo id is treated as unknown (accepted, never a 403
|
||||
// that would confirm the id names a real repo). This pins the
|
||||
// `!quarantined` arm on this copy of the gate.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&repo.id, true)
|
||||
.await
|
||||
.expect("quarantine");
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"a quarantined repo id must not 403 (existence oracle): {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the quarantined id is stored verbatim as a label
|
||||
// too, so the accepted write is not silently dropped.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some(repo.id.as_str()),
|
||||
"a quarantined id must be stored verbatim as a label"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): a repo-lookup fault in createTask's gate must stay
|
||||
/// opaque. Dropping a column `get_repo_by_id` selects faults that lookup;
|
||||
/// the resolver must return the fixed DB message and leak no schema text
|
||||
/// (#250), as `create_task_db_error_message_is_opaque` pins for the
|
||||
/// agent_tasks fault.
|
||||
#[sqlx::test]
|
||||
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner = "did:key:zGQLTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-fault-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-fault-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop machine_id");
|
||||
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{owner}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"gate sqlx fault must be opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("machine_id") && !errs.contains("column"),
|
||||
"schema text leaked through the gate: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-assigned GraphQL claimTask must reject a stranger (no UCAN leak)
|
||||
/// and admit the reserved assignee.
|
||||
#[sqlx::test]
|
||||
async fn claim_task_honors_preassigned_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let reserved = "did:key:zGQLRESERVEDAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let thief = "did:key:zGQLTHIEFBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-gql-reserved".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: Some(reserved.into()),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: Some("gql-ucan-secret".into()),
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ claimTask(id: "task-gql-reserved", assigneeDid: "{actor}") {{ id status ucanToken }} }}"#
|
||||
)
|
||||
};
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q(thief)).data(AuthenticatedDid(thief.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
// The claim gate hides existence: an ineligible claimer gets the same
|
||||
// not-found as a missing task, not a reserved deny that would leak it.
|
||||
assert!(
|
||||
errs.contains("task not found"),
|
||||
"stranger claim must read as not-found, got: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("gql-ucan-secret"),
|
||||
"UCAN must not leak in GraphQL errors: {errs}"
|
||||
);
|
||||
if let Ok(data) = resp.data.into_json() {
|
||||
let s = data.to_string();
|
||||
assert!(
|
||||
!s.contains("gql-ucan-secret"),
|
||||
"UCAN must not leak in GraphQL data on failed claim: {s}"
|
||||
);
|
||||
}
|
||||
// Parity with REST: failed steal must leave the row untouched.
|
||||
let still = state
|
||||
.db
|
||||
.get_task("task-gql-reserved")
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("task exists");
|
||||
assert_eq!(
|
||||
still.status, "pending",
|
||||
"status must stay pending after steal"
|
||||
);
|
||||
assert_eq!(
|
||||
still.assignee_did.as_deref(),
|
||||
Some(reserved),
|
||||
"assignee_did must stay reserved after steal"
|
||||
);
|
||||
|
||||
let resp = schema
|
||||
.execute(Request::new(q(reserved)).data(AuthenticatedDid(reserved.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"reserved assignee must claim: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert_eq!(data["claimTask"]["status"], "claimed");
|
||||
assert_eq!(data["claimTask"]["ucanToken"], "gql-ucan-secret");
|
||||
}
|
||||
|
||||
/// create_task normalizes a whitespace-only assignee to NULL (open task),
|
||||
/// so the create response must show the stored row, not echo the input: a
|
||||
/// client trusting the echoed "reservation" would believe a claimable task
|
||||
/// is reserved. Before the fix the response was built from the pre-insert
|
||||
/// struct and returned the padded input verbatim.
|
||||
#[sqlx::test]
|
||||
async fn create_task_reports_stored_assignee_not_input(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let caller = "did:key:zGQLCREATORAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{caller}", input: {{ kind: "build", capability: "repo:write", assigneeDid: " " }}) {{ id assigneeDid }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q).data(AuthenticatedDid(caller.into())))
|
||||
.await;
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert!(
|
||||
data["createTask"]["assigneeDid"].is_null(),
|
||||
"whitespace-only assigneeDid must come back null (stored shape): {data}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A task seeded with a whitespace-padded assignee must still be
|
||||
/// completable by that assignee over GraphQL: REST and Db::finish_task
|
||||
/// trim the stored value before comparing, and the GraphQL gate must do
|
||||
/// the same or padded reservations finish only through REST.
|
||||
#[sqlx::test]
|
||||
async fn graphql_finish_accepts_padded_stored_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let assignee = "did:key:zGQLPADDEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-gql-padded".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORDDDDDDDDDDDDDDDDDDDDDDDDDD".into(),
|
||||
assignee_did: Some(format!("\t{assignee}\n")),
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let claim = format!(
|
||||
r#"mutation {{ claimTask(id: "task-gql-padded", assigneeDid: "{assignee}") {{ id status }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(claim).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"reserved assignee must claim a padded reservation: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
let finish = format!(
|
||||
r#"mutation {{ completeTask(id: "task-gql-padded", byDid: "{assignee}", input: {{}}) {{ id status }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(finish).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.is_empty(),
|
||||
"assignee must complete a padded reservation: {errs}"
|
||||
);
|
||||
let data = resp.data.into_json().expect("json data");
|
||||
assert_eq!(data["completeTask"]["status"], "completed");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,16 +11,15 @@ pub struct SubscriptionRoot;
|
||||
|
||||
#[Subscription]
|
||||
impl SubscriptionRoot {
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted under the
|
||||
/// `optional_signature` layer and carries optional caller identity, but this
|
||||
/// resolver does not inspect it — it relays whatever enters the broadcast
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted outside the
|
||||
/// `optional_signature` layer, so this resolver has NO caller identity and
|
||||
/// cannot gate per-subscriber — it relays whatever enters the broadcast
|
||||
/// channel to any anonymous client. Its visibility safety therefore rests
|
||||
/// entirely on the WRITE side: the push handler only sends a
|
||||
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
|
||||
/// `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).
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -47,7 +46,6 @@ impl SubscriptionRoot {
|
||||
})
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task_events(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -11,14 +11,6 @@ pub struct RepoType {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject)]
|
||||
pub struct RepoPageType {
|
||||
pub nodes: Vec<RepoType>,
|
||||
pub has_next_page: bool,
|
||||
/// Position after the last returned visible repository; null for an empty page.
|
||||
pub end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct AgentTaskType {
|
||||
pub id: String,
|
||||
@@ -56,65 +48,6 @@ impl From<AgentTask> for AgentTaskType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only projection of `AgentTask` for the `tasks`/`task` queries, as
|
||||
/// opposed to `AgentTaskType`, which the task mutations (`createTask`,
|
||||
/// `claimTask`, `completeTask`, `failTask` — all `require_signer`-gated)
|
||||
/// return. Identical except for the missing `ucan_token` (#268): a read
|
||||
/// surface never needs to echo it back, since the assignee already received it
|
||||
/// at delegation/claim time via the mutation response.
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct AgentTaskReadType {
|
||||
pub id: String,
|
||||
pub repo_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub status: String,
|
||||
pub delegator_did: String,
|
||||
pub assignee_did: Option<String>,
|
||||
pub capability: String,
|
||||
pub payload: Option<String>,
|
||||
pub result: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub deadline: Option<String>,
|
||||
}
|
||||
|
||||
/// Wraps a `tasks` page with the same completion signals the REST list route
|
||||
/// exposes, so the two surfaces answer pagination identically.
|
||||
///
|
||||
/// `hasMore` and `incomplete` are separate because they are separate facts
|
||||
/// (#327 review): `hasMore` says more candidates remain, `incomplete` says
|
||||
/// this page is short *only* because the authorization scan hit its safety
|
||||
/// wall. `nextCursor` is present exactly when `hasMore` is true and is an
|
||||
/// opaque MAC'd token — it can name the last examined candidate, denied or
|
||||
/// not, without disclosing it, which is what lets a caller page past a denied
|
||||
/// window instead of stalling on it.
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct TaskPageType {
|
||||
pub items: Vec<AgentTaskReadType>,
|
||||
pub has_more: bool,
|
||||
pub incomplete: bool,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
impl From<AgentTask> for AgentTaskReadType {
|
||||
fn from(t: AgentTask) -> Self {
|
||||
Self {
|
||||
id: t.id,
|
||||
repo_id: t.repo_id,
|
||||
kind: t.kind,
|
||||
status: t.status,
|
||||
delegator_did: t.delegator_did,
|
||||
assignee_did: t.assignee_did,
|
||||
capability: t.capability,
|
||||
payload: t.payload,
|
||||
result: t.result,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
deadline: t.deadline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct RefUpdateType {
|
||||
pub repo: String,
|
||||
|
||||
@@ -922,12 +922,6 @@ async fn sweep_pass(
|
||||
// Advance FIRST: every path below this line may skip the row, and none of them
|
||||
// may wedge the walk (scenario 7).
|
||||
last = sha.clone();
|
||||
// Round-3 P1: skip Pinata-only rows whose local cid is NULL.
|
||||
// The legacy repair walk re-keys `cid` from a provider CID to
|
||||
// the raw resolver CID; a row with no local cid has no string
|
||||
// to re-key, and skipping is the natural behavior. The cursor
|
||||
// still advances so the walk does not loop on this row.
|
||||
let Some(stored) = stored else { continue };
|
||||
// Same cost gate as the skip-path repair: a canonical raw CIDv1 key is already
|
||||
// the resolver key, so it reads no bytes and resolves no repo.
|
||||
if gitlawb_core::cid::is_raw_cidv1(&stored) {
|
||||
@@ -1463,97 +1457,6 @@ fn note_legacy_repair_read() {
|
||||
/// have to be documented, validated, and kept meaningful.
|
||||
pub const PIN_BATCH_BUDGET: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A captured per-repo visibility-policy epoch that fences a pin batch.
|
||||
///
|
||||
/// The reconciliation sweep reads the epoch immediately before dispatching a
|
||||
/// pin loop and passes a fence in; the loop re-reads the epoch before every
|
||||
/// upload and aborts the batch the moment it moves. A visibility narrow that
|
||||
/// lands mid-batch (a rule made private, a repo quarantined) must not let the
|
||||
/// remaining pre-authorized objects still go to a public content-addressed
|
||||
/// backend — the narrow is a policy change, and dispatching against the stale
|
||||
/// snapshot is the exact irreversible-publication class this fence exists for
|
||||
/// (R1-P1). `None` (the push path) means "no fence": the push derives its own
|
||||
/// object list at admission and holds a write lease, so no sweep-style batch
|
||||
/// snapshot crosses the dispatch boundary.
|
||||
///
|
||||
/// The fence deliberately does NOT hold any lock across the batch: a
|
||||
/// visibility narrow (rule insert/remove, quarantine set) must commit
|
||||
/// immediately, and the batch aborts on its next `is_current` check. Holding
|
||||
/// a per-repo mutex from capture through drop would invert this — the narrow
|
||||
/// would block behind the background sweep and every object in the batch
|
||||
/// would still be sealed/posted to the reader being removed. The accepted
|
||||
/// residual is a single in-flight object: a narrow that commits between the
|
||||
/// pre-POST `is_current` check and the HTTP POST cannot be recalled, but the
|
||||
/// next iteration aborts and the fenced DB record (row-locked against the
|
||||
/// narrow's epoch bump) refuses to land the raced row as durable.
|
||||
/// Multi-process / multi-node narrows are ordered by the same epoch column;
|
||||
/// no in-process registry is involved.
|
||||
///
|
||||
/// Product decision (revocation model): prompt revocation wins over
|
||||
/// publication atomicity. The suite intentionally allows one provider
|
||||
/// upload already in flight when a rule narrows and requires every
|
||||
/// LATER object to stop (`encrypt_and_pin_stops_sealing_when_reader_
|
||||
/// removed_mid_batch`). A stronger contract — revocation commit
|
||||
/// linearizing against every concurrent publication, or compensation
|
||||
/// (unpin/delete) for an envelope that finishes after removal — is
|
||||
/// explicitly OUT of scope until decided and documented here. Do not
|
||||
/// reintroduce batch-spanning mutual exclusion to close the
|
||||
/// single-in-flight window without that decision: it trades one
|
||||
/// possibly-raced object for sealing the whole batch to a removed
|
||||
/// reader.
|
||||
#[derive(Clone)]
|
||||
pub struct PolicyFence {
|
||||
db: crate::db::Db,
|
||||
repo_id: String,
|
||||
epoch: i64,
|
||||
}
|
||||
|
||||
impl PolicyFence {
|
||||
/// Capture the current policy epoch for `repo_id`. A read failure is a
|
||||
/// skip, not a retry-with-zero: the caller must not dispatch a batch it
|
||||
/// cannot fence (fail closed on a stale allow).
|
||||
pub async fn capture(db: &crate::db::Db, repo_id: &str) -> Option<Self> {
|
||||
match db.repo_policy_epoch(repo_id).await {
|
||||
Ok(epoch) => Some(PolicyFence {
|
||||
db: db.clone(),
|
||||
repo_id: repo_id.to_string(),
|
||||
epoch,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_id, err = %e, "policy-epoch read failed; not fencing pin batch");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the repo's policy epoch is unchanged since capture. A read
|
||||
/// failure is treated as "changed": never dispatch on a policy we cannot
|
||||
/// prove current.
|
||||
pub async fn is_current(&self) -> bool {
|
||||
match self.db.repo_policy_epoch(&self.repo_id).await {
|
||||
Ok(epoch) => epoch == self.epoch,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The epoch value captured at `capture` time. Exposed so the
|
||||
/// pinner can pass it to
|
||||
/// `Db::record_pinned_cid_with_source_fenced` — the third
|
||||
/// fence in the same transaction as the row insert.
|
||||
/// Returning the field directly (rather than a `Option`)
|
||||
/// matches the contract: a `PolicyFence` always has a
|
||||
/// captured epoch; `is_current()` reports whether it still
|
||||
/// matches.
|
||||
pub fn captured_epoch(&self) -> i64 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// The repo this fence guards, for log correlation.
|
||||
pub fn repo_id(&self) -> &str {
|
||||
&self.repo_id
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest remainder worth starting a bounded git read (or an add) with.
|
||||
///
|
||||
/// A 1ms remainder otherwise buys a child spawned already past its deadline, which
|
||||
@@ -1605,7 +1508,6 @@ pub async fn pin_git_object(
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let expected_cid = Cid::from_git_object_bytes(data).to_string();
|
||||
let url = format!(
|
||||
"{}/api/v0/add?cid-version=1&raw-leaves=true&pin=true",
|
||||
ipfs_api.trim_end_matches('/')
|
||||
@@ -1648,15 +1550,6 @@ pub async fn pin_git_object(
|
||||
|
||||
// Kubo returns newline-delimited JSON; we only care about the last object
|
||||
// (there's typically just one for a single-file add).
|
||||
//
|
||||
// The response MUST carry a real `Hash`: a misconfigured `GITLAWB_IPFS_API`
|
||||
// (proxy returning HTML, health check on the wrong port, truncated gateway)
|
||||
// can otherwise answer 2xx with no JSON, and falling back to the locally
|
||||
// computed `expected_cid` would record a row for bytes the backend never
|
||||
// stored. The reconciliation sweep trusts `pinned_cids` rows as durability
|
||||
// evidence, so a silent false positive at pin time becomes a permanent blind
|
||||
// spot for the backstop. A missing `Hash` fails the pin rather than recording
|
||||
// a phantom row (mirrors Pinata's `data.cid` check).
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
@@ -1672,26 +1565,13 @@ pub async fn pin_git_object(
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
v["Hash"].as_str().map(|s| s.to_string())
|
||||
})
|
||||
.next_back();
|
||||
let cid = match cid {
|
||||
Some(cid) => {
|
||||
if cid != expected_cid {
|
||||
tracing::warn!(
|
||||
sha256 = %sha256_hex,
|
||||
returned = %cid,
|
||||
expected = %expected_cid,
|
||||
"IPFS returned a different CID than computed locally (Kubo chunking may differ); recording the backend's answer"
|
||||
);
|
||||
}
|
||||
cid
|
||||
}
|
||||
None => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"IPFS /api/v0/add returned 2xx without a Hash field; refusing to record \
|
||||
a CID the backend never acknowledged (misconfigured GITLAWB_IPFS_API?)"
|
||||
));
|
||||
}
|
||||
};
|
||||
.next_back()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"IPFS /api/v0/add returned 200 without a usable Hash: {:.200}",
|
||||
body
|
||||
)
|
||||
})?;
|
||||
// The string still has to be a CID. A garbage Hash would be recorded and
|
||||
// later cat-ed the same way a missing one was, so reject it here.
|
||||
Cid::from_str(&cid).map_err(|e| {
|
||||
@@ -1774,8 +1654,8 @@ pub(crate) fn batch_budget_gate(
|
||||
/// than [`PIN_READ_FLOOR`] left. It is a gate, not a hard ceiling, since a started
|
||||
/// iteration still runs to completion;
|
||||
/// - the git read: `store::read_object_bounded` runs under `spawn_blocking` against the
|
||||
/// ABSOLUTE batch deadline (not the loop-top remainder, which the `has_ipfs_cid`
|
||||
/// round-trip sitting between the two would push past it), with SIGTERM-then-SIGKILL
|
||||
/// ABSOLUTE batch deadline (not the loop-top remainder, which the `is_pinned` round-trip
|
||||
/// sitting between the two would push past it), with SIGTERM-then-SIGKILL
|
||||
/// process-group teardown, so a hung `git cat-file` costs this batch its remaining
|
||||
/// budget plus one watchdog teardown instead of holding the permit for the child's
|
||||
/// whole lifetime and blocking a runtime worker while it does;
|
||||
@@ -1819,11 +1699,10 @@ pub(crate) fn batch_budget_gate(
|
||||
/// # Truncation semantics
|
||||
///
|
||||
/// A batch stopped at the deadline leaves its remaining objects unpinned, and
|
||||
/// nothing sweeps them up afterwards on the push path; recovery is opportunistic
|
||||
/// (a later full-scan push re-offers the skipped OIDs). The reconciliation
|
||||
/// sweep is the systematic backstop: when it is enabled and a pin backend is
|
||||
/// configured, it re-derives the public object set each pass and fills any
|
||||
/// remaining gap.
|
||||
/// nothing sweeps them up afterwards. There is no reconciliation pass over
|
||||
/// `pinned_cids`; recovery is opportunistic, happening only if some later push
|
||||
/// on the repo takes the full-scan fallback (`push_delta::list_all_objects`) and
|
||||
/// re-derives the whole object set, which then re-offers the skipped OIDs.
|
||||
///
|
||||
/// The twin in `pinata.rs` is back at parity on everything that bounds or repairs an
|
||||
/// object: it runs the same shared budget gate at the top of every iteration, the same
|
||||
@@ -1840,29 +1719,6 @@ pub(crate) fn batch_budget_gate(
|
||||
///
|
||||
/// Returns a list of `(sha256_hex, cid)` pairs pinned AND durably recorded this
|
||||
/// call.
|
||||
/// What one `pin_new_objects` call (IPFS or Pinata twin) did, with the
|
||||
/// three backend states kept apart instead of inferred from each other:
|
||||
///
|
||||
/// - `confirmed`: `(sha, provider_cid)` pairs whose DB record durably
|
||||
/// landed. ONLY these may advance `gaps_filled`, branch/gossip CID
|
||||
/// state, or any "repaired" bookkeeping. A provider upload whose
|
||||
/// record timed out, failed, or was refused by the fence is absent
|
||||
/// here even though the bytes may sit on the provider.
|
||||
/// - `last_attempted`: the last OID whose loop body was entered — i.e.
|
||||
/// the backend did real work for it (reads, an upload attempt, or a
|
||||
/// skip-branch decision), whether that work succeeded, failed, or hit
|
||||
/// an unknown outcome. `None` when nothing was entered (empty input,
|
||||
/// immediate fence abort, or immediate budget gate). Durable
|
||||
/// continuation cursors advance to this, never to the tail of the
|
||||
/// planned vector: the tail may never have been visited, and
|
||||
/// promoting it would rotate an untouched suffix behind the backlog
|
||||
/// forever.
|
||||
#[derive(Debug)]
|
||||
pub struct PinBatchOutcome {
|
||||
pub confirmed: Vec<(String, String)>,
|
||||
pub last_attempted: Option<String>,
|
||||
}
|
||||
|
||||
// Eight because #173's git seam (`git_bin`, `git_timeout`) and pin provenance
|
||||
// (`repo_id`) sit alongside #174's batch budget. All four callers pass every one, and
|
||||
// grouping them into a context struct would add a type whose only job is to be
|
||||
@@ -1877,34 +1733,16 @@ pub async fn pin_new_objects(
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
batch_budget: Duration,
|
||||
fence: Option<&PolicyFence>,
|
||||
) -> PinBatchOutcome {
|
||||
) -> Vec<(String, String)> {
|
||||
if ipfs_api.is_empty() {
|
||||
return PinBatchOutcome {
|
||||
confirmed: Vec::new(),
|
||||
last_attempted: None,
|
||||
};
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + batch_budget;
|
||||
let total = object_list.len();
|
||||
let mut pinned = Vec::new();
|
||||
let mut last_attempted: Option<String> = None;
|
||||
|
||||
for (attempted, sha) in object_list.into_iter().enumerate() {
|
||||
// Policy fence (R1-P1): a visibility narrow that lands after the caller
|
||||
// built this batch must abort it before the next irreversible upload.
|
||||
// Checked FIRST so a changed policy costs nothing beyond the read.
|
||||
if let Some(f) = fence {
|
||||
if !f.is_current().await {
|
||||
tracing::warn!(
|
||||
repo = %f.repo_id,
|
||||
unattempted = total - attempted,
|
||||
"visibility policy changed mid-batch; stopping the pin loop"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Top of the iteration, before any of this object's work: an object is
|
||||
// never started with a remainder too small to cover a bounded read's
|
||||
// teardown. Consumed as a guard only: the read below runs against the
|
||||
@@ -1913,40 +1751,19 @@ pub async fn pin_new_objects(
|
||||
if batch_budget_gate("IPFS", deadline, pinned.len(), total - attempted).is_none() {
|
||||
break;
|
||||
}
|
||||
// Attempt progress (not a durability claim): from here the loop does
|
||||
// real work for this OID — skip-branch reads, airgapped git reads,
|
||||
// an upload attempt — whatever the outcome. The caller persists
|
||||
// this as the continuation, never the planned vector's tail.
|
||||
last_attempted = Some(sha.clone());
|
||||
// Skip if the object is ALREADY a real local IPFS pin, but first
|
||||
// backfill provenance if the existing pin has none. A legacy pin
|
||||
// (recorded before repo_id existed, #173, jatmn) is skipped here
|
||||
// before record_pinned_cid ever runs, so its NULL provenance would
|
||||
// never resolve to one repo and known CIDs keep hitting the scan.
|
||||
// The backfill only sets repo_id (AND repo_id IS NULL guard
|
||||
// preserves first-pinner-owns) and never re-pins the bytes: the
|
||||
// object is already on IPFS.
|
||||
//
|
||||
// #218 review P1a: this check keys on `has_ipfs_cid` (writer-owned
|
||||
// `local_ipfs_provenance = TRUE`), NOT on row existence
|
||||
// (`is_pinned`). A Pinata-only row is `is_pinned = true` but
|
||||
// `has_ipfs_cid = false`: the bytes never reached the local IPFS
|
||||
// daemon, only Pinata, and we MUST fall through to the local
|
||||
// writer path so a real local pin lands. Using `is_pinned` here
|
||||
// was the gap that made the Pinata-only → local-IPFS repair
|
||||
// path inert: every sweep pass re-entered this arm, recorded
|
||||
// the source, and continued without ever calling
|
||||
// `pin_git_object` or `record_pinned_cid_with_source`. The flag
|
||||
// stayed FALSE forever.
|
||||
//
|
||||
// Every DB call from here to the end of the iteration is bounded
|
||||
// by the ABSOLUTE batch deadline (F3, #173): the loop runs under
|
||||
// a global pin permit and a bare await parked it for the whole
|
||||
// stall. The elapsed arm is mapped per site below, never as a
|
||||
// blanket "existing error arm": a timeout cancels the client
|
||||
// future but not the statement Postgres is running, so it
|
||||
// reports an UNKNOWN outcome, not a failed write.
|
||||
match db_bounded(deadline, db.has_ipfs_cid(&sha)).await {
|
||||
// Skip if already pinned, but first backfill provenance if the existing
|
||||
// pin has none. A legacy pin (recorded before repo_id existed, #173, jatmn)
|
||||
// is skipped here before record_pinned_cid ever runs, so its NULL provenance
|
||||
// would never resolve to one repo and known CIDs keep hitting the scan. The
|
||||
// backfill only sets repo_id (AND repo_id IS NULL guard preserves
|
||||
// first-pinner-owns) and never re-pins the bytes: the object is already on IPFS.
|
||||
// Every DB call from here to the end of the iteration is bounded by the
|
||||
// ABSOLUTE batch deadline (F3, #173): the loop runs under a global pin permit
|
||||
// and a bare await parked it for the whole stall. The elapsed arm is mapped per
|
||||
// site below, never as a blanket "existing error arm": a timeout cancels the
|
||||
// client future but not the statement Postgres is running, so it reports an
|
||||
// UNKNOWN outcome, not a failed write.
|
||||
match db_bounded(deadline, db.is_pinned(&sha)).await {
|
||||
Ok(true) => {
|
||||
// Elapsed here is free to skip: these are reads, so a late server-side
|
||||
// completion costs nothing, and the backfill's own `AND repo_id IS NULL`
|
||||
@@ -2056,7 +1873,7 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking IPFS pinned status");
|
||||
tracing::warn!(sha = %sha, err = %e, "DB error checking pinned status");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2070,7 +1887,7 @@ pub async fn pin_new_objects(
|
||||
// own deadline regardless.
|
||||
//
|
||||
// The read runs against the ABSOLUTE batch deadline, not against the remainder
|
||||
// measured at the top of the iteration: the `has_ipfs_cid` round-trip above sits
|
||||
// measured at the top of the iteration: the `is_pinned` round-trip above sits
|
||||
// between the two, so `Instant::now() + budget_left` would land past `deadline`
|
||||
// by however long the DB took, and under a saturated pool that is the dominant
|
||||
// term. A slow DB check must not push the read's own bound out.
|
||||
@@ -2155,24 +1972,6 @@ pub async fn pin_new_objects(
|
||||
break;
|
||||
};
|
||||
|
||||
// Dispatch fence (R1-P1): re-read the policy epoch immediately before
|
||||
// the irreversible HTTP POST. The iteration-top check catches a narrow
|
||||
// that landed before work began; THIS check catches a narrow that landed
|
||||
// during the has_ipfs_cid round-trip or the bounded Git read — both of
|
||||
// which can take seconds and during which a quarantine or rule change may
|
||||
// have committed. Without this, stale plaintext can start uploading
|
||||
// under authorization that is no longer current.
|
||||
if let Some(f) = fence {
|
||||
if !f.is_current().await {
|
||||
tracing::warn!(
|
||||
repo = %f.repo_id,
|
||||
unattempted = total - attempted,
|
||||
"visibility policy changed during preparation; aborting IPFS upload"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pin to IPFS
|
||||
match pin_git_object(ipfs_api, &sha, &data, Some(add_timeout)).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
@@ -2222,31 +2021,7 @@ pub async fn pin_new_objects(
|
||||
// per-object failure.
|
||||
match db_bounded(
|
||||
db_record_deadline(deadline),
|
||||
retry_db_record(|| {
|
||||
// #218 review round 9 (guidance #3 —
|
||||
// linearization): always go through the
|
||||
// fenced form. The fence is either
|
||||
// captured (sweep / public-pin path: the
|
||||
// third fence is the linearization point
|
||||
// that closes the rule-write /
|
||||
// record-write race) or absent
|
||||
// (push-side admission where the
|
||||
// decision is made at request time —
|
||||
// we pass `i64::MAX` as a sentinel that
|
||||
// the fenced form treats as "no fence
|
||||
// check"). The 3-arg
|
||||
// `record_pinned_cid_with_source` is
|
||||
// still available for tests that don't
|
||||
// own a fence, but the production
|
||||
// pinner routes through here.
|
||||
let fence_epoch = fence.map(|f| f.captured_epoch()).unwrap_or(i64::MAX);
|
||||
db.record_pinned_cid_with_source_fenced(
|
||||
&sha,
|
||||
&raw_cid,
|
||||
repo_id,
|
||||
fence_epoch,
|
||||
)
|
||||
}),
|
||||
retry_db_record(|| db.record_pinned_cid_with_source(&sha, &raw_cid, repo_id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2263,10 +2038,7 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
}
|
||||
|
||||
PinBatchOutcome {
|
||||
confirmed: pinned,
|
||||
last_attempted,
|
||||
}
|
||||
pinned
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2602,74 +2374,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The misconfigured-`GITLAWB_IPFS_API` false positive (P3): a 2xx response
|
||||
/// that carries no `Hash` field (proxy returning HTML, health check on the
|
||||
/// wrong port, truncated gateway) must FAIL the pin, not fall back to the
|
||||
/// locally computed `expected_cid`. Falling back records a `pinned_cids`
|
||||
/// row for bytes the backend never stored, and the reconciliation sweep
|
||||
/// trusts rows as durability evidence — so the false positive becomes a
|
||||
/// permanent blind spot for the backstop. A missing `Hash` must surface as
|
||||
/// an explicit error, never a successful pin.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_rejects_a_2xx_without_a_hash_field() {
|
||||
let endpoint = empty_ok_endpoint().await;
|
||||
let inner = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_git_object(&endpoint, "deadbeef", b"some object bytes\n", None),
|
||||
)
|
||||
.await
|
||||
.expect("wedge guard: an immediate empty 200 cannot take 30s");
|
||||
let err = inner.expect_err(
|
||||
"a 2xx without a Hash field must not surface as a successful pin \
|
||||
(would record a phantom pinned_cids row the sweep then trusts)",
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("without a Hash field"),
|
||||
"the error must name the missing Hash so operators diagnose the endpoint: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A 200 that answers with an empty body and no `Hash` — the exact shape of
|
||||
/// a proxy or health-check endpoint mistaken for a Kubo API.
|
||||
async fn empty_ok_endpoint() -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let mut acc = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = match sock.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
acc.extend_from_slice(&buf[..n]);
|
||||
if let Some(hdr_end) =
|
||||
acc.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
|
||||
{
|
||||
let headers = String::from_utf8_lossy(&acc[..hdr_end]).to_lowercase();
|
||||
let len: usize = headers
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("content-length:"))
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
if acc.len() >= hdr_end + len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
||||
.await;
|
||||
let _ = sock.flush().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// The second unhardened sink, reached from `sync.rs`. Same shape as above.
|
||||
#[tokio::test]
|
||||
async fn cat_against_silent_endpoint_errors_within_its_own_timeout() {
|
||||
@@ -2782,16 +2486,15 @@ mod tests {
|
||||
&db,
|
||||
"repo-batch-budget",
|
||||
Duration::from_millis(5500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("wedge guard: a 5.5s budget cannot take 30s");
|
||||
|
||||
assert!(
|
||||
(1..=3).contains(&pinned.confirmed.len()),
|
||||
(1..=3).contains(&pinned.len()),
|
||||
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
|
||||
pinned.confirmed.len()
|
||||
pinned.len()
|
||||
);
|
||||
let text = logs.text();
|
||||
let warns: Vec<&str> = text
|
||||
@@ -2813,52 +2516,9 @@ mod tests {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
|
||||
assert!(
|
||||
unattempted >= 1 && unattempted + pinned.confirmed.len() <= 5,
|
||||
unattempted >= 1 && unattempted + pinned.len() <= 5,
|
||||
"unattempted={unattempted} with {} pinned is not a partial batch of five",
|
||||
pinned.confirmed.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The outcome reports the last object actually ENTERED, not the planned
|
||||
/// tail: the first upload hangs 6s against a ~2s per-request timeout, so
|
||||
/// it fails, and the loop-top gate breaks the batch before the second
|
||||
/// object starts. `last_attempted` must be the first OID — the only one
|
||||
/// the loop body entered — even though nothing was confirmed and two
|
||||
/// OIDs were never visited. A cursor persisted from `to_pin.last()`
|
||||
/// would rotate the untouched suffix behind the backlog forever.
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_reports_last_attempted_not_planned_tail(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool);
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("attempted.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 3);
|
||||
let endpoint = delaying_endpoint(vec![Duration::from_secs(6)]).await;
|
||||
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&endpoint,
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids.clone(),
|
||||
&db,
|
||||
"repo-attempted",
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a 2s budget cannot take 30s");
|
||||
assert_eq!(
|
||||
outcome.last_attempted,
|
||||
Some(oids[0].clone()),
|
||||
"only the first object was entered; the planned tail was never visited"
|
||||
);
|
||||
assert!(
|
||||
outcome.confirmed.is_empty(),
|
||||
"the hung upload timed out, so nothing was confirmed"
|
||||
pinned.len()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2893,13 +2553,12 @@ mod tests {
|
||||
&db,
|
||||
"repo-batch-continues",
|
||||
Duration::from_secs(90),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("wedge guard: a 13s add plus an immediate one cannot take 60s");
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
2,
|
||||
"a slow but progressing endpoint must pin both objects: an upload past the client's \
|
||||
10s default is not a dead endpoint"
|
||||
@@ -2932,12 +2591,11 @@ mod tests {
|
||||
&db,
|
||||
"repo-batch-rejects",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a rejecting endpoint answers immediately, so this cannot take 30s");
|
||||
assert!(pinned.confirmed.is_empty(), "every add was rejected");
|
||||
assert!(pinned.is_empty(), "every add was rejected");
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
4,
|
||||
@@ -2980,7 +2638,6 @@ mod tests {
|
||||
/// type probe carries its oid on stdin rather than in argv, so an oid appears in
|
||||
/// the log only once an object has already got past its probe, and a healthy
|
||||
/// object costs two invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
@@ -3031,7 +2688,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3042,7 +2698,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -3119,7 +2775,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3134,7 +2789,7 @@ mod tests {
|
||||
that can only be spawned and reaped"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
1,
|
||||
"the first object is inside the budget and must pin: {pinned:?}"
|
||||
);
|
||||
@@ -3193,7 +2848,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3203,7 +2857,7 @@ mod tests {
|
||||
|
||||
if genuinely_unreadable {
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"nothing can be pinned through a store that cannot be read: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -3268,7 +2922,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3284,7 +2937,7 @@ mod tests {
|
||||
must still be attempted, got {attempted} of {}",
|
||||
oids.len()
|
||||
);
|
||||
let pinned_shas: Vec<&String> = pinned.confirmed.iter().map(|(sha, _)| sha).collect();
|
||||
let pinned_shas: Vec<&String> = pinned.iter().map(|(sha, _)| sha).collect();
|
||||
let expected: Vec<&String> = oids.iter().filter(|o| !tainted.contains(o)).collect();
|
||||
assert_eq!(
|
||||
pinned_shas, expected,
|
||||
@@ -3337,7 +2990,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3349,7 +3001,7 @@ mod tests {
|
||||
"an object-scoped fault must not stop the batch: every object must be read"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
4,
|
||||
"one corrupt object must cost only itself: the other four must still pin"
|
||||
);
|
||||
@@ -3572,7 +3224,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-stalled-db",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3583,7 +3234,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"a stalled pinned-status check cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -3643,7 +3294,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-skip-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -3654,7 +3304,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"an already-pinned object is skipped, never re-pinned: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -3710,17 +3360,13 @@ mod tests {
|
||||
&db,
|
||||
"repo-multi-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("three stalled objects must still cost one budget, not three");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
"nothing can pin against a stalled DB"
|
||||
);
|
||||
assert!(pinned.is_empty(), "nothing can pin against a stalled DB");
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"three stalled objects must charge ONE budget (1.5s), not one each; got {elapsed:?}"
|
||||
@@ -3743,7 +3389,7 @@ mod tests {
|
||||
///
|
||||
/// The lock time is a MARGIN, not a boundary: taking it at 100ms left `is_pinned`
|
||||
/// racing it on a loaded box, and losing that race makes the read block, time out,
|
||||
/// and break the batch, which fails on `pinned.confirmed.len() == 1` for a reason that has
|
||||
/// and break the batch, which fails on `pinned.len() == 1` for a reason that has
|
||||
/// nothing to do with the floor. Any time between the `is_pinned` round trip and
|
||||
/// the add's 1.7s return proves the same thing.
|
||||
#[sqlx::test]
|
||||
@@ -3781,7 +3427,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-spent-budget",
|
||||
Duration::from_millis(2000),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, locker);
|
||||
@@ -3794,7 +3439,7 @@ mod tests {
|
||||
nothing can resolve the CID"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
1,
|
||||
"the durably recorded pin must be returned: {pinned:?}"
|
||||
);
|
||||
@@ -3855,7 +3500,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-definite-error",
|
||||
Duration::from_millis(1200),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, commit);
|
||||
@@ -3937,7 +3581,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-marker-floor",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, controller);
|
||||
@@ -3948,7 +3591,7 @@ mod tests {
|
||||
drop(sources_lock);
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"an already-pinned object is skipped, never re-pinned: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -4042,7 +3685,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-repair-stalled",
|
||||
Duration::from_millis(2200),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, mut cids_lock) = tokio::join!(pin, controller);
|
||||
|
||||
+54
-329
@@ -7,7 +7,6 @@ mod bootstrap;
|
||||
mod cert;
|
||||
mod config;
|
||||
mod db;
|
||||
mod durable_outbox;
|
||||
mod encrypted_pin;
|
||||
mod error;
|
||||
mod git;
|
||||
@@ -19,13 +18,11 @@ mod operator;
|
||||
mod p2p;
|
||||
mod pinata;
|
||||
mod rate_limit;
|
||||
mod reconciliation;
|
||||
mod server;
|
||||
mod state;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod upstream_mirror;
|
||||
mod visibility;
|
||||
mod webhooks;
|
||||
|
||||
@@ -54,7 +51,6 @@ use state::AppState;
|
||||
struct DegradedState {
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Two independent counters with no cross-field invariant — atomics, not a
|
||||
@@ -163,7 +159,6 @@ async fn main() -> Result<()> {
|
||||
degraded_listener,
|
||||
node_did.to_string(),
|
||||
Arc::clone(&db_startup),
|
||||
config.web_url.clone(),
|
||||
db_ready_rx,
|
||||
shutdown_tx.subscribe(),
|
||||
));
|
||||
@@ -309,15 +304,10 @@ async fn main() -> Result<()> {
|
||||
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
|
||||
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
|
||||
|
||||
// Node-keyed, so continuation tokens this node mints are only accepted by
|
||||
// this node and survive its restarts without any configured secret.
|
||||
let task_cursor_key = api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
|
||||
|
||||
let graphql_schema = Arc::new(graphql::build_schema(
|
||||
Arc::clone(&db),
|
||||
ref_update_tx.clone(),
|
||||
task_event_tx.clone(),
|
||||
task_cursor_key.clone(),
|
||||
));
|
||||
|
||||
let machine_id = std::env::var("FLY_MACHINE_ID").ok();
|
||||
@@ -358,14 +348,9 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory. Max and window
|
||||
// are operator-configurable via GITLAWB_RATE_LIMIT_MAX /
|
||||
// GITLAWB_RATE_LIMIT_WINDOW_SECS; both reject 0 at parse time.
|
||||
let rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
config.rate_limit_max_requests as usize,
|
||||
std::time::Duration::from_secs(config.rate_limit_window_secs),
|
||||
200_000,
|
||||
);
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
let rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
|
||||
|
||||
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
|
||||
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
|
||||
@@ -444,8 +429,6 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
let federated_rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(12, std::time::Duration::from_secs(60), 10_000);
|
||||
if config.sync_trigger_rate_limit == 0 {
|
||||
tracing::warn!(
|
||||
"GITLAWB_SYNC_TRIGGER_RATE_LIMIT=0 — /sync/trigger IP rate limiting disabled"
|
||||
@@ -463,7 +446,6 @@ async fn main() -> Result<()> {
|
||||
db,
|
||||
node_did: node_did.clone(),
|
||||
node_keypair: keypair,
|
||||
task_cursor_key,
|
||||
p2p: p2p_handle,
|
||||
http_client,
|
||||
ref_update_tx,
|
||||
@@ -485,7 +467,6 @@ async fn main() -> Result<()> {
|
||||
push_limiter_trust,
|
||||
sync_trigger_rate_limiter,
|
||||
peer_write_rate_limiter,
|
||||
federated_rate_limiter,
|
||||
shutdown_tx: shutdown_tx.clone(),
|
||||
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_git_ops)),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(
|
||||
@@ -562,19 +543,11 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
task_read_rate_limiter: rate_limit::RateLimiter::new_bounded(
|
||||
config.task_read_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
git_bin: "git".to_string(),
|
||||
};
|
||||
if config.ipfs_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled");
|
||||
}
|
||||
if config.task_read_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_TASK_READ_RATE_LIMIT=0 — per-IP task read rate limiting disabled");
|
||||
}
|
||||
|
||||
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
|
||||
// we still set the gauge to 0 so dashboards don't show "no data".
|
||||
@@ -631,13 +604,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
let _legacy_cid_sweep = spawn_legacy_cid_sweep(&state, &config);
|
||||
let _queue_lifecycle_sweep = spawn_queue_lifecycle_sweep(&state, &config);
|
||||
// NOTE: the due-request worker is spawned AFTER the one-shot startup
|
||||
// reconcile/drain below, not here: its 5s interval would otherwise tick
|
||||
// during operator/PoS setup and interleave with boot recovery on backlog
|
||||
// rows (state gates prevent data loss, but the interleave costs recovery
|
||||
// latency and widens the reconcile/drain race window).
|
||||
let _marker_cleanup_worker = spawn_marker_cleanup_worker(&state);
|
||||
|
||||
let router = server::build_router(state.clone());
|
||||
// Re-register the socket bound at startup — same fd, so there was never a
|
||||
@@ -695,46 +661,6 @@ async fn main() -> Result<()> {
|
||||
info!("auto-sync worker started");
|
||||
}
|
||||
|
||||
// External-forge mirroring is a separate, default-off worker. Startup
|
||||
// validation already requires owner-only pushes when it is enabled; the
|
||||
// worker additionally verifies the Git runtime can pin DNS answers before
|
||||
// any URL reaches `git fetch`.
|
||||
if config.upstream_mirror_enabled {
|
||||
upstream_mirror::start(
|
||||
Arc::clone(&state.db),
|
||||
Arc::clone(&state.config),
|
||||
state.repo_store.clone(),
|
||||
state.repo_write_leases.clone(),
|
||||
Arc::clone(&state.git_write_semaphore),
|
||||
state.subscribe_shutdown(),
|
||||
)
|
||||
.context("starting upstream mirror worker")?;
|
||||
}
|
||||
|
||||
// Periodic reconciliation sweep: re-derives pin/seal sets and fills gaps
|
||||
// so a dropped replication job never means data loss.
|
||||
{
|
||||
let db = state.db.clone();
|
||||
let config = Arc::clone(&state.config);
|
||||
let http_client = Arc::clone(&state.http_client);
|
||||
let node_keypair = Arc::clone(&state.node_keypair);
|
||||
let node_did = state.node_did.clone();
|
||||
let pin_sem = Arc::clone(&state.pin_semaphore);
|
||||
let shutdown_rx = state.subscribe_shutdown();
|
||||
if reconciliation::spawn(
|
||||
db,
|
||||
config,
|
||||
http_client,
|
||||
node_keypair,
|
||||
node_did,
|
||||
pin_sem,
|
||||
shutdown_rx,
|
||||
Some(state.repo_store.clone()),
|
||||
) {
|
||||
info!("reconciliation sweep worker started");
|
||||
}
|
||||
}
|
||||
|
||||
// On-chain operator setup: verify stake + spawn heartbeat loop
|
||||
if !state.config.contract_node_staking.is_empty()
|
||||
&& !state.config.operator_private_key.is_empty()
|
||||
@@ -769,63 +695,6 @@ async fn main() -> Result<()> {
|
||||
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
|
||||
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
|
||||
|
||||
// #26 Split PR 1: drain any `applied` rows left by a previous
|
||||
// process that crashed after Git applied a ref but before the
|
||||
// bookkeeping landed. Runs once, BEFORE the server accepts new
|
||||
// pushes, so a recovery re-derivation does not race a fresh push.
|
||||
// Non-fatal: a transient drain failure is logged and the rows
|
||||
// remain `applied` for the next startup to pick up.
|
||||
//
|
||||
// P1-A: the reconcile step runs FIRST and promotes any `prepared`
|
||||
// or `uncertain` row whose target SHA actually landed on disk.
|
||||
// This is the path that recovers a ref when the post-receive
|
||||
// `mark_pending_ref_transitions_applied` call errored or was
|
||||
// interrupted after `receive_pack` returned Ok, or when
|
||||
// receive-pack returned Err but some refs may have landed
|
||||
// (the `uncertain` state). Without this step, the drain (gated
|
||||
// on `state = 'applied'`) would never see those rows.
|
||||
//
|
||||
// P2 (reviewer-1/2 round 3): use the multi-pass reconcile so
|
||||
// prepared/uncertain rows beyond the first 1000-row page are
|
||||
// processed in the same startup, rather than waiting for the
|
||||
// next restart (where they might age out of MAX_RECONCILE_AGE).
|
||||
match durable_outbox::reconcile_prepared_from_disk_all(
|
||||
state.clone(),
|
||||
durable_outbox::DRAIN_PER_PASS_LIMIT,
|
||||
durable_outbox::DRAIN_MAX_PASSES,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(
|
||||
n,
|
||||
"reconciled prepared/uncertain -> applied via on-disk ref match"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
err = %e,
|
||||
"pending ref transition reconcile failed at startup (non-fatal; will retry on next start)"
|
||||
),
|
||||
}
|
||||
match durable_outbox::drain_receive_pack_requests_all(
|
||||
state.clone(),
|
||||
durable_outbox::DRAIN_PER_PASS_LIMIT,
|
||||
durable_outbox::DRAIN_MAX_PASSES,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(n, "drained pending ref transitions from prior run"),
|
||||
Err(e) => warn!(
|
||||
err = %e,
|
||||
"pending ref transition drain failed at startup (non-fatal; will retry on next start)"
|
||||
),
|
||||
}
|
||||
|
||||
// Start the due-request worker only now that boot recovery is done (see
|
||||
// the note at the other spawns above): its first tick must not interleave
|
||||
// with the one-shot reconcile/drain on backlog rows.
|
||||
let _due_request_worker = spawn_due_request_worker(&state);
|
||||
|
||||
// `into_make_service_with_connect_info` exposes the socket peer address as
|
||||
// `ConnectInfo<SocketAddr>` so the push limiter can key on the real client
|
||||
// when no trusted proxy header applies (see `rate_limit::client_key`).
|
||||
@@ -858,158 +727,6 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// U4 (#173): spawn the periodic legacy provider-CID repair sweep. Releases before this
|
||||
/// version stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`,
|
||||
/// and this version's `/ipfs/{cid}` resolver withholds any row whose stored key is not
|
||||
/// the raw-content CID. The opportunistic repair on the pin path only fires when a push
|
||||
/// re-carries the object, which normal git negotiation makes it not do, so those rows
|
||||
/// need a walk. DETACHED, never on the boot path: the caller keeps serving while this
|
||||
/// runs, and the sweep's own batch bound plus inter-batch delay keep it off the DB's
|
||||
/// critical path. Its cursor is durable, so a restart mid-walk resumes instead of
|
||||
/// rewinding. It re-arms after every run, including a failed one, so it never returns
|
||||
/// and there is no awaited value to log here; the shutdown watcher below is what ends
|
||||
/// it.
|
||||
///
|
||||
/// A named function rather than an inline block in `main` so the WIRING has a seam a
|
||||
/// test can call: that the task is spawned at all, that it reads its batch and delay
|
||||
/// from the config knobs rather than some other field, that the caller is not blocked
|
||||
/// on it, and that the shutdown watcher actually ends it mid-walk. The sweep's own
|
||||
/// behavior is covered elsewhere; this is the boot-path half.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
// Shutdown mid-walk simply drops the run; the persisted cursor means the
|
||||
// next boot picks up where this one stopped.
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// #26 Split PR 1 step 4 — periodic queue-lifecycle purge. Runs on
|
||||
/// the same detached task pattern as `spawn_legacy_cid_sweep`:
|
||||
/// tokio::spawn with a shutdown watcher, never on the boot path. The
|
||||
/// interval is fixed at 24 hours (the spec calls for "one per
|
||||
/// cluster per day"); the inter-batch delay is implicit in the
|
||||
/// batch size plus the wall-clock cost of each pass. The drain
|
||||
/// (`drain_receive_pack_requests_all`) and the purge
|
||||
/// (`purge_request_queue`) share the same `DRAIN_PER_PASS_LIMIT`
|
||||
/// budget so a 1000-row purge pass takes roughly the same time as
|
||||
/// a 1000-row drain pass.
|
||||
fn spawn_queue_lifecycle_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let app_state = state.clone();
|
||||
let retention_days = config.queue_retention_days;
|
||||
let batch = config.queue_purge_batch;
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs.min(30));
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// Consume the immediate first tick: Tokio intervals fire
|
||||
// immediately on creation, which would run the purge
|
||||
// concurrently with startup reconcile/drain. The child purge
|
||||
// is terminal-parent-gated in SQL, but delaying the first
|
||||
// sweep a full period removes the race entirely.
|
||||
interval.tick().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if let Err(e) = durable_outbox::purge_request_queue(
|
||||
&app_state.db,
|
||||
retention_days,
|
||||
batch,
|
||||
).await {
|
||||
tracing::warn!(err = %e, "queue lifecycle purge failed; will retry on next tick");
|
||||
}
|
||||
// Drain marker tombstones with the bounded deleter;
|
||||
// failures retain the tombstone for the next tick.
|
||||
if let Err(e) = durable_outbox::drain_marker_cleanup_queue(
|
||||
&app_state,
|
||||
batch,
|
||||
git_timeout,
|
||||
).await {
|
||||
tracing::warn!(err = %e, "marker cleanup drain failed");
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Shutdown-aware background due-request loop. The startup drain runs
|
||||
/// once; without this, `effects_pending` rows with a future
|
||||
/// `next_attempt_at` would wait until the next process restart.
|
||||
/// Polls the indexed due query with bounded batches and failure
|
||||
/// isolation (one request's error never aborts the batch).
|
||||
fn spawn_due_request_worker(state: &AppState) -> tokio::task::JoinHandle<()> {
|
||||
let app_state = state.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match durable_outbox::drain_receive_pack_requests(
|
||||
app_state.clone(),
|
||||
durable_outbox::DRAIN_PER_PASS_LIMIT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((0, _)) => {}
|
||||
Ok((n, _)) => tracing::info!(n, "due request worker drained"),
|
||||
Err(e) => tracing::warn!(err = %e, "due request worker drain failed"),
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Dedicated marker-tombstone worker at a shorter cadence than the
|
||||
/// daily purge, so external Git refs do not linger a full period
|
||||
/// after their SQL owner is gone.
|
||||
fn spawn_marker_cleanup_worker(state: &AppState) -> tokio::task::JoinHandle<()> {
|
||||
let app_state = state.clone();
|
||||
let git_timeout =
|
||||
std::time::Duration::from_secs(app_state.config.git_service_timeout_secs.min(30));
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if let Err(e) = durable_outbox::drain_marker_cleanup_queue(
|
||||
&app_state,
|
||||
durable_outbox::DRAIN_PER_PASS_LIMIT,
|
||||
git_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(err = %e, "marker cleanup worker failed");
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
@@ -1152,12 +869,11 @@ async fn run_degraded_server(
|
||||
listener: TcpListener,
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
mut db_ready_rx: watch::Receiver<bool>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
let addr = listener.local_addr().ok();
|
||||
let router = build_degraded_router(node_did, db_startup, web_url);
|
||||
let router = build_degraded_router(node_did, db_startup);
|
||||
info!(?addr, "degraded HTTP server ready");
|
||||
|
||||
axum::serve(listener, router)
|
||||
@@ -1174,15 +890,10 @@ async fn run_degraded_server(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_degraded_router(
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
) -> Router {
|
||||
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
|
||||
let state = DegradedState {
|
||||
node_did,
|
||||
db_startup,
|
||||
web_url,
|
||||
};
|
||||
// Everything answers 503 with the same body — including /health and
|
||||
// /ready, so peer readiness probes and uptime monitors correctly see a
|
||||
@@ -1214,9 +925,6 @@ async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResp
|
||||
obj.insert("name".into(), "gitlawb-node".into());
|
||||
obj.insert("version".into(), env!("CARGO_PKG_VERSION").into());
|
||||
obj.insert("did".into(), state.node_did.clone().into());
|
||||
if let Some(web_url) = &state.web_url {
|
||||
obj.insert("web_url".into(), web_url.clone().into());
|
||||
}
|
||||
}
|
||||
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
|
||||
}
|
||||
@@ -1463,10 +1171,7 @@ mod rate_limiter_sweep_tests {
|
||||
state.close_issue_rate_limiter = RateLimiter::new(10, window);
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
|
||||
state.peer_write_rate_limiter = RateLimiter::new(10, window);
|
||||
state.federated_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_work_rate_limiter = RateLimiter::new(10, window);
|
||||
state.task_read_rate_limiter = RateLimiter::new(10, window);
|
||||
|
||||
let limiters = |s: &crate::state::AppState| {
|
||||
[
|
||||
@@ -1476,10 +1181,7 @@ mod rate_limiter_sweep_tests {
|
||||
s.close_issue_rate_limiter.clone(),
|
||||
s.sync_trigger_rate_limiter.clone(),
|
||||
s.peer_write_rate_limiter.clone(),
|
||||
s.federated_rate_limiter.clone(),
|
||||
s.ipfs_rate_limiter.clone(),
|
||||
s.ipfs_work_rate_limiter.clone(),
|
||||
s.task_read_rate_limiter.clone(),
|
||||
]
|
||||
};
|
||||
for l in limiters(&state) {
|
||||
@@ -1497,6 +1199,55 @@ mod rate_limiter_sweep_tests {
|
||||
}
|
||||
|
||||
/// it.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn gossip_ping_round(
|
||||
db: &Db,
|
||||
client: &reqwest::Client,
|
||||
failed_once: &mut HashSet<String>,
|
||||
peers: Vec<db::PeerRecord>,
|
||||
) {
|
||||
{
|
||||
let current_dids: HashSet<&str> = peers.iter().map(|peer| peer.did.as_str()).collect();
|
||||
failed_once.retain(|did| current_dids.contains(did.as_str()));
|
||||
}
|
||||
for peer in peers {
|
||||
let ok = ping_peer_readiness(client, &peer.http_url).await;
|
||||
match peer_ping_db_update(failed_once, &peer.did, ok) {
|
||||
Some(reachable) => {
|
||||
if let Err(error) = db.mark_peer_ping(&peer.did, reachable).await {
|
||||
warn!(did = %peer.did, err = %error, "failed to persist peer readiness");
|
||||
}
|
||||
}
|
||||
None => warn!(
|
||||
did = %peer.did,
|
||||
"peer readiness probe failed once; preserving previous federation status"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether one readiness sample should update the persisted federation
|
||||
/// gate. A success is authoritative immediately. A failure must repeat on the
|
||||
/// next gossip tick before it can mark a peer unreachable, so one transient
|
||||
/// database hiccup does not hide the peer's repositories for five minutes.
|
||||
fn peer_ping_db_update(failed_once: &mut HashSet<String>, did: &str, ready: bool) -> Option<bool> {
|
||||
if ready {
|
||||
failed_once.remove(did);
|
||||
@@ -1673,32 +1424,6 @@ fn load_or_create_keypair_at(key_path: &std::path::Path) -> Result<Keypair> {
|
||||
Ok(kp)
|
||||
}
|
||||
|
||||
async fn gossip_ping_round(
|
||||
db: &Db,
|
||||
client: &reqwest::Client,
|
||||
failed_once: &mut HashSet<String>,
|
||||
peers: Vec<db::PeerRecord>,
|
||||
) {
|
||||
{
|
||||
let current_dids: HashSet<&str> = peers.iter().map(|peer| peer.did.as_str()).collect();
|
||||
failed_once.retain(|did| current_dids.contains(did.as_str()));
|
||||
}
|
||||
for peer in peers {
|
||||
let ok = ping_peer_readiness(client, &peer.http_url).await;
|
||||
match peer_ping_db_update(failed_once, &peer.did, ok) {
|
||||
Some(reachable) => {
|
||||
if let Err(error) = db.mark_peer_ping(&peer.did, reachable).await {
|
||||
warn!(did = %peer.did, err = %error, "failed to persist peer readiness");
|
||||
}
|
||||
}
|
||||
None => warn!(
|
||||
did = %peer.did,
|
||||
"peer readiness probe failed once; preserving previous federation status"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::{
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
//! `gitlawb_pack_size_bytes`
|
||||
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
|
||||
//! * currently-connected peer count — `gitlawb_peers_connected`
|
||||
//! * reconciliation sweep gaps found and filled —
|
||||
//! `gitlawb_reconciliation_gaps_found_total` /
|
||||
//! `gitlawb_reconciliation_gaps_filled_total`
|
||||
//!
|
||||
//! All metrics live in a single process-wide registry initialized by
|
||||
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
|
||||
@@ -61,8 +58,6 @@ static GOSSIP_INGEST: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static GOSSIP_REPLAY_GUARD_SATURATED: OnceLock<IntCounter> = OnceLock::new();
|
||||
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
|
||||
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
static RECONCILIATION_GAPS_FOUND: OnceLock<IntCounter> = OnceLock::new();
|
||||
static RECONCILIATION_GAPS_FILLED: OnceLock<IntCounter> = OnceLock::new();
|
||||
/// 1 when the node started without its p2p identity because the key storage
|
||||
/// could not be used. The node keeps serving HTTP in that state and /health
|
||||
/// stays green, so without this the outage is visible only to whoever reads
|
||||
@@ -246,29 +241,6 @@ fn init_inner(version: &str, node_did: &str) {
|
||||
.set(peers_connected)
|
||||
.expect("set PEERS_CONNECTED once");
|
||||
|
||||
let gaps_found = IntCounter::with_opts(Opts::new(
|
||||
"gitlawb_reconciliation_gaps_found_total",
|
||||
"Total reconciliation sweep gaps detected (objects that should be pinned but are not)",
|
||||
))
|
||||
.expect("gitlawb_reconciliation_gaps_found_total definition");
|
||||
registry
|
||||
.register(Box::new(gaps_found.clone()))
|
||||
.expect("register gitlawb_reconciliation_gaps_found_total");
|
||||
RECONCILIATION_GAPS_FOUND
|
||||
.set(gaps_found)
|
||||
.expect("set RECONCILIATION_GAPS_FOUND once");
|
||||
|
||||
let gaps_filled = IntCounter::with_opts(Opts::new(
|
||||
"gitlawb_reconciliation_gaps_filled_total",
|
||||
"Total reconciliation sweep gaps successfully filled (objects pinned by the sweep)",
|
||||
))
|
||||
.expect("gitlawb_reconciliation_gaps_filled_total definition");
|
||||
registry
|
||||
.register(Box::new(gaps_filled.clone()))
|
||||
.expect("register gitlawb_reconciliation_gaps_filled_total");
|
||||
RECONCILIATION_GAPS_FILLED
|
||||
.set(gaps_filled)
|
||||
.expect("set RECONCILIATION_GAPS_FILLED once");
|
||||
let p2p_key_load_failed = IntGauge::with_opts(Opts::new(
|
||||
"gitlawb_p2p_identity_key_load_failed",
|
||||
"1 if the node is running without p2p because its identity key could not be loaded",
|
||||
@@ -342,9 +314,7 @@ pub fn record_sync_processed(status: &str) {
|
||||
}
|
||||
|
||||
/// Record one webhook delivery attempt outcome. `result` ∈
|
||||
/// {ok, http_error, network_error, skipped, invalid_url}.
|
||||
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
|
||||
/// check at delivery time and the POST was never sent.
|
||||
/// {ok, http_error, network_error, skipped}.
|
||||
pub fn record_webhook_delivery(result: &str) {
|
||||
if let Some(c) = WEBHOOK_DELIVERIES.get() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
@@ -425,20 +395,6 @@ pub fn set_peers_connected(count: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record reconciliation sweep gaps found (objects that should be pinned but are not).
|
||||
pub fn record_reconciliation_gaps_found(count: u64) {
|
||||
if let Some(c) = RECONCILIATION_GAPS_FOUND.get() {
|
||||
c.inc_by(count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record reconciliation sweep gaps filled (objects successfully pinned by the sweep).
|
||||
pub fn record_reconciliation_gaps_filled(count: u64) {
|
||||
if let Some(c) = RECONCILIATION_GAPS_FILLED.get() {
|
||||
c.inc_by(count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record whether the node is running without its p2p identity.
|
||||
pub fn set_p2p_key_load_failed(failed: bool) {
|
||||
if let Some(g) = P2P_KEY_LOAD_FAILED.get() {
|
||||
@@ -483,8 +439,6 @@ mod tests {
|
||||
.expect("PUSHES set after init")
|
||||
.with_label_values(&["alice/repo"])
|
||||
.inc();
|
||||
record_reconciliation_gaps_found(7);
|
||||
record_reconciliation_gaps_filled(3);
|
||||
|
||||
let body = encode().expect("encode should succeed after init");
|
||||
assert!(
|
||||
@@ -499,14 +453,6 @@ mod tests {
|
||||
body.contains("gitlawb_pushes_total{repo=\"alice/repo\"} 1"),
|
||||
"expected the incremented counter to be visible in: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("gitlawb_reconciliation_gaps_found_total 7"),
|
||||
"expected the reconciliation gaps-found counter to be visible in: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("gitlawb_reconciliation_gaps_filled_total 3"),
|
||||
"expected the reconciliation gaps-filled counter to be visible in: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #192 F4: `init` is idempotent and safe to call repeatedly. The panic that
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! The node's PeerId comes from an Ed25519 keypair loaded from a persistent
|
||||
//! key file, so the PeerId is stable across restarts.
|
||||
|
||||
use std::collections::{hash_map::DefaultHasher, HashMap, HashSet, VecDeque};
|
||||
use std::collections::{hash_map::DefaultHasher, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -315,17 +315,6 @@ fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
// Identify is an untrusted address source. Keep enough entries for normal
|
||||
// multi-homed peers while bounding both one peer and the whole routing table.
|
||||
const IDENTIFY_ADDRESS_LIMIT: usize = 8;
|
||||
// Bound canonicalization and sorting too, including rejected/duplicate entries.
|
||||
const IDENTIFY_REPORT_ADDRESS_LIMIT: usize = 64;
|
||||
const IDENTIFY_NEW_ADDRESS_LIMIT: usize = 8;
|
||||
const IDENTIFY_GLOBAL_ADDRESS_LIMIT: usize = 1024;
|
||||
const IDENTIFY_ADDRESS_WINDOW: Duration = Duration::from_secs(60);
|
||||
const IDENTIFY_ADDRESS_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const IDENTIFY_ADDRESS_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// A ref-update event published to Gossipsub when a push lands.
|
||||
///
|
||||
/// The signing bytes are this struct serialized with `sig` set to None (see
|
||||
@@ -1642,200 +1631,6 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
|
||||
kad::RecordKey::new(&format!("/gitlawb/did/{did}").as_bytes())
|
||||
}
|
||||
|
||||
/// Returns true when `address` was explicitly configured for `peer` through
|
||||
/// AddKnownPeer. Explicit addresses are retained in Kademlia even when an
|
||||
/// overlapping Identify lease expires or is evicted.
|
||||
fn is_explicit_address(
|
||||
explicit_addresses: &HashMap<PeerId, HashSet<Multiaddr>>,
|
||||
peer: &PeerId,
|
||||
address: &Multiaddr,
|
||||
) -> bool {
|
||||
explicit_addresses
|
||||
.get(peer)
|
||||
.is_some_and(|addresses| addresses.contains(address))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdentifyAddress {
|
||||
address: Multiaddr,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdentifyPeerAddresses {
|
||||
addresses: VecDeque<IdentifyAddress>,
|
||||
window_started: Instant,
|
||||
new_addresses: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct IdentifyAddressChanges {
|
||||
added: Vec<Multiaddr>,
|
||||
refreshed: Vec<Multiaddr>,
|
||||
removed: Vec<(PeerId, Multiaddr)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct IdentifyAddressBook {
|
||||
peers: HashMap<PeerId, IdentifyPeerAddresses>,
|
||||
address_count: usize,
|
||||
}
|
||||
|
||||
impl IdentifyAddressBook {
|
||||
fn update(
|
||||
&mut self,
|
||||
peer_id: PeerId,
|
||||
now: Instant,
|
||||
addresses: &[Multiaddr],
|
||||
) -> IdentifyAddressChanges {
|
||||
let mut changes = IdentifyAddressChanges::default();
|
||||
if self.peers.contains_key(&peer_id) {
|
||||
changes.removed.extend(self.expire_peer(peer_id, now));
|
||||
}
|
||||
let reported = addresses
|
||||
.iter()
|
||||
.take(IDENTIFY_REPORT_ADDRESS_LIMIT)
|
||||
.filter_map(|address| address.clone().with_p2p(peer_id).ok())
|
||||
.collect::<HashSet<_>>();
|
||||
if reported.is_empty() && !self.peers.contains_key(&peer_id) {
|
||||
return changes;
|
||||
}
|
||||
let state = self
|
||||
.peers
|
||||
.entry(peer_id)
|
||||
.or_insert_with(|| IdentifyPeerAddresses {
|
||||
addresses: VecDeque::new(),
|
||||
window_started: now,
|
||||
new_addresses: 0,
|
||||
});
|
||||
|
||||
if now.saturating_duration_since(state.window_started) >= IDENTIFY_ADDRESS_WINDOW {
|
||||
state.window_started = now;
|
||||
state.new_addresses = 0;
|
||||
}
|
||||
|
||||
// Refresh every retained address in the bounded report before admission.
|
||||
// Refreshed addresses are reported so the Identify handler can re-admit
|
||||
// them to Kademlia. Kademlia may have dropped a retained address
|
||||
// after a failed dial or never kept it for a full k-bucket, and a
|
||||
// TTL refresh alone would never re-add it.
|
||||
for existing in &mut state.addresses {
|
||||
if reported.contains(&existing.address) {
|
||||
existing.expires_at = now + IDENTIFY_ADDRESS_TTL;
|
||||
changes.refreshed.push(existing.address.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_addresses: Vec<Multiaddr> = reported
|
||||
.iter()
|
||||
.filter(|address| {
|
||||
!state
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|existing| &existing.address == *address)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// HashSet iteration is deliberately unordered. Sort new candidates so
|
||||
// an oversized Identify report has deterministic admission behavior.
|
||||
new_addresses.sort();
|
||||
|
||||
for address in new_addresses {
|
||||
if state.new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT {
|
||||
break;
|
||||
}
|
||||
|
||||
let evicted = if state.addresses.len() < IDENTIFY_ADDRESS_LIMIT {
|
||||
None
|
||||
} else {
|
||||
state
|
||||
.addresses
|
||||
.iter()
|
||||
.position(|existing| !reported.contains(&existing.address))
|
||||
.and_then(|index| state.addresses.remove(index))
|
||||
};
|
||||
if let Some(evicted) = evicted {
|
||||
self.address_count -= 1;
|
||||
changes.removed.push((peer_id, evicted.address));
|
||||
} else if state.addresses.len() >= IDENTIFY_ADDRESS_LIMIT {
|
||||
// Every retained address was refreshed in this report. Keep
|
||||
// that stable subset and drop surplus new candidates.
|
||||
break;
|
||||
}
|
||||
|
||||
if self.address_count >= IDENTIFY_GLOBAL_ADDRESS_LIMIT {
|
||||
// Reject the admission instead of evicting another peer's
|
||||
// entry: cross-peer eviction lets a flood of new identities
|
||||
// push honest peers' addresses out of the book and, when the
|
||||
// evicted address is their last one, out of the Kademlia
|
||||
// routing table. Per-peer eviction above still applies to the
|
||||
// reporting peer's own addresses.
|
||||
break;
|
||||
}
|
||||
|
||||
state.new_addresses += 1;
|
||||
state.addresses.push_back(IdentifyAddress {
|
||||
address: address.clone(),
|
||||
expires_at: now + IDENTIFY_ADDRESS_TTL,
|
||||
});
|
||||
self.address_count += 1;
|
||||
changes.added.push(address);
|
||||
}
|
||||
|
||||
// Do not keep a row when every candidate was rejected: a zero-address
|
||||
// row is invisible to address_count and would otherwise linger until
|
||||
// the expiry tick. This matches expire_peer, which drops empty rows.
|
||||
if state.addresses.is_empty() {
|
||||
self.peers.remove(&peer_id);
|
||||
}
|
||||
|
||||
changes
|
||||
}
|
||||
|
||||
fn expire(&mut self, now: Instant) -> Vec<(PeerId, Multiaddr)> {
|
||||
let mut removed = Vec::new();
|
||||
let peers = self.peers.keys().copied().collect::<Vec<_>>();
|
||||
for peer_id in peers {
|
||||
removed.extend(self.expire_peer(peer_id, now));
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn expire_peer(&mut self, peer_id: PeerId, now: Instant) -> Vec<(PeerId, Multiaddr)> {
|
||||
let expired = self
|
||||
.peers
|
||||
.get_mut(&peer_id)
|
||||
.map(|state| Self::expire_state(state, now))
|
||||
.unwrap_or_default();
|
||||
self.address_count -= expired.len();
|
||||
if self
|
||||
.peers
|
||||
.get(&peer_id)
|
||||
.is_some_and(|state| state.addresses.is_empty())
|
||||
{
|
||||
self.peers.remove(&peer_id);
|
||||
}
|
||||
expired
|
||||
.into_iter()
|
||||
.map(|address| (peer_id, address))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expire_state(state: &mut IdentifyPeerAddresses, now: Instant) -> Vec<Multiaddr> {
|
||||
let mut expired = Vec::new();
|
||||
let mut retained = VecDeque::with_capacity(state.addresses.len());
|
||||
while let Some(address) = state.addresses.pop_front() {
|
||||
if address.expires_at <= now {
|
||||
expired.push(address.address);
|
||||
} else {
|
||||
retained.push_back(address);
|
||||
}
|
||||
}
|
||||
state.addresses = retained;
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined libp2p behaviour.
|
||||
#[derive(NetworkBehaviour)]
|
||||
#[behaviour(prelude = "libp2p_swarm::derive_prelude")]
|
||||
@@ -4165,14 +3960,6 @@ pub async fn start(
|
||||
// Track in-flight GetRecord queries → reply channels
|
||||
let mut pending_get_did: HashMap<kad::QueryId, oneshot::Sender<Option<DidRecord>>> =
|
||||
HashMap::new();
|
||||
// Keep Identify-derived entries across disconnects so a bootstrap address
|
||||
// learned through Identify remains usable for redial. The TTL cleanup below
|
||||
// removes entries that are no longer refreshed without touching explicit
|
||||
// AddKnownPeer addresses.
|
||||
let mut identify_addresses = IdentifyAddressBook::default();
|
||||
let mut explicit_addresses: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
|
||||
let mut identify_cleanup = tokio::time::interval(IDENTIFY_ADDRESS_CLEANUP_INTERVAL);
|
||||
identify_cleanup.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
// Start the event loop as a background task
|
||||
tokio::spawn(async move {
|
||||
@@ -4192,13 +3979,6 @@ pub async fn start(
|
||||
_ = ingest_sweep.tick() => {
|
||||
run_ingest_sweep(&ingest_limiters, &replay_guard, ingest_now()).await;
|
||||
}
|
||||
_ = identify_cleanup.tick() => {
|
||||
for (peer_id, address) in identify_addresses.expire(Instant::now()) {
|
||||
if !is_explicit_address(&explicit_addresses, &peer_id, &address) {
|
||||
swarm.behaviour_mut().kademlia.remove_address(&peer_id, &address);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Graceful shutdown: exit the swarm loop when the
|
||||
// process-wide signal flips. This drops the Swarm
|
||||
// which closes all libp2p connections cleanly.
|
||||
@@ -4333,21 +4113,7 @@ pub async fn start(
|
||||
identify::Event::Received { peer_id, info, .. }
|
||||
)) => {
|
||||
debug!(peer = %peer_id, "identify received");
|
||||
let changes = identify_addresses.update(
|
||||
peer_id,
|
||||
Instant::now(),
|
||||
&info.listen_addrs,
|
||||
);
|
||||
for (removed_peer, addr) in changes.removed {
|
||||
if !is_explicit_address(&explicit_addresses, &removed_peer, &addr)
|
||||
{
|
||||
swarm
|
||||
.behaviour_mut()
|
||||
.kademlia
|
||||
.remove_address(&removed_peer, &addr);
|
||||
}
|
||||
}
|
||||
for addr in changes.refreshed.into_iter().chain(changes.added) {
|
||||
for addr in info.listen_addrs {
|
||||
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
|
||||
}
|
||||
}
|
||||
@@ -4384,14 +4150,6 @@ pub async fn start(
|
||||
}
|
||||
}
|
||||
P2pCommand::AddKnownPeer { peer_id, addr } => {
|
||||
let Ok(addr) = addr.clone().with_p2p(peer_id) else {
|
||||
warn!(%peer_id, %addr, "dropping known-peer address with a foreign peer suffix");
|
||||
continue;
|
||||
};
|
||||
explicit_addresses
|
||||
.entry(peer_id)
|
||||
.or_default()
|
||||
.insert(addr.clone());
|
||||
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
|
||||
}
|
||||
P2pCommand::Dial(addr) => {
|
||||
@@ -4443,453 +4201,6 @@ pub async fn start(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identify_address(peer_id: PeerId, octet: u8, port: u16) -> Multiaddr {
|
||||
format!("/ip4/192.0.2.{octet}/udp/{port}/quic-v1")
|
||||
.parse::<Multiaddr>()
|
||||
.unwrap()
|
||||
.with_p2p(peer_id)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn identify_address_without_peer(octet: u8, port: u16) -> Multiaddr {
|
||||
format!("/ip4/192.0.2.{octet}/udp/{port}/quic-v1")
|
||||
.parse::<Multiaddr>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_empty_reports_do_not_retain_peer_entries() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let foreign = identify_address(PeerId::random(), 1, 10_000);
|
||||
for _ in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT + 1 {
|
||||
let peer = PeerId::random();
|
||||
assert_eq!(
|
||||
book.update(peer, now, &[]),
|
||||
IdentifyAddressChanges::default()
|
||||
);
|
||||
assert_eq!(
|
||||
book.update(peer, now, std::slice::from_ref(&foreign)),
|
||||
IdentifyAddressChanges::default()
|
||||
);
|
||||
}
|
||||
assert!(book.peers.is_empty());
|
||||
assert_eq!(book.address_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_fully_rejected_peer_leaves_no_row_at_full_budget() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
// Fill the global budget with one-address peers.
|
||||
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT {
|
||||
let peer = PeerId::random();
|
||||
let address = identify_address(peer, 1, 10_000 + index as u16);
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
}
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
// A fresh peer reporting at saturation has every candidate rejected
|
||||
// by the global-budget check. The row created by or_insert_with must
|
||||
// be dropped: a zero-address row is invisible to address_count and
|
||||
// would otherwise linger until the expiry tick.
|
||||
let fresh = PeerId::random();
|
||||
let fresh_address = identify_address(fresh, 2, 30_000);
|
||||
let changes = book.update(fresh, now, std::slice::from_ref(&fresh_address));
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(!book.peers.contains_key(&fresh));
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_update_reports_expired_addresses_as_removed() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let address = identify_address(peer, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
// An update past the TTL expires the stale address through
|
||||
// expire_peer and reports it in changes.removed so the Identify
|
||||
// handler can drop it from Kademlia.
|
||||
let changes = book.update(
|
||||
peer,
|
||||
now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1),
|
||||
std::slice::from_ref(&address),
|
||||
);
|
||||
assert_eq!(changes.removed, vec![(peer, address.clone())]);
|
||||
assert_eq!(changes.added, vec![address]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_expire_drops_rows_emptied_by_expiry() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let address = identify_address(peer, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
assert!(book.peers.contains_key(&peer));
|
||||
// Once every address in the row has expired, expire() must drop the
|
||||
// row itself, matching the empty-row drop in update().
|
||||
let removed = book.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1));
|
||||
assert_eq!(removed, vec![(peer, address)]);
|
||||
assert!(!book.peers.contains_key(&peer));
|
||||
assert_eq!(book.address_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_report_processing_stops_at_the_input_limit() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let foreign = identify_address(PeerId::random(), 1, 10_000);
|
||||
let accepted = identify_address(peer, 2, 10_001);
|
||||
let ignored = identify_address(peer, 3, 10_002);
|
||||
let mut report = vec![foreign; IDENTIFY_REPORT_ADDRESS_LIMIT - 1];
|
||||
report.push(accepted.clone());
|
||||
report.push(ignored);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let changes = book.update(peer, now, &report);
|
||||
assert_eq!(changes.added, vec![accepted]);
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(book.address_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_grow_without_eviction_below_the_peer_cap() {
|
||||
let peer = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let first = identify_address(peer, 1, 10_000);
|
||||
let second = identify_address(peer, 2, 10_001);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
book.update(peer, now, std::slice::from_ref(&first));
|
||||
let changes = book.update(
|
||||
peer,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&second),
|
||||
);
|
||||
assert_eq!(changes.added, vec![second]);
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(book.peers[&peer].addresses.len(), 2);
|
||||
assert_eq!(book.address_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_global_budget_rejects_new_admissions() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let peers = (0..IDENTIFY_GLOBAL_ADDRESS_LIMIT)
|
||||
.map(|index| {
|
||||
let peer = PeerId::random();
|
||||
let address = identify_address(peer, 1, 10_000 + index as u16);
|
||||
book.update(peer, now, std::slice::from_ref(&address));
|
||||
(peer, address)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (peer, refreshed) = &peers[0];
|
||||
let new_address = identify_address(*peer, 2, 30_000);
|
||||
let changes = book.update(
|
||||
*peer,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
&[new_address.clone(), refreshed.clone()],
|
||||
);
|
||||
// The budget is full: the new address is rejected rather than
|
||||
// evicting another peer's entry, and the refreshed address keeps its
|
||||
// slot and is reported for re-admission to Kademlia.
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(changes.refreshed, vec![(*refreshed).clone()]);
|
||||
assert_eq!(book.peers.len(), IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
assert_eq!(
|
||||
book.peers[peer]
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| &a.address)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![refreshed]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_per_peer_eviction_applies_at_full_global_budget() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
// One filler peer goes in before the victim so the evicted address
|
||||
// is not also the globally-oldest insertion. If saturation evicted
|
||||
// the globally-oldest entry instead of the victim's own oldest,
|
||||
// the sentinel's address would be the one removed.
|
||||
let sentinel = PeerId::random();
|
||||
let sentinel_address = identify_address(sentinel, 200, 19_999);
|
||||
book.update(sentinel, now, std::slice::from_ref(&sentinel_address));
|
||||
// The victim peer holds a full per-peer row while the rest of the
|
||||
// global budget is filled by one-address peers.
|
||||
let victim = PeerId::random();
|
||||
let victim_addresses: Vec<Multiaddr> = (0..IDENTIFY_ADDRESS_LIMIT)
|
||||
.map(|index| identify_address(victim, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect();
|
||||
book.update(victim, now, &victim_addresses);
|
||||
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT - IDENTIFY_ADDRESS_LIMIT - 1 {
|
||||
let peer = PeerId::random();
|
||||
let address = identify_address(peer, 200, 20_000 + index as u16);
|
||||
book.update(peer, now, &[address]);
|
||||
}
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
|
||||
// The victim reports a ninth address after the rate window rolls over.
|
||||
// Per-peer eviction must still apply at global saturation: the oldest
|
||||
// non-reported address is evicted to make room, and the global count
|
||||
// is unchanged. This ordering only holds because eviction runs before
|
||||
// the global-budget check; hoisting that check above the eviction
|
||||
// block makes this test fail.
|
||||
let ninth = identify_address(victim, 100, 30_000);
|
||||
let changes = book.update(
|
||||
victim,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&ninth),
|
||||
);
|
||||
assert_eq!(changes.added, vec![ninth]);
|
||||
assert_eq!(changes.removed, vec![(victim, victim_addresses[0].clone())]);
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
assert_eq!(book.peers[&victim].addresses.len(), IDENTIFY_ADDRESS_LIMIT);
|
||||
// The sentinel was inserted first; per-peer eviction must leave it
|
||||
// alone. A global-oldest eviction policy would have removed it.
|
||||
assert_eq!(
|
||||
book.peers[&sentinel]
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| &a.address)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![&sentinel_address]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_address_helper_detects_retained_addresses() {
|
||||
let peer = PeerId::random();
|
||||
let other = PeerId::random();
|
||||
let address = identify_address(peer, 1, 10_000);
|
||||
let mut explicit: HashMap<PeerId, HashSet<Multiaddr>> = HashMap::new();
|
||||
// Unknown peer: nothing is explicit.
|
||||
assert!(!is_explicit_address(&explicit, &peer, &address));
|
||||
explicit.entry(peer).or_default().insert(address.clone());
|
||||
assert!(is_explicit_address(&explicit, &peer, &address));
|
||||
// The same address under a different peer is not retained.
|
||||
assert!(!is_explicit_address(&explicit, &other, &address));
|
||||
// A different address for the same peer is not retained.
|
||||
assert!(!is_explicit_address(
|
||||
&explicit,
|
||||
&peer,
|
||||
&identify_address(peer, 2, 10_001)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_are_capped_with_fifo_eviction() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let initial = (0..IDENTIFY_ADDRESS_LIMIT)
|
||||
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect::<Vec<_>>();
|
||||
let replacement = identify_address(peer_id, 200, 20_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
let first = book.update(peer_id, now, &initial);
|
||||
assert_eq!(first.added, initial);
|
||||
assert!(first.removed.is_empty());
|
||||
|
||||
let second = book.update(
|
||||
peer_id,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&replacement),
|
||||
);
|
||||
assert_eq!(second.added, vec![replacement]);
|
||||
assert_eq!(second.removed, vec![(peer_id, initial[0].clone())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_oversized_reports_keep_the_same_refreshed_subset() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let mut report = (0..IDENTIFY_ADDRESS_LIMIT + 2)
|
||||
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect::<Vec<_>>();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
let first = book.update(peer_id, now, &report);
|
||||
assert_eq!(first.added.len(), IDENTIFY_ADDRESS_LIMIT);
|
||||
assert_eq!(first.removed.len(), 0);
|
||||
let retained = first.added.clone();
|
||||
|
||||
report.reverse();
|
||||
let second = book.update(peer_id, now + IDENTIFY_ADDRESS_WINDOW, &report);
|
||||
assert!(second.added.is_empty());
|
||||
assert!(second.removed.is_empty());
|
||||
let current = book
|
||||
.peers
|
||||
.get(&peer_id)
|
||||
.unwrap()
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.address.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(current, retained);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_rate_limit_new_entries_across_updates() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let initial = (0..IDENTIFY_NEW_ADDRESS_LIMIT)
|
||||
.map(|index| identify_address(peer_id, index as u8 + 1, 10_000 + index as u16))
|
||||
.collect::<Vec<_>>();
|
||||
let replacement = identify_address(peer_id, 200, 20_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert_eq!(book.update(peer_id, now, &initial).added, initial);
|
||||
assert!(book
|
||||
.update(peer_id, now, std::slice::from_ref(&replacement))
|
||||
.added
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
book.update(
|
||||
peer_id,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&replacement),
|
||||
)
|
||||
.added,
|
||||
vec![replacement]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_have_a_global_budget_across_peers() {
|
||||
let now = Instant::now();
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
let mut first = None;
|
||||
|
||||
for index in 0..IDENTIFY_GLOBAL_ADDRESS_LIMIT {
|
||||
let peer_id = PeerId::random();
|
||||
let address = identify_address(peer_id, (index % 254) as u8 + 1, 10_000 + index as u16);
|
||||
if first.is_none() {
|
||||
first = Some((peer_id, address.clone()));
|
||||
}
|
||||
assert!(book.update(peer_id, now, &[address]).removed.is_empty());
|
||||
}
|
||||
|
||||
let (peer_id, first_address) = first.unwrap();
|
||||
let address = identify_address(peer_id, 250, 30_000);
|
||||
let changes = book.update(
|
||||
peer_id,
|
||||
now + IDENTIFY_ADDRESS_WINDOW,
|
||||
std::slice::from_ref(&address),
|
||||
);
|
||||
// The global budget is full, so the admission is rejected and the
|
||||
// peer's existing entry is left alone.
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(
|
||||
book.peers[&peer_id]
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|a| &a.address)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![&first_address]
|
||||
);
|
||||
assert_eq!(book.address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_refresh_and_expire() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let address = identify_address(peer_id, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert_eq!(
|
||||
book.update(peer_id, now, std::slice::from_ref(&address))
|
||||
.added,
|
||||
vec![address.clone()]
|
||||
);
|
||||
let refreshed = now + IDENTIFY_ADDRESS_TTL - Duration::from_secs(1);
|
||||
let refresh = book.update(peer_id, refreshed, std::slice::from_ref(&address));
|
||||
assert!(refresh.added.is_empty());
|
||||
assert!(refresh.removed.is_empty());
|
||||
// Refreshed addresses are reported so the Identify handler re-adds
|
||||
// them to Kademlia even though they were not newly admitted.
|
||||
assert_eq!(refresh.refreshed, vec![address.clone()]);
|
||||
assert!(book
|
||||
.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1))
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
book.expire(refreshed + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
|
||||
vec![(peer_id, address)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_refresh_reports_only_addresses_in_the_report() {
|
||||
let peer_id = PeerId::random();
|
||||
let now = Instant::now();
|
||||
let reported = identify_address(peer_id, 1, 10_000);
|
||||
let unreported = identify_address(peer_id, 2, 20_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
// Admit both addresses up front so the peer holds two.
|
||||
assert_eq!(
|
||||
book.update(peer_id, now, &[reported.clone(), unreported.clone()])
|
||||
.added
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
// A later report carrying only one of the two addresses must refresh
|
||||
// just that one: pins the negative direction of the refresh
|
||||
// predicate, so an address the peer stopped advertising keeps its
|
||||
// original TTL instead of being refreshed back onto the Kademlia
|
||||
// re-admit path on every report.
|
||||
let report_at = now + IDENTIFY_ADDRESS_TTL - Duration::from_secs(1);
|
||||
let changes = book.update(peer_id, report_at, std::slice::from_ref(&reported));
|
||||
assert!(changes.added.is_empty());
|
||||
assert!(changes.removed.is_empty());
|
||||
assert_eq!(changes.refreshed, vec![reported.clone()]);
|
||||
|
||||
// The unreported address still expires at its original TTL, while
|
||||
// the refreshed address survives until its refreshed TTL.
|
||||
assert_eq!(
|
||||
book.expire(now + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
|
||||
vec![(peer_id, unreported)]
|
||||
);
|
||||
assert!(book.expire(report_at + Duration::from_secs(1)).is_empty());
|
||||
assert_eq!(
|
||||
book.expire(report_at + IDENTIFY_ADDRESS_TTL + Duration::from_secs(1)),
|
||||
vec![(peer_id, reported)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_reject_foreign_peer_suffixes() {
|
||||
let peer_id = PeerId::random();
|
||||
let other_peer_id = PeerId::random();
|
||||
let address = identify_address(other_peer_id, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert!(book
|
||||
.update(peer_id, Instant::now(), &[address])
|
||||
.added
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_addresses_canonicalize_the_peer_suffix_once() {
|
||||
let peer_id = PeerId::random();
|
||||
let raw = identify_address_without_peer(1, 10_000);
|
||||
let canonical = identify_address(peer_id, 1, 10_000);
|
||||
let mut book = IdentifyAddressBook::default();
|
||||
|
||||
assert_eq!(
|
||||
book.update(peer_id, Instant::now(), &[raw]).added,
|
||||
vec![canonical]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_mode_replace_authority_is_the_write_bits() {
|
||||
assert!(!dir_mode_allows_untrusted_replace(0o755));
|
||||
|
||||
@@ -153,26 +153,6 @@ pub async fn pin_object(
|
||||
/// `upsert_branch_cid` and the p2p `publish_ref_update` gossip CID. The twin's return is
|
||||
/// log-only, so it omits a record-failed pin rather than logging a pin the resolver
|
||||
/// cannot serve. Moving this side to match would need that consumer moved first.
|
||||
/// Whether the committed `pinned_cids` row already names `repo_id` as
|
||||
/// first pinner, making a failed redundant `record_pin_source` insert
|
||||
/// irrelevant to resolvability (`pin_sources_for_oid` unions the
|
||||
/// primary row). Bounded read; any failure (including its own
|
||||
/// timeout) answers false — an unproven row never confirms.
|
||||
/// Used only after the primary `record_pinata_cid` for this call
|
||||
/// succeeded or verified: callers must not promote a row this call
|
||||
/// did not establish.
|
||||
async fn primary_covers_repo(
|
||||
db: &crate::db::Db,
|
||||
sha: &str,
|
||||
repo_id: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> bool {
|
||||
matches!(
|
||||
crate::ipfs_pin::db_bounded(deadline, db.provenance_for_oid(sha)).await,
|
||||
Ok(Some(owner)) if owner.as_str() == repo_id
|
||||
)
|
||||
}
|
||||
|
||||
// Ten arguments, over clippy's threshold: the three the budget and the git seam add
|
||||
// (`git_bin`, `git_timeout`, `batch_budget`) plus #173's `repo_id` are what put the read
|
||||
// under test injection and under a deadline, and grouping them into a struct would only
|
||||
@@ -190,33 +170,16 @@ pub async fn pin_new_objects(
|
||||
db: &crate::db::Db,
|
||||
repo_id: &str,
|
||||
batch_budget: Duration,
|
||||
fence: Option<&crate::ipfs_pin::PolicyFence>,
|
||||
) -> crate::ipfs_pin::PinBatchOutcome {
|
||||
) -> Vec<(String, String)> {
|
||||
if jwt.is_empty() {
|
||||
return crate::ipfs_pin::PinBatchOutcome {
|
||||
confirmed: Vec::new(),
|
||||
last_attempted: None,
|
||||
};
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + batch_budget;
|
||||
let total = object_list.len();
|
||||
let mut pinned = Vec::new();
|
||||
let mut last_attempted: Option<String> = None;
|
||||
|
||||
for (attempted, sha) in object_list.into_iter().enumerate() {
|
||||
// Policy fence (R1-P1): a visibility narrow that lands after the caller
|
||||
// built this batch must abort it before the next irreversible upload.
|
||||
if let Some(f) = fence {
|
||||
if !f.is_current().await {
|
||||
tracing::warn!(
|
||||
repo = %f.repo_id(),
|
||||
unattempted = total - attempted,
|
||||
"visibility policy changed mid-batch; stopping the Pinata pin loop"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Top of the iteration, before any of this object's work: an object is never
|
||||
// started with a remainder too small to cover a bounded read's teardown. The
|
||||
// gate is shared with the IPFS loop so the two cannot drift apart in how they
|
||||
@@ -228,10 +191,6 @@ pub async fn pin_new_objects(
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Attempt progress (not a durability claim): same contract as the
|
||||
// IPFS twin — the continuation persists this, never the planned
|
||||
// vector's tail.
|
||||
last_attempted = Some(sha.clone());
|
||||
|
||||
// Every DB call from here to the end of the iteration is bounded by the
|
||||
// ABSOLUTE batch deadline (F3, #173), through the same `db_bounded` helper the
|
||||
@@ -436,21 +395,6 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
};
|
||||
|
||||
// Dispatch fence (R1-P1): re-read the policy epoch immediately before
|
||||
// the irreversible HTTP POST. The iteration-top check catches a narrow
|
||||
// that landed before work began; THIS check catches a narrow that landed
|
||||
// during the has_pinata_cid round-trip or the bounded Git read.
|
||||
if let Some(f) = fence {
|
||||
if !f.is_current().await {
|
||||
tracing::warn!(
|
||||
repo = %f.repo_id(),
|
||||
unattempted = total - attempted,
|
||||
"visibility policy changed during preparation; aborting Pinata upload"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match pin_object(client, upload_url, jwt, &sha, &data).await {
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
// The resolver key (`pinned_cids.cid`) must be the locally-computed
|
||||
@@ -466,109 +410,30 @@ pub async fn pin_new_objects(
|
||||
// runs under the shared client's own ceiling, so a successful one can
|
||||
// return with ~0 of the batch budget left, and an unfloored bound would
|
||||
// fail a write that today completes in milliseconds. That costs more on
|
||||
// this side than on the twin: `pinned.push` below feeds
|
||||
// `api/repos.rs`, which builds its `cid_map` from the pair and drives
|
||||
// this side than on the twin: `pinned.push` below is UNCONDITIONAL, so
|
||||
// `api/repos.rs` builds its `cid_map` from the pair either way and drives
|
||||
// `upsert_branch_cid` plus the p2p `publish_ref_update` gossip from it. A
|
||||
// dropped record therefore makes the node ADVERTISE a CID whose `/ipfs`
|
||||
// read 404s. Only durably recorded pairs are pushed, so the map never
|
||||
// advertises an unconfirmed row.
|
||||
//
|
||||
// Round 10 P2: a closed/failed DB write means the (sha, cid)
|
||||
// pair is not durable; we suppress the `pinned.push` for this
|
||||
// sha so the reconcile cannot count a Pinata gap as filled
|
||||
// when no row exists. The source-record failure arms are also
|
||||
// hard failures (multi-statement transaction never committed)
|
||||
// and suppress the push in the same way.
|
||||
//
|
||||
// `record_pinata_cid` is an explicit multi-statement
|
||||
// transaction (row lock + insert + commit), NOT a single
|
||||
// autocommit upsert, so a timed-out future proves nothing:
|
||||
// cancellation before COMMIT wrote nothing, during COMMIT is
|
||||
// unknown. The Elapsed arm therefore verifies by content
|
||||
// (`verify_pinata_record`: exact row + unchanged fence epoch)
|
||||
// under its own bound and pushes only on proof; anything
|
||||
// else stays suppressed and the gap is re-offered.
|
||||
let fence_epoch = fence.map(|f| f.captured_epoch()).unwrap_or(i64::MAX);
|
||||
let mut db_record_durable = false;
|
||||
match crate::ipfs_pin::db_bounded(
|
||||
// read 404s. If the floored bound still fires, THIS site's outcome really
|
||||
// is unknown, and unlike the source record below that is a property of
|
||||
// the operation: `record_pinata_cid` is a single autocommit upsert, so
|
||||
// the statement Postgres already started can still land after the client
|
||||
// future is cancelled. The warn names the arm through the error's own
|
||||
// Display and the site keeps its existing behavior: the pair is still
|
||||
// returned, and the row may or may not exist.
|
||||
if let Err(e) = crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
crate::ipfs_pin::retry_db_record(|| {
|
||||
// P2 (reviewer round 9): Pinata's POST is
|
||||
// irreversible exactly like IPFS's, so
|
||||
// route the record through the fenced
|
||||
// variant when a fence is in scope. The
|
||||
// `i64::MAX` sentinel tells the helper
|
||||
// to skip the lock + comparison (the
|
||||
// unfenced caller path).
|
||||
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id), fence_epoch)
|
||||
db.record_pinata_cid(&sha, &raw_cid, &cid, Some(repo_id))
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => db_record_durable = true,
|
||||
Err(crate::ipfs_pin::BoundedDbError::Elapsed) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
"record_pinata_cid deadline elapsed on a multi-statement transaction; \
|
||||
verifying the exact row before treating it as durable"
|
||||
);
|
||||
match crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
db.verify_pinata_record(&sha, &raw_cid, &cid, repo_id, fence_epoch),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
"timed-out pinata record verified present with unchanged epoch; \
|
||||
treating as durable"
|
||||
);
|
||||
db_record_durable = true;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
"timed-out pinata record has no matching row or the epoch moved; \
|
||||
suppressing the push so the gap is re-offered"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
err = %e,
|
||||
"pinata record verification did not complete; \
|
||||
suppressing the push so the gap is re-offered"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
err = %e,
|
||||
"failed to record pinata_cid in DB; suppressing the (sha, cid) push \
|
||||
so the reconcile cannot count this gap as filled"
|
||||
);
|
||||
// db_record_durable stays false → push suppressed
|
||||
}
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pinata_cid in DB");
|
||||
}
|
||||
// F1 (#173 round 8): also record the first pinner in pin_repo_sources.
|
||||
// U3: an exhausted retry marks the set incomplete so the resolver keeps
|
||||
// the scan fallback rather than 404ing a copy it could serve.
|
||||
//
|
||||
// Whether the pair stays confirmed depends on what the
|
||||
// PRIMARY `record_pinata_cid` above established: that row
|
||||
// already names this repo as first pinner
|
||||
// (`pin_sources_for_oid` unions it), so the resolver
|
||||
// reaches this copy with or without the redundant
|
||||
// `pin_repo_sources` insert. A source-write failure then
|
||||
// keeps the pair confirmed (but still marks the set
|
||||
// incomplete, preserving the U3 compensation). If the
|
||||
// primary row names ANOTHER repo — or is absent — the
|
||||
// source write was load-bearing and the push stays
|
||||
// suppressed. (The IPFS twin needs no equivalent: its
|
||||
// pin and source land in ONE transaction.)
|
||||
match crate::ipfs_pin::db_bounded(
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
crate::ipfs_pin::retry_db_record(|| db.record_pin_source(&sha, repo_id)),
|
||||
@@ -580,7 +445,9 @@ pub async fn pin_new_objects(
|
||||
// wraps `record_pin_source`, an explicit transaction, so a timed-out
|
||||
// call definitely never committed and the source is definitely
|
||||
// missing. Mark the set incomplete rather than leaving it incomplete
|
||||
// and unmarked.
|
||||
// and unmarked. Note the contrast with `record_pinata_cid` a few
|
||||
// lines up: that one is a single autocommit statement, so its
|
||||
// timeout genuinely is an unknown outcome and it is warn-only.
|
||||
Err(e @ crate::ipfs_pin::BoundedDbError::Elapsed) => {
|
||||
tracing::warn!(
|
||||
sha = %sha,
|
||||
@@ -597,16 +464,6 @@ pub async fn pin_new_objects(
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
if !primary_covers_repo(
|
||||
db,
|
||||
&sha,
|
||||
repo_id,
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
)
|
||||
.await
|
||||
{
|
||||
db_record_durable = false;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to record pin source");
|
||||
@@ -618,21 +475,9 @@ pub async fn pin_new_objects(
|
||||
{
|
||||
tracing::warn!(sha = %sha, err = %e, "failed to mark pin sources incomplete");
|
||||
}
|
||||
if !primary_covers_repo(
|
||||
db,
|
||||
&sha,
|
||||
repo_id,
|
||||
crate::ipfs_pin::db_record_deadline(deadline),
|
||||
)
|
||||
.await
|
||||
{
|
||||
db_record_durable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if db_record_durable {
|
||||
pinned.push((sha, cid));
|
||||
}
|
||||
pinned.push((sha, cid));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -641,10 +486,7 @@ pub async fn pin_new_objects(
|
||||
}
|
||||
}
|
||||
|
||||
crate::ipfs_pin::PinBatchOutcome {
|
||||
confirmed: pinned,
|
||||
last_attempted,
|
||||
}
|
||||
pinned
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -846,20 +688,19 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_millis(5500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("wedge guard: a 5.5s budget cannot take 30s");
|
||||
|
||||
assert!(
|
||||
(1..=3).contains(&pinned.confirmed.len()),
|
||||
(1..=3).contains(&pinned.len()),
|
||||
"the batch must stop partway, not pin all five and not stall on the first: pinned {}",
|
||||
pinned.confirmed.len()
|
||||
pinned.len()
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
"no upload may be issued for an object the budget stopped short of"
|
||||
);
|
||||
let text = logs.text();
|
||||
@@ -887,9 +728,9 @@ mod tests {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the deadline warn must name the unattempted count: {text}"));
|
||||
assert!(
|
||||
unattempted >= 1 && unattempted + pinned.confirmed.len() <= 5,
|
||||
unattempted >= 1 && unattempted + pinned.len() <= 5,
|
||||
"unattempted={unattempted} with {} pinned is not a partial batch of five",
|
||||
pinned.confirmed.len()
|
||||
pinned.len()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -938,7 +779,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -949,7 +789,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1028,7 +868,6 @@ mod tests {
|
||||
"repo-git-timeout",
|
||||
// Generous, so a call that ends on time ended on `git_timeout`.
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1039,7 +878,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"a git that never answers cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1073,7 +912,6 @@ mod tests {
|
||||
/// probe carries its oid on stdin rather than in argv, so an oid appears in the log only
|
||||
/// once an object has already got past its probe, and a healthy object costs two
|
||||
/// invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
@@ -1131,7 +969,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1141,7 +978,7 @@ mod tests {
|
||||
|
||||
if genuinely_unreadable {
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"nothing can be pinned through a store that cannot be read: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1206,7 +1043,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1218,7 +1054,7 @@ mod tests {
|
||||
"an object-scoped fault must not stop the batch: every object must be read"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
4,
|
||||
"one corrupt object must cost only itself: the other four must still pin"
|
||||
);
|
||||
@@ -1258,18 +1094,13 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an immediate endpoint and three healthy objects cannot take 30s");
|
||||
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
3,
|
||||
"every healthy object must pin: {pinned:?}"
|
||||
);
|
||||
for (i, (sha, cid)) in pinned.confirmed.iter().enumerate() {
|
||||
assert_eq!(pinned.len(), 3, "every healthy object must pin: {pinned:?}");
|
||||
for (i, (sha, cid)) in pinned.iter().enumerate() {
|
||||
assert_eq!(sha, &oids[i], "the pairs must carry the objects' own oids");
|
||||
assert_eq!(cid, "QmPinataBatchTestCid");
|
||||
assert!(
|
||||
@@ -1292,15 +1123,11 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a fully deduped batch cannot take 30s");
|
||||
assert!(
|
||||
again.confirmed.is_empty(),
|
||||
"already-recorded objects must be skipped"
|
||||
);
|
||||
assert!(again.is_empty(), "already-recorded objects must be skipped");
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
3,
|
||||
@@ -1308,63 +1135,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The outcome reports the last object actually ENTERED, not the planned
|
||||
/// tail (Pinata twin of the IPFS test above): the first upload takes 6s
|
||||
/// against a 2s batch budget, so it still completes — the Pinata loop
|
||||
/// has no per-request timeout — but the loop-top gate then breaks the
|
||||
/// batch before the second object starts. `last_attempted` must be the
|
||||
/// first OID even though two OIDs were never visited; the 6s server
|
||||
/// sleep makes the break deterministic on any box.
|
||||
#[sqlx::test]
|
||||
async fn pin_new_objects_reports_last_attempted_not_planned_tail(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool);
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("attempted.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 3);
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint = delaying_pinata_endpoint(
|
||||
vec![Duration::from_secs(6)],
|
||||
std::sync::Arc::clone(&requests),
|
||||
)
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&endpoint,
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(60),
|
||||
oids.clone(),
|
||||
&db,
|
||||
"repo-attempted",
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("one 6s upload plus bounded gates cannot take 60s");
|
||||
assert_eq!(
|
||||
outcome.last_attempted,
|
||||
Some(oids[0].clone()),
|
||||
"only the first object was entered; the planned tail was never visited"
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.confirmed.len(),
|
||||
1,
|
||||
"the slow first upload still completed and recorded"
|
||||
);
|
||||
assert_eq!(
|
||||
requests.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"exactly one upload was attempted"
|
||||
);
|
||||
}
|
||||
|
||||
/// The no-op configuration still short-circuits with the budgeted signature: an
|
||||
/// empty JWT must return before any git child is spawned and before any request
|
||||
/// is issued. The `git_bin` here records every invocation, so "git was never
|
||||
@@ -1406,13 +1176,12 @@ mod tests {
|
||||
&db,
|
||||
"repo-merge-test",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("an unconfigured sink returns immediately");
|
||||
|
||||
assert!(pinned.confirmed.is_empty(), "an empty JWT pins nothing");
|
||||
assert!(pinned.is_empty(), "an empty JWT pins nothing");
|
||||
assert!(
|
||||
!log.exists(),
|
||||
"no git child may be spawned when the sink is not configured"
|
||||
@@ -1512,7 +1281,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1523,7 +1291,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"a stalled pinata-status check cannot produce a pinned object: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1569,15 +1337,9 @@ mod tests {
|
||||
// bytes.
|
||||
let raw_cid =
|
||||
gitlawb_core::cid::Cid::from_git_object_bytes(b"pinata skip seed").to_string();
|
||||
db.record_pinata_cid(
|
||||
&sha,
|
||||
&raw_cid,
|
||||
"QmSeedProviderCid",
|
||||
Some("repo-seed"),
|
||||
i64::MAX,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.record_pinata_cid(&sha, &raw_cid, "QmSeedProviderCid", Some("repo-seed"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.record_pin_source(&sha, "repo-seed").await.unwrap();
|
||||
let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let endpoint = delaying_pinata_endpoint(
|
||||
@@ -1609,7 +1371,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-skip-stalled",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1620,7 +1381,7 @@ mod tests {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
pinned.is_empty(),
|
||||
"an already-pinned object is skipped, never re-uploaded: {pinned:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1664,7 +1425,7 @@ mod tests {
|
||||
///
|
||||
/// The lock time is a MARGIN, not a boundary: taking it at 100ms left
|
||||
/// `has_pinata_cid` racing it on a loaded box, and losing that race makes the read
|
||||
/// block, time out, and break the batch, which fails on `pinned.confirmed.len() == 1` for a
|
||||
/// block, time out, and break the batch, which fails on `pinned.len() == 1` for a
|
||||
/// reason that has nothing to do with the floor. Any time between the
|
||||
/// `has_pinata_cid` round trip and the upload's 1.7s return proves the same thing.
|
||||
#[sqlx::test]
|
||||
@@ -1710,7 +1471,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-spent-budget",
|
||||
Duration::from_millis(2000),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let (pinned, ()) = tokio::join!(pin, locker);
|
||||
@@ -1728,7 +1488,7 @@ mod tests {
|
||||
record makes api/repos.rs advertise a CID the resolver cannot serve"
|
||||
);
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
1,
|
||||
"the uploaded pin must still be returned: {pinned:?}"
|
||||
);
|
||||
@@ -1796,7 +1556,6 @@ mod tests {
|
||||
&db,
|
||||
"repo-pinata-post-upload",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -1810,21 +1569,10 @@ mod tests {
|
||||
drop(lock);
|
||||
|
||||
upload.assert_async().await;
|
||||
// The primary `record_pinata_cid` committed above naming this
|
||||
// repo as first pinner, so `pin_sources_for_oid` resolves the
|
||||
// copy with or without the redundant `pin_repo_sources`
|
||||
// insert: the pair stays confirmed even though the source
|
||||
// record timed out. The incomplete marker below is still set
|
||||
// (compensation preserved); only the suppression is gone.
|
||||
// Prior to the round 10 fix, the push fired regardless of
|
||||
// every post-upload outcome; the correction since is that
|
||||
// confirmation follows the durable primary row, not the
|
||||
// redundant source write.
|
||||
assert_eq!(
|
||||
pinned.confirmed.len(),
|
||||
pinned.len(),
|
||||
1,
|
||||
"record_pin_source timed out (pin_repo_sources locked) but the primary row \
|
||||
covers this repo, so the pair stays confirmed: {pinned:?}"
|
||||
"the upload succeeded, so this lane still returns the pair: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(8),
|
||||
@@ -1841,203 +1589,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the source-write contract: when the committed
|
||||
/// primary row names ANOTHER repo as first pinner, the failed
|
||||
/// `record_pin_source` was load-bearing (not redundant) and the
|
||||
/// push stays suppressed. Fixture: a local-only row owned by
|
||||
/// repo-first (no Pinata CID yet), then a Pinata loop as
|
||||
/// repo-second with `pin_repo_sources` locked. The upload lands,
|
||||
/// the primary upsert keeps repo-first, the source insert stalls,
|
||||
/// and coverage fails — so no pair, but the incomplete marker
|
||||
/// still lands and the provider CID is still recorded.
|
||||
#[sqlx::test]
|
||||
async fn pinata_post_upload_stalled_source_without_coverage_suppresses_push(
|
||||
pool: sqlx::PgPool,
|
||||
) {
|
||||
let db = crate::db::Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("pinata_second_source.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
let sha = oids[0].clone();
|
||||
|
||||
// A local-only row owned by repo-first: the writer path below
|
||||
// (repo-second, Pinata-only so far) must not claim it.
|
||||
let raw =
|
||||
gitlawb_core::cid::Cid::from_git_object_bytes(b"pinata loop object 0\n").to_string();
|
||||
db.record_pinned_cid_with_source(&sha, &raw, "repo-first")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let upload = server
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body(r#"{"data":{"cid":"QmSecondSourceProviderCid"}}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let (_logs, _log_guard) = capture_logs();
|
||||
let mut lock = lock_table(&pool, "pin_repo_sources").await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&server.url(),
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids,
|
||||
&db,
|
||||
"repo-second",
|
||||
Duration::from_millis(1500),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("record + coverage ladders are each floored");
|
||||
|
||||
rollback(&mut lock).await;
|
||||
drop(lock);
|
||||
|
||||
upload.assert_async().await;
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
"repo-second is not covered by the repo-first primary row, so the \
|
||||
failed source write stays suppressing: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
db.pin_sources_incomplete(&sha).await.unwrap(),
|
||||
"the compensation marker still lands"
|
||||
);
|
||||
// The primary upsert itself landed (provider CID recorded under
|
||||
// the first-pinner row); only the pair was suppressed.
|
||||
assert!(
|
||||
db.has_pinata_cid(&sha).await.unwrap(),
|
||||
"the committed provider row survives the suppressed push"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hold a row-level lock on one repos row in an open transaction: plain
|
||||
/// `SELECT`s (fence reads, skip checks) proceed, but any `SELECT ...
|
||||
/// FOR UPDATE` on the row — exactly what the fenced record takes —
|
||||
/// blocks until rollback. A table-level lock would also stall the
|
||||
/// fence's own epoch reads and deadlock the loop under test, proving
|
||||
/// nothing about the record arm.
|
||||
async fn hold_repo_row_lock(
|
||||
pool: &sqlx::PgPool,
|
||||
repo_id: &str,
|
||||
) -> sqlx::pool::PoolConnection<sqlx::Postgres> {
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
sqlx::raw_sql("BEGIN").execute(&mut *conn).await.unwrap();
|
||||
sqlx::query("SELECT policy_epoch FROM repos WHERE id = $1 FOR UPDATE")
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// A timed-out `record_pinata_cid` with no verifiable row suppresses the
|
||||
/// push: `record_pinata_cid` is an explicit transaction, so Elapsed proves
|
||||
/// nothing, and the verify read finds no row, so nothing is proven that
|
||||
/// way either. The upload mock still expects the POST — Pinata accepted
|
||||
/// the bytes — but without a durable row the pair must not be returned,
|
||||
/// or the reconcile counts a fill that never happened and the push path
|
||||
/// advertises an unresolvable CID.
|
||||
#[sqlx::test]
|
||||
async fn pinata_post_upload_elapsed_record_without_row_suppresses_push(pool: sqlx::PgPool) {
|
||||
let db = crate::db::Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.expect("migrations");
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let repo_path = tmp.path().join("pinata_elapsed_verify.git");
|
||||
let oids = seed_loose_blobs(&repo_path, 1);
|
||||
let sha = oids[0].clone();
|
||||
|
||||
// A real repo row: the fenced record's `FOR UPDATE` must have a row
|
||||
// to block on. Against a missing row the predicate takes no lock and
|
||||
// the record would sail through instead of stalling.
|
||||
let repo_id = "repo-pinata-elapsed-verify";
|
||||
let now = chrono::Utc::now();
|
||||
db.create_repo(&crate::db::RepoRecord {
|
||||
id: repo_id.to_string(),
|
||||
name: "elapsed-verify".into(),
|
||||
owner_did: "did:key:zElapsedVerifyOwner".into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: repo_path.display().to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Capture the fence BEFORE locking: the capture itself is a plain
|
||||
// epoch read and must succeed for the fenced record path to run.
|
||||
let fence = crate::ipfs_pin::PolicyFence::capture(&db, repo_id)
|
||||
.await
|
||||
.expect("fence captures");
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let upload = server
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body(r#"{"data":{"cid":"QmElapsedVerifyProviderCid"}}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let (_logs, _log_guard) = capture_logs();
|
||||
|
||||
// Stall only the fenced record's `FOR UPDATE`: fence reads and the
|
||||
// skip check are plain SELECTs and proceed, the upload lands, then
|
||||
// the record elapses. The verify read proceeds too and finds no row.
|
||||
let mut lock = hold_repo_row_lock(&pool, repo_id).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let pinned = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
pin_new_objects(
|
||||
&client,
|
||||
&server.url(),
|
||||
"test-jwt",
|
||||
&repo_path,
|
||||
"git",
|
||||
Duration::from_secs(30),
|
||||
oids,
|
||||
&db,
|
||||
repo_id,
|
||||
Duration::from_millis(1500),
|
||||
Some(&fence),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect(
|
||||
"record + verify ladders are each floored, so the call returns in seconds, \
|
||||
never at the lock's lifetime",
|
||||
);
|
||||
|
||||
rollback(&mut lock).await;
|
||||
drop(lock);
|
||||
|
||||
upload.assert_async().await;
|
||||
assert!(
|
||||
pinned.confirmed.is_empty(),
|
||||
"an unverified timed-out record must not return the pair: {pinned:?}"
|
||||
);
|
||||
assert!(
|
||||
!db.has_pinata_cid(&sha).await.unwrap(),
|
||||
"the cancelled record transaction must not have landed a row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pin_skipped_when_jwt_empty() {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
@@ -70,14 +70,6 @@ impl RateLimiter {
|
||||
self.window.min(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
/// The configured window, exposed so a 429 can carry an honest
|
||||
/// `retry-after`: a hardcoded value lies when the operator retunes the
|
||||
/// window, and a client that honors the header would retry into another
|
||||
/// 429 until the real window elapses.
|
||||
pub fn window(&self) -> Duration {
|
||||
self.window
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -301,7 +293,12 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
|
||||
if let (Some(limiter), Some(did)) = (limiter, did) {
|
||||
if !limiter.check(&did).await {
|
||||
return too_many_requests(limiter.window().as_secs());
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,72 +401,18 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The message every per-IP brake answers with, shared so the GraphQL surface
|
||||
/// (which cannot return a 429 status inside a 200 GraphQL envelope) says the
|
||||
/// same thing as the REST routes.
|
||||
pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later";
|
||||
|
||||
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
|
||||
/// middleware now serves the push path AND the peer-sync routes, so the message
|
||||
/// stays generic (the offending path is recorded in the warn log below).
|
||||
pub fn too_many_requests(retry_after_secs: u64) -> Response {
|
||||
pub fn too_many_requests() -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", retry_after_secs.to_string())],
|
||||
RATE_LIMIT_MESSAGE,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// The per-IP task-read brake carried as GraphQL request data: the limiter plus
|
||||
/// the client key the transport already resolved.
|
||||
///
|
||||
/// `/graphql` is a single POST endpoint, so this brake cannot sit in middleware
|
||||
/// the way `task_read_routes` mounts `rate_limit_by_ip`: that would charge every
|
||||
/// unrelated query and every mutation against the task-read bucket. The two task
|
||||
/// resolvers debit it instead, which also prices an aliased query honestly — ten
|
||||
/// aliased `tasks` fields run the visibility gate ten times and pay ten slots,
|
||||
/// where one request-scoped debit would pay one (#327 review).
|
||||
/// Default cap on the number of task read fields (across all aliases) permitted
|
||||
/// in a single GraphQL request before rejecting further field executions (#327 review).
|
||||
pub const MAX_GRAPHQL_TASK_READS_PER_REQUEST: usize = 5;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TaskReadBrake {
|
||||
pub limiter: RateLimiter,
|
||||
/// `None` when no key could be resolved at all (no trusted header and no
|
||||
/// `ConnectInfo`, e.g. a synthetic test request). Never braked, matching
|
||||
/// [`rate_limit_by_ip`].
|
||||
pub key: Option<String>,
|
||||
pub request_count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TaskReadBrake {
|
||||
/// Debit one slot for one task-read field. `true` = proceed.
|
||||
pub async fn check(&self, field: &str) -> bool {
|
||||
let Some(key) = self.key.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
let count = self
|
||||
.request_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if count >= MAX_GRAPHQL_TASK_READS_PER_REQUEST {
|
||||
tracing::warn!(
|
||||
key = %key,
|
||||
field = %field,
|
||||
count = count + 1,
|
||||
"per-request GraphQL task field limit exceeded"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if self.limiter.check(key).await {
|
||||
return true;
|
||||
}
|
||||
tracing::warn!(key = %key, field = %field, "per-IP rate limit exceeded");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Throttle the git push path by resolved client IP. The socket peer address is
|
||||
/// read from `ConnectInfo` (see `into_make_service_with_connect_info` in
|
||||
/// `main`). Only skips the limiter when no key can be resolved at all.
|
||||
@@ -484,7 +427,7 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
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(limiter.limiter.window().as_secs());
|
||||
return too_many_requests();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+30
-1132
File diff suppressed because it is too large.
Load diff
@@ -59,10 +59,6 @@ pub struct AppState {
|
||||
pub task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
/// GraphQL schema (queries + mutations + subscriptions)
|
||||
pub graphql_schema: Arc<crate::graphql::GitlawbSchema>,
|
||||
/// MAC key for task-list continuation tokens, derived from the node
|
||||
/// keypair seed. Held here (and handed to the GraphQL schema) so REST and
|
||||
/// GraphQL mint and accept the same tokens.
|
||||
pub task_cursor_key: crate::api::task_cursor::TaskCursorKey,
|
||||
/// Fly.io machine ID — used for fly-replay routing in multi-machine deployments
|
||||
pub machine_id: Option<String>,
|
||||
/// Centralized repo storage: local disk cache + optional Tigris backend
|
||||
@@ -194,9 +190,6 @@ pub struct AppState {
|
||||
/// sink as trigger and accepts unsigned requests from known peers, so it is
|
||||
/// braked too; each peer's distinct IP gets its own bucket.
|
||||
pub peer_write_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP federation budget: 12 requests per minute, with at most
|
||||
/// 10,000 tracked source keys. Shared by router instances and swept below.
|
||||
pub federated_rate_limiter: RateLimiter,
|
||||
/// Process-wide graceful-shutdown signal. Sending `true` causes every
|
||||
/// task that holds a `watch::Receiver` to exit at its next await point.
|
||||
/// Used by:
|
||||
@@ -330,16 +323,6 @@ pub struct AppState {
|
||||
/// (`with_default_max_keys`, reject-before-insert) so a source-key farm cannot grow
|
||||
/// it (INV-15).
|
||||
pub git_ipfs_walk_per_caller: crate::rate_limit::PerCallerConcurrency,
|
||||
/// Per-client-IP rate limiter for the task read routes. `GET /api/v1/tasks`
|
||||
/// and `GET /api/v1/tasks/{id}` are anonymously reachable and run the #268
|
||||
/// visibility gate (a task lookup plus deduped-repo and visibility-rule
|
||||
/// queries) before returning the opaque 404, so an unauthenticated prober
|
||||
/// costs the node real work per request whether or not anything is visible.
|
||||
/// Keyed on the resolved client IP via `push_limiter_trust`. Layered on
|
||||
/// `task_read_routes` via `rate_limit_by_ip`.
|
||||
/// The same per-IP hourly budget applies to GraphQL `tasks` / `task`
|
||||
/// queries and WebSocket task queries via `TaskReadBrake`.
|
||||
pub task_read_rate_limiter: RateLimiter,
|
||||
/// The `git` executable the served-git withheld-blob walk spawns. Production is
|
||||
/// `"git"` (resolved via PATH); injectable so a fake `git` can drive the walk's
|
||||
/// process-group teardown in handler tests without mutating the process-global
|
||||
@@ -368,8 +351,6 @@ impl AppState {
|
||||
self.ipfs_work_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
self.peer_write_rate_limiter.cleanup().await;
|
||||
self.task_read_rate_limiter.cleanup().await;
|
||||
self.federated_rate_limiter.cleanup().await;
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown. Idempotent — calling more than once
|
||||
|
||||
+10
-862
@@ -14,7 +14,6 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gitlawb_core::identity::Keypair;
|
||||
@@ -436,10 +435,9 @@ async fn process_batch(
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Prune vs terminal classification lives in
|
||||
// `handle_sync_item_error` (defer = stays pending for the
|
||||
// next tick; anything else = failed).
|
||||
handle_sync_item_error(db, &item, &origin_url, e).await;
|
||||
warn!(repo = %item.repo, origin = %origin_url, err = %e, "repo sync failed");
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
crate::metrics::record_sync_processed("failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,284 +691,6 @@ async fn existing_promisor_state(repo: &str) -> PromisorProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte-prefix check for the two node-managed blob namespaces exempt from
|
||||
/// the fail-closed commit walk (`visibility_pack.rs`): request markers
|
||||
/// (`refs/gitlawb/requests/`) and issue records (`refs/gitlawb/issues/`).
|
||||
/// Bytewise so non-UTF-8 refnames (which git accepts) are classified, not
|
||||
/// lossy-decoded into a false exempt match. Extracted so the predicate is
|
||||
/// unit-testable; [`prune_non_exempt_gitlawb_refs`] enforces it on import.
|
||||
fn gitlawb_ref_is_exempt(raw_refname: &[u8]) -> bool {
|
||||
raw_refname.starts_with(b"refs/gitlawb/requests/")
|
||||
|| raw_refname.starts_with(b"refs/gitlawb/issues/")
|
||||
}
|
||||
|
||||
/// Parse one `git show-ref` output line into its refname bytes: `<hex-sha>`
|
||||
/// (40-hex SHA-1 or 64-hex SHA-256, which a sha256 mirror inherits from its
|
||||
/// origin), one space, then the refname. Empty lines (trailing newline) yield
|
||||
/// `Ok(None)`; anything else-shaped is an anomaly the caller fails on rather
|
||||
/// than skips — silently passing an unclassifiable line could leave an evil
|
||||
/// ref imported. Extracted so malformed input is unit-testable without git.
|
||||
fn show_ref_refname(line: &[u8]) -> anyhow::Result<Option<&[u8]>> {
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(sp) = line.iter().position(|b| *b == b' ') else {
|
||||
return Err(anyhow::anyhow!("git show-ref emitted an unparsable line"));
|
||||
};
|
||||
let (sha, raw) = (&line[..sp], &line[sp + 1..]);
|
||||
let sha_ok = (sha.len() == 40 || sha.len() == 64) && sha.iter().all(|b| b.is_ascii_hexdigit());
|
||||
if !sha_ok || raw.is_empty() {
|
||||
return Err(anyhow::anyhow!("git show-ref emitted an unparsable line"));
|
||||
}
|
||||
Ok(Some(raw))
|
||||
}
|
||||
|
||||
/// Delete imported `refs/gitlawb/*` refs outside the two exempt subtrees.
|
||||
///
|
||||
/// Mirrors import whatever the origin advertises (`+refs/*:refs/*`), and
|
||||
/// `transfer.hideRefs` is a serve-side knob: a repo's own `git fetch` never
|
||||
/// consults local hideRefs, and origins hide only `requests/` anyway. So a
|
||||
/// planted `refs/gitlawb/<other>` blob ref — creatable on any pre-gate node —
|
||||
/// lands on every mirror and then trips `assert_all_refs_are_commits` closed,
|
||||
/// wedging serving on each one. Prune at import instead: after clone/fetch,
|
||||
/// list `refs/gitlawb` and delete every ref outside the exempt subtrees
|
||||
/// (negative refspecs cannot re-include them, so post-import deletion is the
|
||||
/// mechanism that works).
|
||||
///
|
||||
/// Callers map a transient prune error to [`PruneFailed`] so the batch
|
||||
/// defers the row (stays `pending`, retried next tick) instead of failing
|
||||
/// it: a failed row would leave a possibly-wedged mirror unretried until
|
||||
/// the next peer announcement. Deterministic refusals (identity mismatch,
|
||||
/// malformed enumeration) map to [`PruneInvalid`], which fails terminally
|
||||
/// since no retry can clear them.
|
||||
///
|
||||
/// Enumeration is `git show-ref` (no pattern), parsed as raw bytes: refnames
|
||||
/// cannot contain newlines, so one line per ref is lossless even for
|
||||
/// non-UTF-8 names, which a lossy decode would miss. The `<sha> SP <refname>`
|
||||
/// structure (not `for-each-ref --format=%(refname)`, which carries no object
|
||||
/// id) is what lets the parser width-check 40-hex SHA-1 vs 64-hex SHA-256
|
||||
/// and reject anything else-shaped instead of skipping it.
|
||||
///
|
||||
/// Fail-closed throughout: the exit code is classified (only `1` with empty
|
||||
/// output means "no refs"; anything else non-zero, e.g. 128 on a missing
|
||||
/// repo, is fatal, since a silent skip would leave an imported evil ref in
|
||||
/// place while the sync reports success), and any line that is not
|
||||
/// `<hex-sha> SP <refname>` — 40-hex SHA-1 or 64-hex SHA-256, which a sha256
|
||||
/// mirror inherits from its origin — fails the sync rather than being
|
||||
/// skipped into an unpruned wedge.
|
||||
/// Bounded git spawn for the prune below: `kill_on_drop` plus a hard timeout
|
||||
/// so a wedged child cannot stall a mirror import or sweep forever. The
|
||||
/// refname delete runs on exact bytes, so args arrive as `OsStr`, not `str`.
|
||||
/// Non-PruneInvalid failures (including a timeout) wrap as PruneFailed and
|
||||
/// defer for retry, matching the transient classification below.
|
||||
async fn prune_git(
|
||||
local_path: &Path,
|
||||
args: &[&std::ffi::OsStr],
|
||||
label: &str,
|
||||
) -> anyhow::Result<std::process::Output> {
|
||||
let fut = async {
|
||||
// allow-unbounded-git: bounded by the tokio::time::timeout wrapping
|
||||
// this future below, plus kill_on_drop.
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.kill_on_drop(true);
|
||||
cmd.arg("-C").arg(local_path).args(args);
|
||||
cmd.output().await
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), fut)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("{label} timed out"))?
|
||||
.map_err(|e| anyhow::anyhow!("{label} failed to spawn: {e}"))
|
||||
}
|
||||
|
||||
async fn prune_non_exempt_gitlawb_refs(local_path: &Path) -> anyhow::Result<()> {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
// Prove the path is itself a repo before trusting the enumeration:
|
||||
// `git -C` walks up to the nearest ancestor repo, so inside a non-repo
|
||||
// child (a partially-created mirror) `show-ref` would exit 0 with the
|
||||
// ANCESTOR's refs and `update-ref -d` would then run against that repo.
|
||||
// Require `rev-parse --absolute-git-dir` to resolve to the path itself
|
||||
// (mirrors are bare repos, where the git dir IS the path).
|
||||
let git_dir_out = prune_git(
|
||||
local_path,
|
||||
&[
|
||||
std::ffi::OsStr::new("rev-parse"),
|
||||
std::ffi::OsStr::new("--absolute-git-dir"),
|
||||
],
|
||||
"git rev-parse",
|
||||
)
|
||||
.await?;
|
||||
if !git_dir_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&git_dir_out.stderr);
|
||||
return Err(anyhow::anyhow!(PruneInvalid(format!(
|
||||
"mirror path is not a git repo (rev-parse failed): {stderr}"
|
||||
))));
|
||||
}
|
||||
let actual_dir = PathBuf::from(
|
||||
String::from_utf8_lossy(&git_dir_out.stdout)
|
||||
.trim()
|
||||
.to_string(),
|
||||
);
|
||||
let canonical_path = local_path
|
||||
.canonicalize()
|
||||
.map_err(|e| anyhow::anyhow!(PruneInvalid(format!("mirror path does not resolve: {e}"))))?;
|
||||
let canonical_git_dir = actual_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| anyhow::anyhow!(PruneInvalid(format!("git dir does not resolve: {e}"))))?;
|
||||
if canonical_git_dir != canonical_path {
|
||||
return Err(anyhow::anyhow!(PruneInvalid(format!(
|
||||
"mirror path {local_str} is not itself a repo (git dir resolves elsewhere); refusing to enumerate"
|
||||
))));
|
||||
}
|
||||
let out = prune_git(
|
||||
local_path,
|
||||
&[std::ffi::OsStr::new("show-ref")],
|
||||
"git show-ref",
|
||||
)
|
||||
.await?;
|
||||
// Classify on the exit code (the `existing_promisor_state` precedent):
|
||||
// 0 parses, 1 with empty output is an empty repo, anything else is a
|
||||
// fatal enumeration failure that must not read as "nothing to prune".
|
||||
match out.status.code() {
|
||||
Some(0) => {}
|
||||
Some(1) if out.stdout.is_empty() => return Ok(()),
|
||||
_ => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"git show-ref failed with code {:?}: {stderr}",
|
||||
out.status.code()
|
||||
));
|
||||
}
|
||||
}
|
||||
for line in out.stdout.split(|b| *b == b'\n') {
|
||||
// Malformed lines are deterministic (same git, same output every
|
||||
// tick): fail terminally via PruneInvalid, not transiently.
|
||||
let Some(raw) =
|
||||
show_ref_refname(line).map_err(|e| anyhow::anyhow!(PruneInvalid(e.to_string())))?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !(raw == b"refs/gitlawb" || raw.starts_with(b"refs/gitlawb/"))
|
||||
|| gitlawb_ref_is_exempt(raw)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Delete by exact bytes (not the lossy display string below): a
|
||||
// non-UTF-8 refname would otherwise survive the prune.
|
||||
let refname_os = os_from_raw_refname(raw);
|
||||
let shown = refname_os.to_string_lossy().into_owned();
|
||||
let delete = prune_git(
|
||||
local_path,
|
||||
&[
|
||||
std::ffi::OsStr::new("update-ref"),
|
||||
std::ffi::OsStr::new("-d"),
|
||||
refname_os.as_os_str(),
|
||||
std::ffi::OsStr::new("--no-deref"),
|
||||
],
|
||||
"git update-ref -d",
|
||||
)
|
||||
.await?;
|
||||
if !delete.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&delete.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"git update-ref -d {shown} failed (mirror still carries a non-exempt refs/gitlawb ref): {stderr}"
|
||||
));
|
||||
}
|
||||
tracing::info!(
|
||||
gitlawb_ref = %shown,
|
||||
"mirror: pruned non-exempt refs/gitlawb ref on import"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lossless bytes → OsString for `update-ref -d`: non-UTF-8 refnames must be
|
||||
/// deleted by exact bytes. Unix is the serving platform; elsewhere fall back
|
||||
/// to a lossy decode (best effort where exact bytes are unrepresentable).
|
||||
#[cfg(unix)]
|
||||
fn os_from_raw_refname(raw: &[u8]) -> std::ffi::OsString {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
std::ffi::OsString::from_vec(raw.to_vec())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn os_from_raw_refname(raw: &[u8]) -> std::ffi::OsString {
|
||||
String::from_utf8_lossy(raw).into_owned().into()
|
||||
}
|
||||
|
||||
/// Marker for mirror-prune failures: the clone/fetch succeeded, so the
|
||||
/// mirror is on disk, but a non-exempt `refs/gitlawb/*` ref may still be
|
||||
/// imported. Local-git conditions are transient (a retry can succeed), while
|
||||
/// `mark_sync_failed` is terminal until the next peer announcement — and a
|
||||
/// failed row would leave a possibly-wedged mirror unretried in the
|
||||
/// meantime. The batch error arm downcasts to this and defers the row
|
||||
/// (stays `pending`, retried on the next 30s tick, where fetch+prune are
|
||||
/// idempotent) instead of failing it.
|
||||
#[derive(Debug)]
|
||||
struct PruneFailed(String);
|
||||
|
||||
impl std::fmt::Display for PruneFailed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "mirror prune failed: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PruneFailed {}
|
||||
|
||||
/// Marker for deterministic mirror-prune refusals: the identity check (path
|
||||
/// is not itself a repo) and malformed `show-ref` output cannot clear on
|
||||
/// retry — re-running fetch+prune every tick would spin forever on a
|
||||
/// permanently bad path. Unlike [`PruneFailed`], these fail the row
|
||||
/// terminally so the condition surfaces instead of looping silently.
|
||||
#[derive(Debug)]
|
||||
struct PruneInvalid(String);
|
||||
|
||||
/// Preserve a deterministic prune refusal through the clone/fetch call
|
||||
/// sites: `prune_non_exempt_gitlawb_refs` already returns `PruneInvalid`
|
||||
/// for identity mismatches and malformed enumeration, and re-wrapping
|
||||
/// every error as `PruneFailed` would stringify that type away before
|
||||
/// `handle_sync_item_error` downcasts it — leaving a permanently invalid
|
||||
/// mirror deferred (fetch+prune every tick) instead of failed. Transient
|
||||
/// failures keep the `PruneFailed` deferral.
|
||||
fn wrap_prune_error(e: anyhow::Error) -> anyhow::Error {
|
||||
match e.downcast::<PruneInvalid>() {
|
||||
Ok(invalid) => anyhow::Error::new(invalid),
|
||||
Err(other) => anyhow::anyhow!(PruneFailed(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PruneInvalid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "mirror prune refused: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PruneInvalid {}
|
||||
|
||||
/// Classify a mirror sync failure for one queue row. Transient prune
|
||||
/// failures ([`PruneFailed`]) defer: the mirror is on disk but may still
|
||||
/// carry a non-exempt refs/gitlawb ref, a transient local-git condition a
|
||||
/// retry can clear — failing the row would leave it unretried until the
|
||||
/// next peer announcement, wedging serving in the meantime. Deterministic
|
||||
/// refusals ([`PruneInvalid`]) and everything else fail the row terminally:
|
||||
/// retrying a permanently bad path every tick would spin forever instead of
|
||||
/// surfacing. Extracted so the deferral (not just the prune itself) is
|
||||
/// unit-testable.
|
||||
async fn handle_sync_item_error(
|
||||
db: &Db,
|
||||
item: &crate::db::SyncQueueItem,
|
||||
origin_url: &str,
|
||||
err: anyhow::Error,
|
||||
) {
|
||||
if err.downcast_ref::<PruneFailed>().is_some() {
|
||||
warn!(repo = %item.repo, origin = %origin_url, err = %err, "mirror prune failed; leaving sync row pending for retry");
|
||||
crate::metrics::record_sync_processed("deferred");
|
||||
return;
|
||||
}
|
||||
warn!(repo = %item.repo, origin = %origin_url, err = %err, "repo sync failed");
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
crate::metrics::record_sync_processed("failed");
|
||||
}
|
||||
|
||||
/// Mirror-clone a repo from a remote URL into a local bare repo.
|
||||
/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
|
||||
/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
|
||||
@@ -981,7 +701,6 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
if mode == MirrorMode::Promisor {
|
||||
args.push("--filter=blob:limit=10g");
|
||||
}
|
||||
args.push("--");
|
||||
args.push(remote_url);
|
||||
args.push(local_str);
|
||||
|
||||
@@ -995,13 +714,6 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(anyhow::anyhow!("git clone --mirror failed: {stderr}"));
|
||||
}
|
||||
// A fresh mirror imports everything the origin advertises, including any
|
||||
// planted non-exempt `refs/gitlawb/*` ref; prune before serving from it.
|
||||
// Prune outcome flows through `wrap_prune_error`: transient failures
|
||||
// defer (stays pending), deterministic refusals fail terminally.
|
||||
prune_non_exempt_gitlawb_refs(local_path)
|
||||
.await
|
||||
.map_err(wrap_prune_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1017,16 +729,9 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
|
||||
git_run(&[
|
||||
"-C", local_str, "remote", "set-url", "origin", "--", remote_url,
|
||||
])
|
||||
.await?;
|
||||
git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?;
|
||||
|
||||
// The fetch imports whatever the origin advertises via the stored
|
||||
// `+refs/*:refs/*` refspec; prune non-exempt `refs/gitlawb/*` refs after
|
||||
// every fetch so one planted origin ref cannot wedge serving on the
|
||||
// mirror (see `prune_non_exempt_gitlawb_refs`).
|
||||
let fetch_result = match mode {
|
||||
match mode {
|
||||
MirrorMode::Promisor => {
|
||||
git_run(&["-C", local_str, "config", "remote.origin.promisor", "true"]).await?;
|
||||
git_run(&[
|
||||
@@ -1066,13 +771,7 @@ async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> an
|
||||
git_run(&["-C", local_str, "fetch", "--prune", "origin"]).await
|
||||
}
|
||||
}
|
||||
};
|
||||
fetch_result?;
|
||||
// Prune after every fetch (see above); outcome flows through
|
||||
// `wrap_prune_error` like the clone path.
|
||||
prune_non_exempt_gitlawb_refs(local_path)
|
||||
.await
|
||||
.map_err(wrap_prune_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1307,82 +1006,6 @@ mod tests {
|
||||
assert_eq!(probe, PromisorProbe::NotPromisor);
|
||||
}
|
||||
|
||||
/// #374: `clone_repo` interpolates the peer's origin URL into a `git
|
||||
/// clone --mirror` argument list. Without a `--` delimiter git parses a
|
||||
/// remote beginning with `-` as an option instead of a repository.
|
||||
///
|
||||
/// The delimiter is what forces the same string to be a repository, and
|
||||
/// git says so in its own words: it reports the whole argument as a
|
||||
/// repository it cannot find. Undelimited, git consumes it as an option
|
||||
/// and fails against the *destination* instead (`fatal: repository '<dest>' does not exist`),
|
||||
/// never naming the injected string — which is why this assertion is RED
|
||||
/// before the fix and GREEN after.
|
||||
///
|
||||
/// The payload is deliberately free of `:` and spaces: a colon would make
|
||||
/// git read the argument as an ssh-style `host:path` URL and report a
|
||||
/// hostname rather than a repository, which is a different code path.
|
||||
#[tokio::test]
|
||||
async fn clone_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = clone_repo(injected, &dest, MirrorMode::Plain)
|
||||
.await
|
||||
.expect_err("a remote that is not a repository must fail the clone")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(injected),
|
||||
"git must report the argument as a repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #374: `fetch_repo` interpolates the peer's origin URL into
|
||||
/// `git remote set-url`, a second argv construction with the same contract as
|
||||
/// the clone. It needs its own proof: the clone test cannot reach this path.
|
||||
///
|
||||
/// The assertion is on stored state rather than on the error string, and that
|
||||
/// is deliberate. `git_run` formats its failure as `git {args:?} failed:
|
||||
/// {stderr}`, so the injected value is in the message via the debug-printed
|
||||
/// argv whether or not the delimiter is present — asserting on containment
|
||||
/// would pass with `--` deleted. Delimited, `set-url` succeeds and stores the
|
||||
/// value verbatim; undelimited, git exits 129 with `unknown option` and leaves
|
||||
/// the previous URL in place, so this goes red without the fix.
|
||||
///
|
||||
/// The fetch that follows the rewrite fails, because the stored URL is not a
|
||||
/// repository. That is expected and ignored: the property under test is what
|
||||
/// `set-url` wrote, not whether the subsequent fetch could succeed.
|
||||
#[tokio::test]
|
||||
async fn fetch_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let local = td.path().join("mirror.git");
|
||||
let local_str = local.to_str().unwrap();
|
||||
let placeholder = "http://example.invalid/x.git";
|
||||
|
||||
assert!(Command::new("git")
|
||||
.args(["init", "-q", "--bare", local_str])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", local_str, "remote", "add", "origin", placeholder])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
|
||||
let injected = "--upload-pack=false";
|
||||
let _ = fetch_repo(&local, injected, MirrorMode::Plain).await;
|
||||
|
||||
assert_eq!(
|
||||
git_config_get(local_str, "remote.origin.url")
|
||||
.await
|
||||
.as_deref(),
|
||||
Some(injected),
|
||||
"set-url must store an option-shaped remote verbatim, not reject it as an option"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_unknown_on_git_error() {
|
||||
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
|
||||
@@ -1627,18 +1250,6 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Use a rooted path without a Windows drive prefix in the remote fixture.
|
||||
/// The destination still resolves on the temp directory's current drive,
|
||||
/// while the remote's relative path contains no illegal colon component.
|
||||
fn absolute_slug_path(path: &Path) -> String {
|
||||
assert!(path.is_absolute());
|
||||
path.components()
|
||||
.filter(|part| !matches!(part, std::path::Component::Prefix(_)))
|
||||
.collect::<std::path::PathBuf>()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn process_batch_rejects_slug_escaping_repos_dir(pool: PgPool) {
|
||||
// The verified escape from #272: `PathBuf::join` discards everything
|
||||
@@ -1654,13 +1265,12 @@ mod tests {
|
||||
// fails on but leaves the parent it created, so assert on both.
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/escape");
|
||||
let slug = format!("a/{}/escape", escape_dir.display());
|
||||
let escape_target = escape_dir.join("escape.git");
|
||||
|
||||
// Serve the composed URL for real, so a run without the guard genuinely
|
||||
// clones outside the root rather than merely failing at git.
|
||||
let rel = format!("a{escape_path}/escape");
|
||||
let rel = format!("a{}/escape", escape_dir.display());
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
|
||||
let did = "did:key:z6MkAttacker";
|
||||
@@ -1780,7 +1390,6 @@ mod tests {
|
||||
// ── canonical containment before the git call (issue #272) ───────────────
|
||||
|
||||
/// Every ref in `repo`, as one string, for a before/after comparison.
|
||||
#[cfg(unix)]
|
||||
fn refs_of(repo: &Path) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(["-C", repo.to_str().unwrap(), "for-each-ref"])
|
||||
@@ -2113,7 +1722,6 @@ mod tests {
|
||||
/// starvation tests is fixed rather than dependent on how fast the loop
|
||||
/// runs. Two rows enqueued in the same microsecond would otherwise order
|
||||
/// arbitrarily.
|
||||
#[cfg(unix)]
|
||||
async fn enqueue_at(db: &Db, pool: &PgPool, repo: &str, did: &str, enqueued_at: &str) {
|
||||
enqueue(db, repo, did).await;
|
||||
sqlx::query("UPDATE sync_queue SET enqueued_at = $1 WHERE repo = $2")
|
||||
@@ -2307,8 +1915,7 @@ mod tests {
|
||||
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/gitlawb-probe");
|
||||
let slug = format!("a/{}/gitlawb-probe", escape_dir.display());
|
||||
let escape_target = escape_dir.join("gitlawb-probe.git");
|
||||
|
||||
// Two things this fixture must get right or the test is green for the
|
||||
@@ -2319,7 +1926,7 @@ mod tests {
|
||||
// run without the guard genuinely clones outside the root instead of
|
||||
// just failing at git. Db::upsert_peer cannot seed this row: it gates on
|
||||
// is_public_http_url, which rejects file://.
|
||||
let rel = format!("a{escape_path}/gitlawb-probe");
|
||||
let rel = format!("a{}/gitlawb-probe", escape_dir.display());
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
let did = "did:key:z6MkAttacker";
|
||||
seed_local_peer(&pool, did, &peer_url).await;
|
||||
@@ -2405,463 +2012,4 @@ mod tests {
|
||||
assert!(object_count(&mirror) > 0, "mirror has no objects");
|
||||
assert_eq!(sync_status(&pool, "z6Mkfoo/hello").await, "done");
|
||||
}
|
||||
|
||||
/// The import-exemption predicate is bytewise over both node-managed
|
||||
/// namespaces and nothing else. Non-UTF-8 bytes never match (git accepts
|
||||
/// them, so a lossy decode could false-exempt). Narrowing either prefix
|
||||
/// turns these red.
|
||||
#[test]
|
||||
fn gitlawb_import_exemption_covers_both_namespaces_by_bytes() {
|
||||
for exempt in [
|
||||
&b"refs/gitlawb/requests/550e8400-e29b-41d4-a716-446655440000"[..],
|
||||
&b"refs/gitlawb/issues/abc123"[..],
|
||||
] {
|
||||
assert!(
|
||||
gitlawb_ref_is_exempt(exempt),
|
||||
"import must keep {}",
|
||||
String::from_utf8_lossy(exempt)
|
||||
);
|
||||
}
|
||||
for pruned in [
|
||||
&b"refs/gitlawb/evil"[..],
|
||||
&b"refs/gitlawb"[..],
|
||||
&b"refs/gitlawbfoo"[..],
|
||||
&b"refs/heads/main"[..],
|
||||
&b""[..],
|
||||
// Raw 0xFF byte: never an exempt prefix, always pruned.
|
||||
&b"refs/gitlawb/\xffvil"[..],
|
||||
] {
|
||||
assert!(
|
||||
!gitlawb_ref_is_exempt(pruned),
|
||||
"import must prune {}",
|
||||
String::from_utf8_lossy(pruned)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end prune on a scratch bare repo: a planted non-exempt
|
||||
/// `refs/gitlawb/*` blob ref is deleted, exempt `requests/`/`issues/`
|
||||
/// blob refs and content refs survive. Removing the prune call from
|
||||
/// `clone_repo`/`fetch_repo` leaves the evil ref in place, which is what
|
||||
/// wedges serving on mirrors — this test plants exactly that state and
|
||||
/// proves the prune removes it.
|
||||
#[test]
|
||||
fn prune_removes_non_exempt_gitlawb_refs_and_keeps_exempt() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
rt.block_on(async {
|
||||
let td = TempDir::new().unwrap();
|
||||
let work = td.path().join("work");
|
||||
let bare = td.path().join("m.git");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
// A real commit for the content branch (branch refs must point
|
||||
// at commits; the evil/exempt refs below point at blobs).
|
||||
std::fs::write(work.join("f.txt"), b"x\n").unwrap();
|
||||
let run_work = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
run_work(&["init", "-q"]);
|
||||
run_work(&["config", "user.email", "t@t"]);
|
||||
run_work(&["config", "user.name", "t"]);
|
||||
run_work(&["add", "f.txt"]);
|
||||
run_work(&["commit", "-qm", "init"]);
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"-q",
|
||||
"--bare",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap()
|
||||
])
|
||||
.current_dir(td.path())
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git clone --bare failed"
|
||||
);
|
||||
let run = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&bare)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
let run_out = |args: &[&str]| {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?} failed");
|
||||
out.stdout
|
||||
};
|
||||
run(&["init", "-q", "--bare", "."]);
|
||||
std::fs::write(bare.join("blob-body"), b"marker\n").unwrap();
|
||||
let blob = String::from_utf8_lossy(&run_out(&["hash-object", "-w", "blob-body"]))
|
||||
.trim()
|
||||
.to_string();
|
||||
// The clone carries the content branch; plant the evil ref and
|
||||
// both exempt refs (all blob-pointing, like node metadata).
|
||||
run(&["update-ref", "refs/gitlawb/evil", &blob]);
|
||||
run(&["update-ref", "refs/gitlawb/requests/r1", &blob]);
|
||||
run(&["update-ref", "refs/gitlawb/issues/i1", &blob]);
|
||||
|
||||
prune_non_exempt_gitlawb_refs(&bare)
|
||||
.await
|
||||
.expect("prune succeeds");
|
||||
|
||||
let raw_refs = run_out(&["for-each-ref", "--format=%(refname)"]);
|
||||
let refs = String::from_utf8_lossy(&raw_refs);
|
||||
let set: std::collections::HashSet<&str> = refs
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
assert!(
|
||||
!set.contains("refs/gitlawb/evil"),
|
||||
"non-exempt blob ref must be pruned, still present: {set:?}"
|
||||
);
|
||||
for keep in ["refs/gitlawb/requests/r1", "refs/gitlawb/issues/i1"] {
|
||||
assert!(set.contains(keep), "{keep} must survive the prune: {set:?}");
|
||||
}
|
||||
assert!(
|
||||
set.iter().any(|r| r.starts_with("refs/heads/")),
|
||||
"content branch ref must survive the prune: {set:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// `show-ref` line parser: 40-hex SHA-1 and 64-hex SHA-256 lines yield
|
||||
/// the refname, empty lines yield None, and anything else-shaped errors
|
||||
/// (the caller fails the sync rather than skipping into an unpruned
|
||||
/// wedge). Reverting to a 40-only width check turns the sha256 case red.
|
||||
#[test]
|
||||
fn show_ref_line_parser_accepts_both_hash_widths_and_rejects_junk() {
|
||||
// 40-hex SHA-1 line.
|
||||
let sha1_line = format!("{} refs/gitlawb/evil", "a".repeat(40));
|
||||
assert_eq!(
|
||||
show_ref_refname(sha1_line.as_bytes()).unwrap(),
|
||||
Some(b"refs/gitlawb/evil".as_slice())
|
||||
);
|
||||
// 64-hex SHA-256 line (a sha256 mirror inherits the width).
|
||||
let sha256_line = format!("{} refs/gitlawb/evil", "b".repeat(64));
|
||||
assert_eq!(
|
||||
show_ref_refname(sha256_line.as_bytes()).unwrap(),
|
||||
Some(b"refs/gitlawb/evil".as_slice())
|
||||
);
|
||||
// Non-UTF-8 refname passes through as exact bytes.
|
||||
let mut raw = vec![b'c'; 40];
|
||||
raw.extend_from_slice(b" refs/gitlawb/\xffvil");
|
||||
assert_eq!(
|
||||
show_ref_refname(&raw).unwrap(),
|
||||
Some(b"refs/gitlawb/\xffvil".as_slice())
|
||||
);
|
||||
// Trailing newline residue.
|
||||
assert_eq!(show_ref_refname(b"").unwrap(), None);
|
||||
// No space, short sha, non-hex sha, empty refname: all errors.
|
||||
let bads: Vec<Vec<u8>> = vec![
|
||||
b"nospaceshere".to_vec(),
|
||||
format!("{}refs/x", "a".repeat(40)).into_bytes(),
|
||||
[b'z'; 40]
|
||||
.iter()
|
||||
.chain(b" refs/x".iter())
|
||||
.copied()
|
||||
.collect(),
|
||||
format!("{} ", "a".repeat(40)).into_bytes(),
|
||||
format!("{} refs/x", "a".repeat(41)).into_bytes(),
|
||||
];
|
||||
for bad in &bads {
|
||||
assert!(
|
||||
show_ref_refname(bad).is_err(),
|
||||
"malformed show-ref line must error, not skip"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pruning a path that is not a repo errors. Two shapes: a missing path
|
||||
/// fails `rev-parse` outright (exit 128), while a plain subdirectory of
|
||||
/// a repo would otherwise enumerate the ancestor's refs — covered by the
|
||||
/// identity test below. Either way the prune refuses instead of pruning
|
||||
/// the wrong repo or silently skipping an evil ref.
|
||||
#[test]
|
||||
fn prune_on_non_repo_path_errors() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
rt.block_on(async {
|
||||
let err =
|
||||
prune_non_exempt_gitlawb_refs(Path::new("/tmp/gitlawb-definitely-not-a-repo-xyz"))
|
||||
.await
|
||||
.expect_err("non-repo path must fail the prune, not skip it");
|
||||
assert!(
|
||||
err.to_string().contains("not a git repo")
|
||||
|| err.to_string().contains("does not resolve"),
|
||||
"unexpected error shape: {err:#}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Identity check: pruning a plain subdirectory of a repo refuses instead
|
||||
/// of enumerating the ancestor. `git -C` on the child exits 0 from
|
||||
/// `show-ref` with the ANCESTOR's refs, so without the
|
||||
/// `rev-parse --absolute-git-dir` identity check the delete loop would
|
||||
/// enumerate and delete the wrong repository's refs. Deleting the check
|
||||
/// makes the prune succeed and removes the ancestor's evil ref — both
|
||||
/// assertions go red.
|
||||
#[test]
|
||||
fn prune_on_non_repo_child_of_repo_refuses_and_touches_nothing() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
rt.block_on(async {
|
||||
let td = TempDir::new().unwrap();
|
||||
let ancestor = td.path().join("ancestor.git");
|
||||
std::fs::create_dir_all(&ancestor).unwrap();
|
||||
let run_ancestor = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&ancestor)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
run_ancestor(&["init", "-q", "--bare", "."]);
|
||||
// Blob target for the planted ref (mirrors the node-metadata shape).
|
||||
std::fs::write(ancestor.join("blob-body"), b"marker\n").unwrap();
|
||||
let out = Command::new("git")
|
||||
.args(["hash-object", "-w", "blob-body"])
|
||||
.current_dir(&ancestor)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git hash-object failed");
|
||||
let blob = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
run_ancestor(&["update-ref", "refs/gitlawb/evil", &blob]);
|
||||
|
||||
// A plain child directory: not itself a repo.
|
||||
let child = ancestor.join("not-a-repo");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
|
||||
let err = prune_non_exempt_gitlawb_refs(&child)
|
||||
.await
|
||||
.expect_err("pruning a non-repo child must refuse, not enumerate the ancestor");
|
||||
assert!(
|
||||
err.to_string().contains("not itself a repo"),
|
||||
"refusal must name the identity mismatch, got: {err:#}"
|
||||
);
|
||||
|
||||
// The ancestor's evil ref is untouched.
|
||||
let refs_out = Command::new("git")
|
||||
.args(["for-each-ref", "--format=%(refname)"])
|
||||
.current_dir(&ancestor)
|
||||
.output()
|
||||
.unwrap();
|
||||
let refs = String::from_utf8_lossy(&refs_out.stdout);
|
||||
assert!(
|
||||
refs.lines().any(|l| l.trim() == "refs/gitlawb/evil"),
|
||||
"ancestor evil ref must survive the refused prune"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// sha256 mirror: a 64-hex `show-ref` line parses and the planted ref is
|
||||
/// pruned. A 40-only parser skips every line and leaves the evil ref.
|
||||
#[test]
|
||||
fn prune_parses_sha256_show_ref_and_prunes() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
rt.block_on(async {
|
||||
let td = TempDir::new().unwrap();
|
||||
let bare = td.path().join("m256.git");
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
let run = |args: &[&str]| {
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&bare)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success(),
|
||||
"git {args:?} failed"
|
||||
);
|
||||
};
|
||||
let run_out = |args: &[&str]| {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?} failed");
|
||||
out.stdout
|
||||
};
|
||||
run(&["init", "-q", "--bare", "--object-format=sha256", "."]);
|
||||
std::fs::write(bare.join("blob-body"), b"marker\n").unwrap();
|
||||
let blob = String::from_utf8_lossy(&run_out(&["hash-object", "-w", "blob-body"]))
|
||||
.trim()
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
blob.len(),
|
||||
64,
|
||||
"sanity: sha256 repo must mint 64-hex objects"
|
||||
);
|
||||
run(&["update-ref", "refs/gitlawb/evil", &blob]);
|
||||
run(&["update-ref", "refs/gitlawb/issues/i1", &blob]);
|
||||
|
||||
prune_non_exempt_gitlawb_refs(&bare)
|
||||
.await
|
||||
.expect("prune succeeds on a sha256 repo");
|
||||
|
||||
let raw_refs = run_out(&["for-each-ref", "--format=%(refname)"]);
|
||||
let refs = String::from_utf8_lossy(&raw_refs);
|
||||
let set: std::collections::HashSet<&str> = refs
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
assert!(
|
||||
!set.contains("refs/gitlawb/evil"),
|
||||
"evil ref must be pruned from sha256 mirror: {set:?}"
|
||||
);
|
||||
assert!(
|
||||
set.contains("refs/gitlawb/issues/i1"),
|
||||
"exempt ref must survive: {set:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Prune failures defer the queue row (stays `pending` for the next
|
||||
/// tick); deterministic prune refusals and any other sync error fail it
|
||||
/// terminally. Deleting the `downcast_ref::<PruneFailed>` arm restores
|
||||
/// terminal failure and turns the first assertion red.
|
||||
/// Prune failures defer the queue row (stays `pending` for the next
|
||||
/// tick); deterministic prune refusals and any other sync error fail it
|
||||
/// terminally. Deleting the `downcast_ref::<PruneFailed>` arm restores
|
||||
/// terminal failure and turns the first assertion red.
|
||||
///
|
||||
/// The wrapper below is what the clone/fetch call sites run through:
|
||||
/// a `PruneInvalid` from the helper must still downcast as
|
||||
/// `PruneInvalid` afterwards (not stringified into `PruneFailed`),
|
||||
/// and anything else must arrive as `PruneFailed`.
|
||||
#[test]
|
||||
fn wrap_prune_error_preserves_deterministic_type() {
|
||||
let invalid = wrap_prune_error(anyhow::anyhow!(PruneInvalid("x".to_string())));
|
||||
assert!(
|
||||
invalid.downcast_ref::<PruneInvalid>().is_some(),
|
||||
"deterministic refusal must survive the call-site wrap"
|
||||
);
|
||||
assert!(
|
||||
invalid.downcast_ref::<PruneFailed>().is_none(),
|
||||
"deterministic refusal must not also read as transient"
|
||||
);
|
||||
let transient = wrap_prune_error(anyhow::anyhow!("boom"));
|
||||
assert!(
|
||||
transient.downcast_ref::<PruneFailed>().is_some(),
|
||||
"generic failure must arrive as transient"
|
||||
);
|
||||
assert!(
|
||||
transient.downcast_ref::<PruneInvalid>().is_none(),
|
||||
"generic failure must not read as deterministic"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn prune_failure_defers_row_other_failures_fail_it(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let item = |id: &str| crate::db::SyncQueueItem {
|
||||
id: id.to_string(),
|
||||
repo: "z6Mkfoo/hello".to_string(),
|
||||
node_did: "did:key:z6MkOrigin".to_string(),
|
||||
ref_name: "refs/heads/main".to_string(),
|
||||
new_sha: "0".repeat(40),
|
||||
cid: None,
|
||||
status: "pending".to_string(),
|
||||
enqueued_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
async fn status_of(pool: &sqlx::PgPool, id: &str) -> String {
|
||||
let row: (String,) = sqlx::query_as("SELECT status FROM sync_queue WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
row.0
|
||||
}
|
||||
// Seed three pending rows directly (enqueue_sync mints random ids).
|
||||
for id in ["sync-prune-defer", "sync-prune-invalid", "sync-other-fail"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO sync_queue (id, repo, node_did, ref_name, new_sha, cid, status, enqueued_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, 'pending', $6)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind("z6Mkfoo/hello")
|
||||
.bind("did:key:z6MkOrigin")
|
||||
.bind("refs/heads/main")
|
||||
.bind("0".repeat(40))
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
handle_sync_item_error(
|
||||
&state.db,
|
||||
&item("sync-prune-defer"),
|
||||
"http://origin.example",
|
||||
anyhow::anyhow!(PruneFailed("boom".to_string())),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status_of(state.db.pool(), "sync-prune-defer").await,
|
||||
"pending",
|
||||
"prune failure must leave the row pending for retry"
|
||||
);
|
||||
|
||||
handle_sync_item_error(
|
||||
&state.db,
|
||||
&item("sync-other-fail"),
|
||||
"http://origin.example",
|
||||
anyhow::anyhow!("boom"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status_of(state.db.pool(), "sync-other-fail").await,
|
||||
"failed",
|
||||
"non-prune failure must fail the row"
|
||||
);
|
||||
|
||||
// Deterministic refusal: no retry can clear it, so it fails
|
||||
// terminally instead of spinning every tick forever.
|
||||
handle_sync_item_error(
|
||||
&state.db,
|
||||
&item("sync-prune-invalid"),
|
||||
"http://origin.example",
|
||||
anyhow::anyhow!(PruneInvalid("not itself a repo".to_string())),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status_of(state.db.pool(), "sync-prune-invalid").await,
|
||||
"failed",
|
||||
"deterministic prune refusal must fail the row, not defer forever"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! Native Git fixtures shared by timing tests on Unix and Windows.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The fixture delays or hangs in its own process, so the production watchdog
|
||||
/// can terminate it without depending on a shell or descendant process support.
|
||||
pub(super) enum Behavior<'a> {
|
||||
Delay(u64),
|
||||
Hang,
|
||||
HangOid(&'a str),
|
||||
HangRepo(&'a str),
|
||||
LogTypes(&'a Path),
|
||||
}
|
||||
|
||||
pub(super) struct GitShim {
|
||||
_directory: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
impl Deref for GitShim {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Path {
|
||||
&self.executable
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create(name: &str, behavior: Behavior<'_>) -> GitShim {
|
||||
static COMPILED: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
let compiled = COMPILED.get_or_init(|| {
|
||||
let directory = tempfile::tempdir().expect("native Git fixture directory");
|
||||
let source = directory.path().join("git_shim.rs");
|
||||
std::fs::write(&source, include_str!("../tests/fixtures/git_shim.rs"))
|
||||
.expect("write native Git fixture source");
|
||||
let executable = directory
|
||||
.path()
|
||||
.join(format!("git-shim{}", std::env::consts::EXE_SUFFIX));
|
||||
let output =
|
||||
std::process::Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
|
||||
.arg("--edition=2021")
|
||||
.arg("-Dwarnings")
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(executable)
|
||||
.output()
|
||||
.expect("compile native Git fixture with the installed Rust toolchain");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
directory
|
||||
});
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(name)
|
||||
.tempdir()
|
||||
.expect("Git fixture instance");
|
||||
let file_name = format!("git-shim{}", std::env::consts::EXE_SUFFIX);
|
||||
let executable = directory.path().join(&file_name);
|
||||
std::fs::copy(compiled.path().join(file_name), &executable).expect("copy native Git fixture");
|
||||
let config = match behavior {
|
||||
Behavior::Delay(milliseconds) => format!("delay\n{milliseconds}"),
|
||||
Behavior::Hang => "hang\n".to_owned(),
|
||||
Behavior::HangOid(oid) => format!("hang-oid\n{oid}"),
|
||||
Behavior::HangRepo(repo) => format!("hang-repo\n{repo}"),
|
||||
Behavior::LogTypes(path) => format!("log-types\n{}", path.display()),
|
||||
};
|
||||
std::fs::write(directory.path().join("config"), config).expect("configure native Git fixture");
|
||||
GitShim {
|
||||
_directory: directory,
|
||||
executable,
|
||||
}
|
||||
}
|
||||
+213
-2811
File diff suppressed because it is too large.
Load diff
@@ -1,968 +0,0 @@
|
||||
//! Scheduled external-forge fetches for canonical INBOUND mirrors.
|
||||
//!
|
||||
//! This worker is deliberately separate from `sync`, which mirrors repositories
|
||||
//! between Gitlawb peers. External upstream URLs are owner-controlled data that
|
||||
//! eventually reach `git fetch`, so each cycle resolves DNS itself, validates
|
||||
//! every address, and pins the approved answers into Git/libcurl with
|
||||
//! `http.curloptResolve`. Redirects, proxies, credential helpers, submodule
|
||||
//! recursion, and non-HTTPS protocols are disabled at the command boundary.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::{Db, InboundMirrorTarget, MirrorStatus, MirrorTransitionPhase};
|
||||
use crate::git::repo_store::RepoStore;
|
||||
use crate::state::{repo_identity_key, RepoWriteLeases};
|
||||
|
||||
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_PINNED_ADDRESSES: usize = 32;
|
||||
const MIN_CURL_OPT_RESOLVE_GIT: (u64, u64) = (2, 37);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct EgressPolicy;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PinnedUpstream {
|
||||
url: String,
|
||||
curlopt_resolve: Option<String>,
|
||||
}
|
||||
|
||||
impl EgressPolicy {
|
||||
async fn resolve(&self, url: reqwest::Url) -> Result<PinnedUpstream> {
|
||||
let raw_host = url.host_str().context("mirror upstream URL has no host")?;
|
||||
let connect_host = raw_host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.to_ascii_lowercase();
|
||||
let policy_host = normalize_policy_host(&connect_host);
|
||||
reject_always_local_hostname(&policy_host)?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.context("mirror upstream URL has no known port")?;
|
||||
|
||||
if let Ok(ip) = connect_host.parse::<IpAddr>() {
|
||||
self.validate_addresses(&policy_host, &[ip])?;
|
||||
return Ok(PinnedUpstream {
|
||||
url: url.to_string(),
|
||||
// An IP literal performs no DNS lookup, so there is no rebinding
|
||||
// window to pin. TLS still verifies the literal from the URL.
|
||||
curlopt_resolve: None,
|
||||
});
|
||||
}
|
||||
|
||||
let resolved = tokio::time::timeout(
|
||||
DNS_TIMEOUT,
|
||||
tokio::net::lookup_host((connect_host.as_str(), port)),
|
||||
)
|
||||
.await
|
||||
.context("mirror upstream DNS lookup timed out")?
|
||||
.context("resolving mirror upstream host")?;
|
||||
let addresses: BTreeSet<IpAddr> = resolved.map(|addr| addr.ip()).collect();
|
||||
if addresses.is_empty() {
|
||||
anyhow::bail!("mirror upstream host resolved to no addresses");
|
||||
}
|
||||
let addresses: Vec<IpAddr> = addresses.into_iter().collect();
|
||||
self.validate_addresses(&policy_host, &addresses)?;
|
||||
|
||||
let pinned = addresses
|
||||
.iter()
|
||||
.map(|ip| match ip {
|
||||
IpAddr::V4(v4) => v4.to_string(),
|
||||
IpAddr::V6(v6) => format!("[{v6}]"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Ok(PinnedUpstream {
|
||||
url: url.to_string(),
|
||||
curlopt_resolve: Some(format!("+{connect_host}:{port}:{pinned}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_addresses(&self, host: &str, addresses: &[IpAddr]) -> Result<()> {
|
||||
if addresses.is_empty() || addresses.len() > MAX_PINNED_ADDRESSES {
|
||||
anyhow::bail!(
|
||||
"mirror upstream host must resolve to 1..={MAX_PINNED_ADDRESSES} addresses"
|
||||
);
|
||||
}
|
||||
for &ip in addresses {
|
||||
if !address_is_permitted(ip) {
|
||||
anyhow::bail!("mirror upstream host {host:?} resolved to disallowed address {ip}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_policy_host(host: &str) -> String {
|
||||
host.trim_end_matches('.').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn reject_always_local_hostname(host: &str) -> Result<()> {
|
||||
if host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
{
|
||||
anyhow::bail!("mirror upstream host is local-only and cannot be fetched");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn address_is_permitted(ip: IpAddr) -> bool {
|
||||
crate::api::peers::mirror_upstream_ip_permitted(ip)
|
||||
}
|
||||
|
||||
fn parse_git_version(output: &str) -> Option<(u64, u64)> {
|
||||
let version = output.split_whitespace().find(|part| {
|
||||
part.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|byte| byte.is_ascii_digit())
|
||||
})?;
|
||||
let mut parts = version.split('.');
|
||||
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
|
||||
}
|
||||
|
||||
fn ensure_safe_git_runtime(git_bin: &str) -> Result<()> {
|
||||
// Bounded runner like every other git spawn on this path: --version does
|
||||
// no I/O of its own, but the child still takes a permit and the deadline.
|
||||
let (status, stdout, stderr) = crate::git::visibility_pack::run_bounded_git_raw_isolated_https(
|
||||
git_bin,
|
||||
&["--version"],
|
||||
&std::env::temp_dir(),
|
||||
b"",
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
)?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"{git_bin} --version failed: {}",
|
||||
String::from_utf8_lossy(&stderr)
|
||||
.chars()
|
||||
.take(256)
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&stdout);
|
||||
let version = parse_git_version(&stdout).context("parsing git version")?;
|
||||
if version < MIN_CURL_OPT_RESOLVE_GIT {
|
||||
anyhow::bail!(
|
||||
"upstream mirroring requires Git 2.37+ for DNS pinning; found {}.{}",
|
||||
version.0,
|
||||
version.1
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct FetchCommand {
|
||||
config: Vec<(String, String)>,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
fn fetch_command(upstream: &PinnedUpstream) -> FetchCommand {
|
||||
let http_scope = |name: &str| format!("http.{}.{name}", upstream.url);
|
||||
let credential_scope = |name: &str| format!("credential.{}.{name}", upstream.url);
|
||||
let mut config = vec![
|
||||
("http.proxy".to_string(), "".to_string()),
|
||||
("http.followRedirects".to_string(), "false".to_string()),
|
||||
// Reset any inherited multi-valued resolver entries before adding the
|
||||
// address set validated for this exact fetch.
|
||||
("http.curloptResolve".to_string(), "".to_string()),
|
||||
// Never forward operator or repository-scoped HTTP credentials to an
|
||||
// owner-selected forge. Empty values reset Git's multi-valued headers
|
||||
// and disable cookie state for this invocation.
|
||||
("http.extraHeader".to_string(), "".to_string()),
|
||||
("http.cookieFile".to_string(), "".to_string()),
|
||||
("http.saveCookies".to_string(), "false".to_string()),
|
||||
("http.sslVerify".to_string(), "true".to_string()),
|
||||
// URL-specific configuration outranks generic `http.*` values even
|
||||
// when the generic value is command-scoped. Repeat every security
|
||||
// control at the exact validated URL so a repository-local scoped
|
||||
// setting cannot re-enable redirects/proxies/headers/cookies or disable
|
||||
// TLS. Key/value environment entries avoid `git -c` splitting a valid
|
||||
// URL path that contains `=`.
|
||||
(http_scope("proxy"), "".to_string()),
|
||||
(http_scope("followRedirects"), "false".to_string()),
|
||||
(http_scope("curloptResolve"), "".to_string()),
|
||||
(http_scope("extraHeader"), "".to_string()),
|
||||
(http_scope("cookieFile"), "".to_string()),
|
||||
(http_scope("saveCookies"), "false".to_string()),
|
||||
(http_scope("sslVerify"), "true".to_string()),
|
||||
];
|
||||
if let Some(resolve) = &upstream.curlopt_resolve {
|
||||
config.extend([
|
||||
("http.curloptResolve".to_string(), resolve.clone()),
|
||||
(http_scope("curloptResolve"), resolve.clone()),
|
||||
]);
|
||||
}
|
||||
config.extend([
|
||||
("credential.helper".to_string(), "".to_string()),
|
||||
(credential_scope("helper"), "".to_string()),
|
||||
("credential.interactive".to_string(), "false".to_string()),
|
||||
("core.askPass".to_string(), "false".to_string()),
|
||||
("fetch.recurseSubmodules".to_string(), "false".to_string()),
|
||||
// Do not let a smart server or repository-local config introduce a
|
||||
// second, unvalidated pack/bundle URL outside the pinned upstream.
|
||||
("fetch.uriprotocols".to_string(), "".to_string()),
|
||||
("fetch.bundleURI".to_string(), "".to_string()),
|
||||
("protocol.allow".to_string(), "never".to_string()),
|
||||
("protocol.https.allow".to_string(), "always".to_string()),
|
||||
]);
|
||||
let args = vec![
|
||||
"fetch".to_string(),
|
||||
"--atomic".to_string(),
|
||||
"--force".to_string(),
|
||||
"--prune".to_string(),
|
||||
"--prune-tags".to_string(),
|
||||
"--no-auto-maintenance".to_string(),
|
||||
"--no-recurse-submodules".to_string(),
|
||||
"--no-write-fetch-head".to_string(),
|
||||
upstream.url.clone(),
|
||||
"+refs/heads/*:refs/heads/*".to_string(),
|
||||
"+refs/tags/*:refs/tags/*".to_string(),
|
||||
];
|
||||
FetchCommand { config, args }
|
||||
}
|
||||
|
||||
fn reject_local_url_rewrite(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
upstream_url: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<()> {
|
||||
let args = ["ls-remote", "--get-url", upstream_url];
|
||||
let (status, stdout, stderr) = crate::git::visibility_pack::run_bounded_git_raw_isolated_https(
|
||||
git_bin, &args, repo_path, b"", deadline,
|
||||
)?;
|
||||
if !status.success() {
|
||||
let stderr = String::from_utf8_lossy(&stderr);
|
||||
anyhow::bail!(
|
||||
"checking mirror upstream URL rewrite failed: {}",
|
||||
stderr.chars().take(4_096).collect::<String>()
|
||||
);
|
||||
}
|
||||
let expanded = std::str::from_utf8(&stdout)
|
||||
.context("Git returned a non-UTF-8 expanded mirror upstream URL")?
|
||||
.trim_end_matches(['\r', '\n']);
|
||||
if expanded != upstream_url {
|
||||
anyhow::bail!("repository-local Git config attempted to rewrite the mirror upstream URL");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_sensitive_local_config(git_bin: &str, repo_path: &Path, deadline: Instant) -> Result<()> {
|
||||
let args = [
|
||||
"config",
|
||||
"--local",
|
||||
"--includes",
|
||||
"--get-regexp",
|
||||
r"^(http|credential|url)\.",
|
||||
];
|
||||
let (status, stdout, stderr) = crate::git::visibility_pack::run_bounded_git_raw_isolated_https(
|
||||
git_bin, &args, repo_path, b"", deadline,
|
||||
)?;
|
||||
match status.code() {
|
||||
Some(1) if stdout.is_empty() => Ok(()),
|
||||
Some(0) => anyhow::bail!(
|
||||
"repository-local network or credential Git config is not allowed for upstream mirrors"
|
||||
),
|
||||
_ => {
|
||||
let stderr = String::from_utf8_lossy(&stderr);
|
||||
anyhow::bail!(
|
||||
"checking repository-local Git config failed: {}",
|
||||
stderr.chars().take(4_096).collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget for each preflight probe inside [`run_fetch`] (the URL-rewrite
|
||||
/// check and the sensitive-local-config scan). Both run local git children
|
||||
/// against the on-disk repo, so a short bound is generous; keeping them on
|
||||
/// their own deadlines means `upstream_mirror_fetch_timeout_secs` covers
|
||||
/// only the external pack transfer it documents.
|
||||
const PREFLIGHT_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn run_fetch(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
upstream: &PinnedUpstream,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let command = fetch_command(upstream);
|
||||
let args: Vec<&str> = command.args.iter().map(String::as_str).collect();
|
||||
let config: Vec<(&str, &str)> = command
|
||||
.config
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.collect();
|
||||
let rewrite_deadline = Instant::now()
|
||||
.checked_add(PREFLIGHT_PROBE_TIMEOUT)
|
||||
.context("mirror URL-rewrite probe timeout is not representable")?;
|
||||
reject_local_url_rewrite(git_bin, repo_path, &upstream.url, rewrite_deadline)?;
|
||||
let config_deadline = Instant::now()
|
||||
.checked_add(PREFLIGHT_PROBE_TIMEOUT)
|
||||
.context("mirror local-config probe timeout is not representable")?;
|
||||
reject_sensitive_local_config(git_bin, repo_path, config_deadline)?;
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.context("upstream mirror fetch timeout is not representable")?;
|
||||
let (status, _stdout, stderr) =
|
||||
crate::git::visibility_pack::run_bounded_git_raw_isolated_https_with_config(
|
||||
git_bin, &args, repo_path, b"", deadline, &config,
|
||||
)?;
|
||||
if !status.success() {
|
||||
let stderr = String::from_utf8_lossy(&stderr);
|
||||
let stderr = stderr.chars().take(4_096).collect::<String>();
|
||||
anyhow::bail!("git upstream fetch failed: {stderr}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Worker {
|
||||
db: Arc<Db>,
|
||||
config: Arc<Config>,
|
||||
repo_store: RepoStore,
|
||||
repo_write_leases: RepoWriteLeases,
|
||||
git_write_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
policy: EgressPolicy,
|
||||
git_bin: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FetchOutcome {
|
||||
Updated,
|
||||
SkippedStale,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
async fn fetch_target(&self, target: InboundMirrorTarget) -> Result<FetchOutcome> {
|
||||
let url = crate::db::validate_mirror_upstream_url(&target.upstream_url)?;
|
||||
let upstream = self.policy.resolve(url).await?;
|
||||
|
||||
let repo_identity = repo_identity_key(&target.owner_did, &target.name);
|
||||
let steal_after = Duration::from_secs(
|
||||
self.config
|
||||
.upstream_mirror_fetch_timeout_secs
|
||||
.saturating_mul(2)
|
||||
.saturating_add(60),
|
||||
);
|
||||
let _lease = self
|
||||
.repo_write_leases
|
||||
.acquire(&repo_identity, steal_after)
|
||||
.await
|
||||
.context("upstream mirror write-lease waiter cap reached")?;
|
||||
|
||||
let _write_permit = Arc::clone(&self.git_write_semaphore)
|
||||
.acquire_owned()
|
||||
.await
|
||||
.context("upstream mirror write semaphore closed")?;
|
||||
let acquire_timeout = Duration::from_secs(self.config.git_acquire_timeout_secs);
|
||||
let guard = tokio::time::timeout(
|
||||
acquire_timeout,
|
||||
self.repo_store
|
||||
.acquire_write(&target.owner_did, &target.name),
|
||||
)
|
||||
.await
|
||||
.context("upstream mirror write-lock acquisition timed out")??;
|
||||
let repo_path = guard.path().to_path_buf();
|
||||
|
||||
// A row can leave INBOUND state after selection. Recheck only after
|
||||
// both the process-local lease and the cluster-wide advisory write lock
|
||||
// are held. Every future transition path must take these same locks
|
||||
// before changing authority, so no other node can commit a transition
|
||||
// between this check and the fetch.
|
||||
let current = match self.db.get_repo_mirror_state(&target.repo_id).await {
|
||||
Ok(Some(current)) => current,
|
||||
Ok(None) => {
|
||||
let _ = guard.release(false).await;
|
||||
anyhow::bail!("inbound mirror state disappeared before fetch");
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = guard.release(false).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// Both stable shapes the CHECK admits: never transitioned, or a
|
||||
// completed transition whose metadata is retained as audit. Only an
|
||||
// in-flight transition makes the target stale.
|
||||
let stable = (current.transition_id.is_none() && current.transition_phase.is_none())
|
||||
|| (current.transition_id.is_some()
|
||||
&& current.transition_phase == Some(MirrorTransitionPhase::Completed));
|
||||
if current.status != MirrorStatus::Inbound
|
||||
|| !stable
|
||||
|| current.upstream_url != target.upstream_url
|
||||
{
|
||||
let _ = guard.release(false).await;
|
||||
info!(repo_id = %target.repo_id, "upstream mirror state changed before fetch; skipping stale target");
|
||||
return Ok(FetchOutcome::SkippedStale);
|
||||
}
|
||||
|
||||
let git_bin = self.git_bin.clone();
|
||||
let fetch_timeout = Duration::from_secs(self.config.upstream_mirror_fetch_timeout_secs);
|
||||
let fetch = tokio::task::spawn_blocking(move || {
|
||||
run_fetch(&git_bin, &repo_path, &upstream, fetch_timeout)
|
||||
})
|
||||
.await
|
||||
.context("upstream mirror fetch task panicked")?;
|
||||
let success = fetch.is_ok();
|
||||
let outcome = guard.release(success).await;
|
||||
// A refused publish (fenced or unknowable upload) must not report
|
||||
// Updated; surface it as the failure it is before mapping the fetch.
|
||||
outcome.into_result()?;
|
||||
fetch.map(|()| FetchOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn scan_once(&self) {
|
||||
let mut cursor: Option<String> = None;
|
||||
loop {
|
||||
let targets = match self
|
||||
.db
|
||||
.list_inbound_mirror_targets(
|
||||
cursor.as_deref(),
|
||||
self.config.upstream_mirror_page_size as i64,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(targets) => targets,
|
||||
Err(error) => {
|
||||
warn!(err = %error, "failed to list inbound mirror targets");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let page_len = targets.len();
|
||||
cursor = targets.last().map(|target| target.repo_id.clone());
|
||||
for target in targets {
|
||||
let repo_id = target.repo_id.clone();
|
||||
let upstream_url = target.upstream_url.clone();
|
||||
match self.fetch_target(target).await {
|
||||
Ok(FetchOutcome::Updated) => {
|
||||
info!(repo_id, upstream = %upstream_url, "upstream mirror fetch completed")
|
||||
}
|
||||
Ok(FetchOutcome::SkippedStale) => {}
|
||||
Err(error) => {
|
||||
warn!(repo_id, upstream = %upstream_url, err = %error, "upstream mirror fetch failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
if page_len < self.config.upstream_mirror_page_size {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the opt-in runtime and spawn the scheduled worker. The worker runs
|
||||
/// one scan immediately, sleeps only after a complete scan, and exits between
|
||||
/// scans when graceful shutdown is signalled. An in-flight Git child is bounded
|
||||
/// and reaped before the worker observes shutdown.
|
||||
pub fn start(
|
||||
db: Arc<Db>,
|
||||
config: Arc<Config>,
|
||||
repo_store: RepoStore,
|
||||
repo_write_leases: RepoWriteLeases,
|
||||
git_write_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
ensure_safe_git_runtime("git")?;
|
||||
let policy = EgressPolicy;
|
||||
let worker = Worker {
|
||||
db,
|
||||
config: Arc::clone(&config),
|
||||
repo_store,
|
||||
repo_write_leases,
|
||||
git_write_semaphore,
|
||||
policy,
|
||||
git_bin: "git".to_string(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
info!(
|
||||
interval_secs = config.upstream_mirror_interval_secs,
|
||||
"upstream mirror worker started"
|
||||
);
|
||||
loop {
|
||||
worker.scan_once().await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(config.upstream_mirror_interval_secs)) => {}
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
info!("upstream mirror worker stopped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
async fn test_worker(
|
||||
pool: sqlx::PgPool,
|
||||
git_bin: PathBuf,
|
||||
page_size: usize,
|
||||
) -> (Worker, Arc<Db>, tempfile::TempDir) {
|
||||
let db = Arc::new(Db::for_testing(pool.clone()));
|
||||
db.run_migrations().await.unwrap();
|
||||
let repos = tempfile::tempdir().unwrap();
|
||||
let mut config = Config::parse_from([
|
||||
"gitlawb-node".to_string(),
|
||||
"--upstream-mirror-enabled".to_string(),
|
||||
"--enforce-owner-push".to_string(),
|
||||
"--upstream-mirror-fetch-timeout-secs".to_string(),
|
||||
"5".to_string(),
|
||||
"--upstream-mirror-page-size".to_string(),
|
||||
page_size.to_string(),
|
||||
]);
|
||||
config.validate().unwrap();
|
||||
let config = Arc::new(config);
|
||||
let worker = Worker {
|
||||
db: Arc::clone(&db),
|
||||
config,
|
||||
repo_store: RepoStore::for_testing(repos.path().to_path_buf(), pool),
|
||||
repo_write_leases: RepoWriteLeases::new(8),
|
||||
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
|
||||
policy: EgressPolicy,
|
||||
git_bin: git_bin.to_string_lossy().into_owned(),
|
||||
};
|
||||
(worker, db, repos)
|
||||
}
|
||||
|
||||
async fn add_inbound_mirror(
|
||||
worker: &Worker,
|
||||
db: &Db,
|
||||
id: &str,
|
||||
name: &str,
|
||||
upstream_url: &str,
|
||||
) -> InboundMirrorTarget {
|
||||
let now = chrono::Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: "did:key:z6MkMirrorWorker".to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/test/{id}.git"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
db.create_repo(&record).await.unwrap();
|
||||
worker
|
||||
.repo_store
|
||||
.init(&record.owner_did, &record.name)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = db
|
||||
.configure_inbound_mirror(&record.id, upstream_url)
|
||||
.await
|
||||
.unwrap();
|
||||
InboundMirrorTarget {
|
||||
repo_id: record.id,
|
||||
owner_did: record.owner_did,
|
||||
name: record.name,
|
||||
upstream_url: state.upstream_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fake_git(repos: &tempfile::TempDir) -> (PathBuf, PathBuf) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let git = repos.path().join("fake-git");
|
||||
let calls = repos.path().join("fetch-calls");
|
||||
let script = format!(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "ls-remote" ]; then
|
||||
printf '%s\n' "$3"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "config" ]; then
|
||||
# Match real Git when no sensitive repository-local keys are configured.
|
||||
exit 1
|
||||
fi
|
||||
case "$*" in
|
||||
*fail.git*) printf '%s\n' fail >> '{}'; exit 7 ;;
|
||||
*) printf '%s\n' ok >> '{}'; exit 0 ;;
|
||||
esac
|
||||
"#,
|
||||
calls.display(),
|
||||
calls.display()
|
||||
);
|
||||
std::fs::write(&git, script).unwrap();
|
||||
let mut permissions = std::fs::metadata(&git).unwrap().permissions();
|
||||
permissions.set_mode(0o700);
|
||||
std::fs::set_permissions(&git, permissions).unwrap();
|
||||
(git, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_version_gate_requires_curlopt_resolve_support() {
|
||||
assert_eq!(parse_git_version("git version 2.37.0"), Some((2, 37)));
|
||||
assert_eq!(
|
||||
parse_git_version("git version 2.39.5 (Apple Git-154)"),
|
||||
Some((2, 39))
|
||||
);
|
||||
assert_eq!(parse_git_version("not git"), None);
|
||||
assert!((2, 36) < MIN_CURL_OPT_RESOLVE_GIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_resolved_address_must_be_public() {
|
||||
let policy = EgressPolicy;
|
||||
let public_v4: IpAddr = "8.8.8.8".parse().unwrap();
|
||||
let private_v4: IpAddr = "10.2.3.4".parse().unwrap();
|
||||
let private_v6: IpAddr = "fd00::20".parse().unwrap();
|
||||
assert!(policy
|
||||
.validate_addresses("public.example", &[public_v4])
|
||||
.is_ok());
|
||||
assert!(policy
|
||||
.validate_addresses("private.example", &[private_v4, private_v6])
|
||||
.is_err());
|
||||
assert!(policy
|
||||
.validate_addresses("mixed.example", &[public_v4, private_v4])
|
||||
.is_err());
|
||||
let too_many = (1..=MAX_PINNED_ADDRESSES + 1)
|
||||
.map(|last| IpAddr::V4(std::net::Ipv4Addr::new(8, 8, 0, last as u8)))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(policy
|
||||
.validate_addresses("oversized.example", &too_many)
|
||||
.is_err());
|
||||
|
||||
for never in [
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"192.0.2.1",
|
||||
"198.18.0.1",
|
||||
"198.51.100.1",
|
||||
"203.0.113.10",
|
||||
"::1",
|
||||
"::ffff:198.18.0.1",
|
||||
"2002:c612:1::",
|
||||
"64:ff9b::c612:1",
|
||||
"100::1",
|
||||
"100:0:0:1::1",
|
||||
"2001::1",
|
||||
"2001:2::1",
|
||||
"2001:db8::1",
|
||||
"fec0::1",
|
||||
"fe80::1",
|
||||
"3fff::1",
|
||||
"5f00::1",
|
||||
] {
|
||||
let ip = never.parse().unwrap();
|
||||
assert!(!address_is_permitted(ip), "{never} must stay denied");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_only_hostnames_are_always_rejected() {
|
||||
for invalid in [
|
||||
"",
|
||||
"localhost",
|
||||
"forge.localhost",
|
||||
"forge.local",
|
||||
"ghe.internal",
|
||||
] {
|
||||
assert!(
|
||||
reject_always_local_hostname(invalid).is_err(),
|
||||
"{invalid:?}"
|
||||
);
|
||||
}
|
||||
assert!(reject_always_local_hostname("github.example").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_command_pins_dns_and_only_updates_branches_and_tags() {
|
||||
let upstream = PinnedUpstream {
|
||||
url: "https://github.example/org/repo.git".to_string(),
|
||||
curlopt_resolve: Some("+github.example:443:8.8.8.8,[2606:4700:4700::1111]".to_string()),
|
||||
};
|
||||
let command = fetch_command(&upstream);
|
||||
let has_config = |key: &str, value: &str| {
|
||||
command
|
||||
.config
|
||||
.contains(&(key.to_string(), value.to_string()))
|
||||
};
|
||||
assert!(has_config("http.followRedirects", "false"));
|
||||
assert!(has_config("http.proxy", ""));
|
||||
assert!(has_config("http.extraHeader", ""));
|
||||
assert!(has_config("http.cookieFile", ""));
|
||||
assert!(has_config("http.saveCookies", "false"));
|
||||
assert!(has_config("http.sslVerify", "true"));
|
||||
assert!(has_config(
|
||||
"http.curloptResolve",
|
||||
"+github.example:443:8.8.8.8,[2606:4700:4700::1111]"
|
||||
));
|
||||
assert!(has_config(
|
||||
"http.https://github.example/org/repo.git.followRedirects",
|
||||
"false"
|
||||
));
|
||||
assert!(has_config(
|
||||
"http.https://github.example/org/repo.git.proxy",
|
||||
""
|
||||
));
|
||||
assert!(has_config(
|
||||
"http.https://github.example/org/repo.git.extraHeader",
|
||||
""
|
||||
));
|
||||
assert!(has_config(
|
||||
"http.https://github.example/org/repo.git.curloptResolve",
|
||||
"+github.example:443:8.8.8.8,[2606:4700:4700::1111]"
|
||||
));
|
||||
assert!(has_config(
|
||||
"credential.https://github.example/org/repo.git.helper",
|
||||
""
|
||||
));
|
||||
assert!(has_config("protocol.allow", "never"));
|
||||
assert!(has_config("protocol.https.allow", "always"));
|
||||
assert!(has_config("fetch.uriprotocols", ""));
|
||||
assert!(has_config("fetch.bundleURI", ""));
|
||||
assert!(!command
|
||||
.config
|
||||
.iter()
|
||||
.any(|(key, _)| key.ends_with("sslCert") || key.ends_with("sslKey")));
|
||||
assert!(command.args.contains(&"--atomic".to_string()));
|
||||
assert!(command
|
||||
.args
|
||||
.contains(&"+refs/heads/*:refs/heads/*".to_string()));
|
||||
assert!(command
|
||||
.args
|
||||
.contains(&"+refs/tags/*:refs/tags/*".to_string()));
|
||||
assert!(!command.args.iter().any(|arg| arg == "+refs/*:refs/*"));
|
||||
}
|
||||
|
||||
/// Spawn `git` inside a fixture repo with inherited repository-location
|
||||
/// and config environment scrubbed, so a test run inside an outer Git
|
||||
/// checkout or custom config can never leak into the fixture.
|
||||
fn fixture_git(dir: &std::path::Path) -> std::process::Command {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.current_dir(dir);
|
||||
for var in [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_CONFIG",
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
"GIT_CONFIG_SYSTEM",
|
||||
"GIT_CONFIG_NOSYSTEM",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_EXEC_PATH",
|
||||
"GIT_SSH",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_ASKPASS",
|
||||
"SSH_ASKPASS",
|
||||
] {
|
||||
cmd.env_remove(var);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_local_instead_of_rewrite_is_rejected() {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let init = fixture_git(repo.path())
|
||||
.args(["init", "--bare"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(init.status.success());
|
||||
let config = fixture_git(repo.path())
|
||||
.args([
|
||||
"config",
|
||||
"url.https://evil.example/.insteadOf",
|
||||
"https://github.example/",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(config.status.success());
|
||||
|
||||
let error = reject_local_url_rewrite(
|
||||
"git",
|
||||
repo.path(),
|
||||
"https://github.example/org/repo.git",
|
||||
Instant::now() + Duration::from_secs(5),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("attempted to rewrite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_local_network_and_credential_config_is_rejected() {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let init = fixture_git(repo.path())
|
||||
.args(["init", "--bare"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(init.status.success());
|
||||
|
||||
reject_sensitive_local_config("git", repo.path(), Instant::now() + Duration::from_secs(5))
|
||||
.unwrap();
|
||||
|
||||
let config = fixture_git(repo.path())
|
||||
.args(["config", "http.sslCert", "/tmp/untrusted-client.pem"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(config.status.success());
|
||||
|
||||
let error = reject_sensitive_local_config(
|
||||
"git",
|
||||
repo.path(),
|
||||
Instant::now() + Duration::from_secs(5),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("network or credential"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn hardened_real_git_reaches_https_transport_for_equals_path() {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let init = fixture_git(repo.path())
|
||||
.args(["init", "--bare"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(init.status.success());
|
||||
|
||||
// This is intentionally a loopback test seam around `run_fetch`, below
|
||||
// the egress-policy layer. Reaching the listener proves the generated
|
||||
// Git config neither dies on an empty client certificate nor splits the
|
||||
// valid `=` path at the command-config boundary. The listener closes
|
||||
// immediately, so the expected final result is a TLS transport error.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let accepted = std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
match listener.accept() {
|
||||
Ok((_stream, _address)) => return true,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(error) => panic!("accepting real-Git transport probe: {error}"),
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
let upstream = PinnedUpstream {
|
||||
url: format!("https://127.0.0.1:{port}/org/repo=mirror.git"),
|
||||
curlopt_resolve: None,
|
||||
};
|
||||
let error = run_fetch("git", repo.path(), &upstream, Duration::from_secs(5)).unwrap_err();
|
||||
assert!(
|
||||
accepted.join().unwrap(),
|
||||
"real Git must reach the HTTPS transport; got {error:#}"
|
||||
);
|
||||
let error = error.to_string();
|
||||
assert!(!error.contains("credential missing host field"));
|
||||
assert!(!error.contains("invalid key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn literal_addresses_are_checked_without_dns() {
|
||||
let policy = EgressPolicy;
|
||||
let allowed = policy
|
||||
.resolve(reqwest::Url::parse("https://8.8.8.8/org/repo.git").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(allowed.curlopt_resolve, None);
|
||||
|
||||
for denied in [
|
||||
"127.0.0.1",
|
||||
"10.2.3.4",
|
||||
"169.254.169.254",
|
||||
"192.0.2.1",
|
||||
"198.18.0.1",
|
||||
"203.0.113.10",
|
||||
"[::1]",
|
||||
"[100::1]",
|
||||
"[100:0:0:1::1]",
|
||||
"[2001::1]",
|
||||
"[2001:db8::1]",
|
||||
"[fec0::1]",
|
||||
"[fd00::20]",
|
||||
"[3fff::1]",
|
||||
"[5f00::1]",
|
||||
] {
|
||||
let url = reqwest::Url::parse(&format!("https://{denied}/org/repo.git")).unwrap();
|
||||
assert!(
|
||||
policy.resolve(url).await.is_err(),
|
||||
"{denied} must stay denied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn stale_target_is_rechecked_under_the_write_locks(pool: sqlx::PgPool) {
|
||||
let missing_git = PathBuf::from("/definitely/missing/git");
|
||||
let (worker, db, _repos) = test_worker(pool, missing_git, 1).await;
|
||||
let target = add_inbound_mirror(
|
||||
&worker,
|
||||
&db,
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"stale",
|
||||
"https://8.8.8.8/org/stale.git",
|
||||
)
|
||||
.await;
|
||||
sqlx::query(
|
||||
"UPDATE repos
|
||||
SET mirror_status = 'outbound'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&target.repo_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let outcome = worker.fetch_target(target).await.unwrap();
|
||||
assert_eq!(outcome, FetchOutcome::SkippedStale);
|
||||
assert!(worker.repo_write_leases.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn scan_pages_past_one_failure_and_fetches_the_next_repo(pool: sqlx::PgPool) {
|
||||
let bootstrap = tempfile::tempdir().unwrap();
|
||||
let (git, calls) = fake_git(&bootstrap);
|
||||
let (worker, db, _repos) = test_worker(pool, git, 1).await;
|
||||
add_inbound_mirror(
|
||||
&worker,
|
||||
&db,
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"fails",
|
||||
"https://8.8.8.8/org/fail.git",
|
||||
)
|
||||
.await;
|
||||
add_inbound_mirror(
|
||||
&worker,
|
||||
&db,
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
"succeeds",
|
||||
"https://8.8.8.8/org/ok.git",
|
||||
)
|
||||
.await;
|
||||
|
||||
worker.scan_once().await;
|
||||
|
||||
let calls = std::fs::read_to_string(calls).unwrap();
|
||||
assert_eq!(calls.lines().collect::<Vec<_>>(), ["fail", "ok"]);
|
||||
assert!(worker.repo_write_leases.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -38,151 +38,26 @@ pub fn fire_event(
|
||||
event: &str,
|
||||
payload: serde_json::Value,
|
||||
) {
|
||||
fire_event_occurrence(db, http_client, repo_id, event, payload, None, None);
|
||||
let repo_id = repo_id.to_string();
|
||||
let event = event.to_string();
|
||||
tokio::spawn(async move {
|
||||
fire_event_async(db, http_client, &repo_id, &event, payload).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Occurrence-keyed variant: at most one delivery per
|
||||
/// `(request_id, ref_name, hook)` is spawned; concurrent executors
|
||||
/// collapse via the `webhook_deliveries` ledger. Best-effort on crash
|
||||
/// (ledger is claimed before spawn), which the PR scope allows.
|
||||
///
|
||||
/// For durable outbox usage, call `claim_webhook_delivery_before_spawn` first
|
||||
/// to claim deliveries synchronously, then call
|
||||
/// `fire_event_occurrence_with_claimed_hooks` with the claimed hook IDs to
|
||||
/// spawn HTTP POST only for claimed hooks.
|
||||
pub fn fire_event_occurrence(
|
||||
async fn fire_event_async(
|
||||
db: Arc<Db>,
|
||||
http_client: Arc<reqwest::Client>,
|
||||
repo_id: &str,
|
||||
event: &str,
|
||||
payload: serde_json::Value,
|
||||
request_id: Option<&str>,
|
||||
ref_name: Option<&str>,
|
||||
) {
|
||||
let repo_id = repo_id.to_string();
|
||||
let event = event.to_string();
|
||||
let request_id = request_id.map(|s| s.to_string());
|
||||
let ref_name = ref_name.map(|s| s.to_string());
|
||||
tokio::spawn(async move {
|
||||
fire_event_async_occurrence(
|
||||
db,
|
||||
http_client,
|
||||
&repo_id,
|
||||
&event,
|
||||
payload,
|
||||
request_id.as_deref(),
|
||||
ref_name.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Variant for durable outbox: only spawn HTTP POST for previously claimed hooks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn fire_event_occurrence_with_claimed_hooks(
|
||||
db: Arc<Db>,
|
||||
http_client: Arc<reqwest::Client>,
|
||||
repo_id: &str,
|
||||
event: &str,
|
||||
payload: serde_json::Value,
|
||||
request_id: Option<&str>,
|
||||
ref_name: Option<&str>,
|
||||
claimed_hooks: Vec<crate::db::Webhook>,
|
||||
) {
|
||||
let repo_id = repo_id.to_string();
|
||||
let event = event.to_string();
|
||||
let request_id = request_id.map(|s| s.to_string());
|
||||
let ref_name = ref_name.map(|s| s.to_string());
|
||||
tokio::spawn(async move {
|
||||
fire_event_async_occurrence(
|
||||
db,
|
||||
http_client,
|
||||
&repo_id,
|
||||
&event,
|
||||
payload,
|
||||
request_id.as_deref(),
|
||||
ref_name.as_deref(),
|
||||
Some(claimed_hooks),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Claim webhook delivery before spawning HTTP POST.
|
||||
/// Returns `Some(hook ids)` successfully claimed, or `None` when the hook
|
||||
/// list itself could not be read (transient DB error, unknown membership).
|
||||
/// A `None` caller must fall back to best-effort send rather than dropping
|
||||
/// the delivery: children are still deleted downstream, so an empty claim
|
||||
/// set on DB error would lose the webhook permanently.
|
||||
/// This synchronous claim phase must be awaited before child deletion to prevent
|
||||
/// permanent webhook loss on crash between deletion and claim.
|
||||
pub async fn claim_webhook_delivery_before_spawn(
|
||||
db: Arc<Db>,
|
||||
repo_id: &str,
|
||||
event: &str,
|
||||
request_id: Option<&str>,
|
||||
ref_name: Option<&str>,
|
||||
) -> Option<Vec<crate::db::Webhook>> {
|
||||
let mut claimed_hooks = Vec::new();
|
||||
|
||||
// Only claim when we have occurrence context (request_id + ref_name)
|
||||
if let (Some(req), Some(r)) = (request_id, ref_name) {
|
||||
let hooks = match db.list_webhooks_for_event(repo_id, event).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, "failed to list webhooks for event {event}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
for hook in hooks {
|
||||
let delivery_id = crate::db::deterministic_id(&["webhook", req, r, &hook.id]);
|
||||
match db
|
||||
.claim_webhook_delivery(&delivery_id, req, repo_id, event)
|
||||
.await
|
||||
{
|
||||
Ok(true) => claimed_hooks.push(hook),
|
||||
Ok(false) => continue, // Already claimed
|
||||
// Send fallback (legacy best-effort): a transient claim
|
||||
// error must not drop the delivery while children are
|
||||
// still deleted. The in-task claimer this replaced sent
|
||||
// anyway on claim error; keep that here.
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, hook_id = %hook.id, "webhook claim failed; sending anyway");
|
||||
claimed_hooks.push(hook);
|
||||
}
|
||||
}
|
||||
let hooks = match db.list_webhooks_for_event(repo_id, event).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, "failed to list webhooks for event {event}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Some(claimed_hooks)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn fire_event_async_occurrence(
|
||||
db: Arc<Db>,
|
||||
http_client: Arc<reqwest::Client>,
|
||||
repo_id: &str,
|
||||
event: &str,
|
||||
payload: serde_json::Value,
|
||||
request_id: Option<&str>,
|
||||
ref_name: Option<&str>,
|
||||
claimed_hooks: Option<Vec<crate::db::Webhook>>,
|
||||
) {
|
||||
// A caller that already claimed deliveries hands the claimed hook rows
|
||||
// through; the send path must not re-list, because a list failure here
|
||||
// would silently drop deliveries already claimed in the ledger.
|
||||
let already_claimed = claimed_hooks.is_some();
|
||||
let hooks = match claimed_hooks {
|
||||
Some(h) => h,
|
||||
None => match db.list_webhooks_for_event(repo_id, event).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, "failed to list webhooks for event {event}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if hooks.is_empty() {
|
||||
@@ -201,44 +76,9 @@ async fn fire_event_async_occurrence(
|
||||
let client = Arc::clone(&http_client);
|
||||
let event_name = event.to_string();
|
||||
let bytes = payload_bytes.clone();
|
||||
// Stable occurrence delivery key when request context exists;
|
||||
// random UUID otherwise (legacy callers without occurrence).
|
||||
let delivery_id = match (request_id, ref_name) {
|
||||
(Some(req), Some(r)) => crate::db::deterministic_id(&["webhook", req, r, &hook.id]),
|
||||
_ => uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
// Claim before spawn so concurrent executors collapse to one
|
||||
// delivery per occurrence. Failures to record fall back to
|
||||
// sending (legacy best-effort) rather than dropping.
|
||||
// Skip claim if already done by caller (durable_outbox path).
|
||||
if !already_claimed && request_id.is_some() {
|
||||
match db
|
||||
.clone()
|
||||
.claim_webhook_delivery(&delivery_id, request_id.unwrap_or(""), repo_id, event)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
let delivery_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let db_for_mark = db.clone();
|
||||
tokio::spawn(async move {
|
||||
// Policy skip, not a delivery failure: the stored URL predates the
|
||||
// create-time gate. The "invalid_url" label is documented on
|
||||
// record_webhook_delivery in metrics.rs.
|
||||
if !crate::api::peers::is_public_http_url(&hook.url) {
|
||||
crate::metrics::record_webhook_delivery("invalid_url");
|
||||
tracing::warn!(
|
||||
url = %hook.url,
|
||||
event = %event_name,
|
||||
"webhook delivery skipped: stored URL is not a public http(s) target"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let delivery_for_mark = delivery_id.clone();
|
||||
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
|
||||
|
||||
let mut req = client
|
||||
@@ -260,10 +100,6 @@ async fn fire_event_async_occurrence(
|
||||
"http_error"
|
||||
};
|
||||
crate::metrics::record_webhook_delivery(result_label);
|
||||
// Any HTTP response proves the delivery fired; mark
|
||||
// sent so the ledger never claims an unsent delivery.
|
||||
// Network errors stay pending for stale-reclaim.
|
||||
let _ = db_for_mark.mark_webhook_sent(&delivery_for_mark).await;
|
||||
tracing::info!(
|
||||
url = %hook.url,
|
||||
event = %event_name,
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let config = std::fs::read_to_string(executable.parent().unwrap().join("config"))?;
|
||||
let (mode, parameter) = config.split_once('\n').unwrap_or((&config, ""));
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if mode == "delay" {
|
||||
std::thread::sleep(Duration::from_millis(parameter.parse().unwrap()));
|
||||
}
|
||||
let batch = args.get(1).is_some_and(|arg| arg == "--batch-check");
|
||||
let input = if batch {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes)?;
|
||||
Some(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cwd = std::env::current_dir()?;
|
||||
let hang = mode == "hang"
|
||||
|| (mode == "hang-repo" && cwd.file_name().is_some_and(|name| name == parameter))
|
||||
|| (mode == "hang-oid"
|
||||
&& (args.iter().any(|arg| arg == parameter)
|
||||
|| input
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| String::from_utf8_lossy(bytes).trim() == parameter)));
|
||||
if hang {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
std::process::exit(1);
|
||||
}
|
||||
if mode == "log-types" && batch {
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(parameter)?;
|
||||
writeln!(
|
||||
log,
|
||||
"{} {}",
|
||||
String::from_utf8_lossy(input.as_ref().unwrap()).trim(),
|
||||
cwd.file_name().unwrap().to_string_lossy()
|
||||
)?;
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.args(&args)
|
||||
.stdin(if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::inherit()
|
||||
})
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
if let Some(bytes) = input {
|
||||
child.stdin.take().unwrap().write_all(&bytes)?;
|
||||
}
|
||||
std::process::exit(child.wait()?.code().unwrap_or(1));
|
||||
}
|
||||
@@ -416,8 +416,8 @@ fn f3_second_writer_leased_until_reap() {
|
||||
NOT RepoWriteGuard (which drops at the disconnect instant, reopening F3).",
|
||||
);
|
||||
let receive = repos_production
|
||||
.find("smart_http::receive_pack_raw")
|
||||
.expect("F3 gate stale: git_receive_pack no longer calls smart_http::receive_pack_raw");
|
||||
.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 \
|
||||
@@ -509,8 +509,8 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
|
||||
/// 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 disposition.spawn_tail`, and `release` must consume the same
|
||||
/// disposition (`disposition.release_ok`) so the two cannot drift apart.
|
||||
/// 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
|
||||
@@ -519,9 +519,7 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() {
|
||||
///
|
||||
/// 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 disposition.spawn_tail` block and the failed-push assertion fails; gate
|
||||
/// `spawn_tail`/`release_ok` on the outcome commit and
|
||||
/// `post_git_disposition_replication_carve_out` fails with it.
|
||||
/// `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");
|
||||
@@ -536,589 +534,41 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() {
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
let success_flag = production
|
||||
.find("post_git_disposition(")
|
||||
.expect("U5 gate missing: the tail's disposition must be bound before the gate");
|
||||
// One tail per push, on the proven-landed ref set. Either spawn
|
||||
// position (carve-out early, or durability-bound at release) must sit
|
||||
// inside a `disposition.spawn_tail` gate and feed `landed_refs` —
|
||||
// never the unfiltered `ref_updates`.
|
||||
let spawn_count = production
|
||||
.matches("tokio::spawn(post_receive_replication_tail(")
|
||||
.count();
|
||||
assert!(
|
||||
spawn_count >= 1,
|
||||
"U5 gate missing: the replication tail must be spawned by git_receive_pack"
|
||||
);
|
||||
for (i, (pos, _)) in production
|
||||
.match_indices("tokio::spawn(post_receive_replication_tail(")
|
||||
.enumerate()
|
||||
{
|
||||
let gate = production[..pos].rfind("if disposition.spawn_tail").expect(
|
||||
"U5 gate missing: every tail spawn must be gated on `if disposition.spawn_tail`",
|
||||
.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()",
|
||||
);
|
||||
// The spawn must sit inside the gate's block: brace depth at the
|
||||
// spawn position must still be positive measured from the `if`.
|
||||
// An ungated spawn after the block closes fails this, as does a
|
||||
// stray `disposition.spawn_tail` mention in a comment.
|
||||
let mut depth = 0i32;
|
||||
for ch in production[gate..pos].chars() {
|
||||
match ch {
|
||||
'{' => depth += 1,
|
||||
'}' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
depth >= 1,
|
||||
"U5 gate bypassed: tail #{i} sits outside the `if disposition.spawn_tail` block"
|
||||
);
|
||||
assert!(
|
||||
!production[gate..pos].contains("post_receive_replication_tail"),
|
||||
"U5 gate bypassed: a second tail spawn sits between the gate and its spawn"
|
||||
);
|
||||
let call_end = production[pos..].find(");").unwrap() + pos;
|
||||
let call = &production[pos..call_end];
|
||||
assert!(
|
||||
call.contains("landed_refs"),
|
||||
"U5 gate bypassed: tail #{i} must carry the proven-landed ref set, not unfiltered ref_updates"
|
||||
);
|
||||
}
|
||||
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(disposition.release_ok)")
|
||||
.expect("U5 gate stale: release must consume the same disposition as the tail gate");
|
||||
.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");
|
||||
// #26 Split PR 1 step 3 — the webhook fan-out moved into
|
||||
// `durable_outbox::apply_request_effects`, which the live
|
||||
// handler calls inline (the recovery drain calls the same
|
||||
// function on the next startup). The gate now pins that the
|
||||
// handler is wired to the executor, not to a per-ref inline
|
||||
// webhook call.
|
||||
let effects_executor = production
|
||||
.find("apply_request_effects(&state, &request_id)")
|
||||
.expect("U5 gate stale: git_receive_pack no longer calls apply_request_effects inline");
|
||||
let webhook = production
|
||||
.find("webhooks::fire_event(")
|
||||
.expect("U5 gate stale: git_receive_pack no longer fires push webhooks");
|
||||
|
||||
assert!(
|
||||
success_flag < spawn,
|
||||
"U5 gate bypassed: the disposition must be bound before any tail spawn"
|
||||
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 < effects_executor,
|
||||
spawn < release && spawn < touch && spawn < webhook,
|
||||
"U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \
|
||||
and the effect executor, so a disconnect in any of those windows cannot drop \
|
||||
and the webhook fan-out, so a disconnect in any of those windows cannot drop \
|
||||
this push's pins, recovery copy, and announcements"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 1 step 3 — drain + handler share a single effect
|
||||
/// executor. The live handler (api/repos.rs) and the recovery
|
||||
/// drain (`durable_outbox::drain_receive_pack_requests_with`) both
|
||||
/// call `apply_request_effects`, so the per-ref effects fan-out
|
||||
/// lives in exactly one place. The v29 per-ref walk
|
||||
/// (`derive_one`, `drain_pending_ref_transitions_all`,
|
||||
/// `lookup_accepted_ordinal`) is dead code: any caller reintroduced
|
||||
/// would be the per-ref walk the step-3 PR removed. This gate
|
||||
/// fails if a call site slips back in or the new seam is bypassed.
|
||||
#[test]
|
||||
fn inv26_step3_live_and_drain_share_apply_request_effects() {
|
||||
let repos = src("api/repos.rs");
|
||||
let outbox = src("durable_outbox.rs");
|
||||
|
||||
// The live handler calls `apply_request_effects`. Split the
|
||||
// file at the test attribute so test code can't satisfy the
|
||||
// gate by itself.
|
||||
let production_repos = repos
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
assert!(
|
||||
production_repos.contains("apply_request_effects(&state, &request_id)"),
|
||||
"live handler must call `apply_request_effects(&state, &request_id)`; \
|
||||
reverting to a per-ref inline fan-out splits live and recovery"
|
||||
);
|
||||
|
||||
// The durable intent insert closes the pre-outbox crash window this
|
||||
// PR exists to fix. Wrapping it in `if false` must turn this gate
|
||||
// red; drain-only tests cannot prove the handler creates the rows.
|
||||
assert!(
|
||||
production_repos.contains("insert_receive_pack_request_with_children("),
|
||||
"live handler must persist durable intent via \
|
||||
`insert_receive_pack_request_with_children`; disabling it must fail this gate"
|
||||
);
|
||||
|
||||
// The drain's per-request seam calls the same executor. Test
|
||||
// code lives below `mod drain_tests`, so split there too.
|
||||
let production_outbox = outbox
|
||||
.split("\nmod drain_tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
assert!(
|
||||
production_outbox.contains("drain_receive_pack_requests_with"),
|
||||
"drain seam `drain_receive_pack_requests_with` must exist; \
|
||||
removing it forces a per-ref walk back into the drain"
|
||||
);
|
||||
assert!(
|
||||
production_outbox.contains("apply_request_effects"),
|
||||
"durable_outbox production code must define `apply_request_effects`; \
|
||||
removing it splits the executor between live and recovery"
|
||||
);
|
||||
|
||||
// The drain's per-request walker is wired to the executor.
|
||||
// The closure body of `drain_receive_pack_requests_with` is the
|
||||
// only call site — if a future change calls `derive_one`
|
||||
// instead, this assertion fires.
|
||||
let drain_seam_open = production_outbox
|
||||
.find("pub async fn drain_receive_pack_requests_with<F, Fut>")
|
||||
.expect("drain seam must be defined in the production half");
|
||||
let drain_seam_close = production_outbox[drain_seam_open..]
|
||||
.find("\n}\n")
|
||||
.expect("drain seam body must close");
|
||||
let drain_seam_body = &production_outbox[drain_seam_open..drain_seam_open + drain_seam_close];
|
||||
assert!(
|
||||
drain_seam_body.contains("apply_request_effects"),
|
||||
"drain_receive_pack_requests_with must call apply_request_effects; \
|
||||
wiring it to a per-ref helper reintroduces the v29 walk"
|
||||
);
|
||||
|
||||
// The deleted per-ref drain must have zero call sites in the
|
||||
// production half. A regression that re-adds a caller would
|
||||
// bring back the per-ref fan-out.
|
||||
assert!(
|
||||
!production_outbox.contains("drain_pending_ref_transitions_all("),
|
||||
"deleted `drain_pending_ref_transitions_all` must have zero production call sites; \
|
||||
a per-ref walk is reintroduced"
|
||||
);
|
||||
assert!(
|
||||
!production_outbox.contains("derive_one("),
|
||||
"deleted `derive_one` must have zero production call sites; \
|
||||
the per-ref fan-out is reintroduced"
|
||||
);
|
||||
assert!(
|
||||
!production_outbox.contains("lookup_accepted_ordinal("),
|
||||
"deleted `lookup_accepted_ordinal` must have zero production call sites; \
|
||||
the per-ref ordinal lookup is reintroduced"
|
||||
);
|
||||
assert!(
|
||||
!production_repos.contains("derive_one("),
|
||||
"deleted `derive_one` must have zero live-handler call sites"
|
||||
);
|
||||
assert!(
|
||||
!production_repos.contains("drain_pending_ref_transitions"),
|
||||
"deleted per-ref drain functions must have zero live-handler call sites"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 1 step 4 — the periodic queue-lifecycle purge is
|
||||
/// wired in `main.rs` and the contract is enforced by the `idx_receive_pack_requests_completed_at`
|
||||
/// partial index from v41.
|
||||
///
|
||||
/// Assertions:
|
||||
/// 1. `main.rs` calls `purge_request_queue` once at boot (well, on
|
||||
/// the spawn-task interval) and the spawn function exists.
|
||||
/// 2. The DB helpers `purge_completed_receive_pack_requests` and
|
||||
/// `purge_completed_pending_ref_transitions` exist in `db/mod.rs`.
|
||||
/// 3. The config knobs `queue_retention_days` and `queue_purge_batch` exist
|
||||
/// on `Config`.
|
||||
/// 4. `quarantined` is NOT in the purge WHERE clause (step 4 does not
|
||||
/// introduce the state, but the invariant holds for the future).
|
||||
#[test]
|
||||
fn inv26_step4_queue_lifecycle_purge_is_wired() {
|
||||
let main_src = src("main.rs");
|
||||
assert!(
|
||||
main_src.contains("spawn_queue_lifecycle_sweep"),
|
||||
"main.rs must spawn the periodic queue-lifecycle purge"
|
||||
);
|
||||
assert!(
|
||||
main_src.contains("purge_request_queue"),
|
||||
"main.rs must call purge_request_queue on the periodic sweep"
|
||||
);
|
||||
|
||||
let db_src = src("db/mod.rs");
|
||||
assert!(
|
||||
db_src.contains("purge_completed_receive_pack_requests"),
|
||||
"db/mod.rs must expose purge_completed_receive_pack_requests"
|
||||
);
|
||||
assert!(
|
||||
db_src.contains("purge_completed_pending_ref_transitions"),
|
||||
"db/mod.rs must expose purge_completed_pending_ref_transitions"
|
||||
);
|
||||
|
||||
let config_src = src("config.rs");
|
||||
assert!(
|
||||
config_src.contains("queue_retention_days"),
|
||||
"Config must expose queue_retention_days"
|
||||
);
|
||||
assert!(
|
||||
config_src.contains("queue_purge_batch"),
|
||||
"Config must expose queue_purge_batch"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 1 step 5 — the marker gate and the retry-bound
|
||||
/// quarantine are wired end-to-end. A missing or hash-mismatched
|
||||
/// marker on disk quarantines the request; a Retry over the bound
|
||||
/// does too. This gate pins every load-bearing seam, against the
|
||||
/// production half of each file (the test modules name the same
|
||||
/// identifiers in their own harnesses).
|
||||
///
|
||||
/// Assertions:
|
||||
/// 1. `reconcile_prepared_page` calls `mark_request_quarantined`
|
||||
/// and `mark_children_rejected_for_quarantined_parent` on
|
||||
/// marker-gate failure.
|
||||
/// 2. The drain's `EffectsOutcome::Retry` arm checks
|
||||
/// `effects_max_attempts`.
|
||||
/// 3. `git::store::read_ref` exists in `git/store.rs`.
|
||||
/// 4. `db::mark_request_quarantined`,
|
||||
/// `db::mark_children_rejected_for_quarantined_parent`,
|
||||
/// `db::get_receive_pack_requests_by_ids`, and
|
||||
/// `db::request_state::QUARANTINED` all exist.
|
||||
/// 5. The handler writes the marker ref BEFORE calling
|
||||
/// `smart_http::receive_pack_raw` (the durability window).
|
||||
#[test]
|
||||
fn inv26_step5_marker_quarantine_and_bound_are_wired() {
|
||||
let outbox = src("durable_outbox.rs");
|
||||
let store = src("git/store.rs");
|
||||
let db = src("db/mod.rs");
|
||||
let repos = src("api/repos.rs");
|
||||
|
||||
// Split at the TEST MODULE for the production-only assertions.
|
||||
let production_outbox = outbox
|
||||
.split("\nmod drain_tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
let production_repos = repos
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// (1) The reconcile's marker gate quarantines via the DB helpers.
|
||||
assert!(
|
||||
production_outbox.contains("mark_request_quarantined"),
|
||||
"reconcile_prepared_page must call mark_request_quarantined on marker-gate failure"
|
||||
);
|
||||
assert!(
|
||||
production_outbox.contains("mark_children_rejected_for_quarantined_parent"),
|
||||
"reconcile_prepared_page must call mark_children_rejected_for_quarantined_parent \
|
||||
so quarantined parents cancel their children"
|
||||
);
|
||||
|
||||
// The gate reads the marker ref and compares against the parent's
|
||||
// `request_bytes_hash` via `git::store::read_ref` /
|
||||
// `git::store::marker_value_for`. Reverting either reintroduces
|
||||
// the DoS window the marker gate exists to close.
|
||||
assert!(
|
||||
production_outbox.contains("git::store::read_ref"),
|
||||
"reconcile_prepared_page must read the marker ref via git::store::read_ref"
|
||||
);
|
||||
assert!(
|
||||
production_outbox.contains("marker_value_for"),
|
||||
"reconcile_prepared_page must compute the expected marker value via \
|
||||
git::store::marker_value_for"
|
||||
);
|
||||
|
||||
// (2) The drain's `EffectsOutcome::Retry` arm checks the bound.
|
||||
assert!(
|
||||
production_outbox.contains("effects_max_attempts"),
|
||||
"drain_receive_pack_requests_with must consult effects_max_attempts on Retry"
|
||||
);
|
||||
|
||||
// (3) `git::store::read_ref` is the read seam the gate depends on.
|
||||
assert!(
|
||||
store.contains("pub fn read_ref("),
|
||||
"git::store::read_ref must exist; the marker gate reads through it"
|
||||
);
|
||||
assert!(
|
||||
store.contains("pub fn marker_value_for("),
|
||||
"git::store::marker_value_for must exist; the marker gate computes the expected \
|
||||
value with it (and the live handler writes the value via the same helper)"
|
||||
);
|
||||
|
||||
// (4) DB-side seams the gate depends on.
|
||||
assert!(
|
||||
db.contains("pub async fn mark_request_quarantined"),
|
||||
"Db::mark_request_quarantined must exist"
|
||||
);
|
||||
assert!(
|
||||
db.contains("pub async fn mark_children_rejected_for_quarantined_parent"),
|
||||
"Db::mark_children_rejected_for_quarantined_parent must exist"
|
||||
);
|
||||
assert!(
|
||||
db.contains("pub async fn get_receive_pack_requests_by_ids"),
|
||||
"Db::get_receive_pack_requests_by_ids must exist (avoids N+1 in the marker gate)"
|
||||
);
|
||||
assert!(
|
||||
db.contains("pub const QUARANTINED: &str = \"quarantined\""),
|
||||
"request_state::QUARANTINED must be defined"
|
||||
);
|
||||
|
||||
// (5) The handler writes the marker ref BEFORE the durability
|
||||
// boundary (smart_http::receive_pack_raw). Severing the
|
||||
// ordering re-opens the marker-gate DoS window for live pushes.
|
||||
let marker_write = production_repos
|
||||
.find("git::store::marker_value_for")
|
||||
.expect("U5 gate stale: the live handler no longer computes the marker value");
|
||||
let receive_raw = production_repos
|
||||
.find("smart_http::receive_pack_raw")
|
||||
.expect("U5 gate stale: git_receive_pack no longer calls smart_http::receive_pack_raw");
|
||||
assert!(
|
||||
marker_write < receive_raw,
|
||||
"U5 gate bypassed: the marker ref must be written BEFORE receive_pack_raw so the \
|
||||
reconcile's gate has evidence of the live push"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #26 split 1 — namespace gate and pre-git refusal lifecycle.
|
||||
///
|
||||
/// The push gate (repos.rs) must deny:
|
||||
/// 1. Any ref-update line that fails UTF-8 decode (the raw body is passed verbatim
|
||||
/// to git receive-pack, and git accepts refnames with bytes >= 0x80; the gate
|
||||
/// never sees these refs, no durable child row exists, and under requests/issues/
|
||||
/// the lossy for-each-ref decode still matches the narrowed exemption).
|
||||
/// 2. The bare `refs/gitlawb` name (the `starts_with("refs/gitlawb/")` check misses it).
|
||||
///
|
||||
/// Every explicit pre-git refusal must call `refuse_receive_pack_before_git` to
|
||||
/// terminalize the intent aggregate in one transaction, or a dropped future between
|
||||
/// the intent insert and `receive_pack` strands a `received` + `prepared` aggregate
|
||||
/// that is never purged, never promotable, and poisons its tuple.
|
||||
///
|
||||
/// The visibility exemption (visibility_pack.rs) was narrowed to `requests/` and
|
||||
/// `issues/` only. The push gate is the premise that narrowing relies on (the comment
|
||||
/// at visibility_pack.rs:340), so tests must prove:
|
||||
/// - A `refs/gitlawb/<other>` ref pointing at a blob is denied on push.
|
||||
/// - A stray `refs/gitlawb/<other>` blob ref planted in the pack path fails closed.
|
||||
///
|
||||
/// Source gates below pin the wiring; behavioral proofs live next to the code:
|
||||
/// `parse_ref_updates_refuses_non_utf8_command_line` and
|
||||
/// `internal_namespace_gate_covers_bare_name_and_subtree` (api/repos.rs),
|
||||
/// `planted_non_exempt_gitlawb_blob_ref_fails_closed` (git/visibility_pack.rs),
|
||||
/// and the mirror import prune
|
||||
/// `prune_removes_non_exempt_gitlawb_refs_and_keeps_exempt` (sync.rs).
|
||||
#[test]
|
||||
fn issue_26_namespace_gate_and_refusal_wiring() {
|
||||
let repos = src("api/repos.rs");
|
||||
let vis = src("git/visibility_pack.rs");
|
||||
|
||||
// P1: fail the push on ref-update lines the handler cannot decode.
|
||||
// The gate at parse_ref_updates must refuse when any command line in the
|
||||
// ref-update section is undecodable UTF-8. The check is an Err arm on
|
||||
// `from_utf8(data)` that returns BadRequest, so removing it allows non-UTF-8
|
||||
// refs to bypass the namespace gate.
|
||||
assert!(
|
||||
repos.contains("ref-update line is not valid UTF-8; push refused"),
|
||||
"P1 gate missing: parse_ref_updates must refuse when any ref-update line is \
|
||||
non-UTF-8 (git accepts bytes >= 0x80; the gate never sees them, no durable \
|
||||
child exists, and the lossy for-each-ref decode still matches the exemption)"
|
||||
);
|
||||
|
||||
// P1 (cont): the bare `refs/gitlawb` name must also be caught. The
|
||||
// predicate is extracted as `ref_is_internal_namespace` so it is
|
||||
// unit-testable; pin the bare-name arm inside it and its use at the
|
||||
// push gate (a `starts_with("refs/gitlawb/")`-only check misses the
|
||||
// bare name, and it lands on repos with no internal refs yet).
|
||||
assert!(
|
||||
repos.contains("ref_name == \"refs/gitlawb\"")
|
||||
&& repos.contains(".find(|r| ref_is_internal_namespace(r))"),
|
||||
"P1 gate missing: ref_is_internal_namespace must match the bare \
|
||||
'refs/gitlawb' name and the push gate must consult it \
|
||||
(starts_with('refs/gitlawb/') misses it, and it lands on repos with \
|
||||
no internal refs yet)"
|
||||
);
|
||||
|
||||
// P1 (cont): every explicit pre-git refusal must call refuse_receive_pack_before_git
|
||||
// to terminalize the intent. Exactly 6 occurrences: the five inline call
|
||||
// sites (lease cap, per-source cap, write pool, acquire timeout, acquire
|
||||
// error) plus the drop-guard's own pre-git spawn. The post-git guard phase
|
||||
// calls mark_receive_pack_interrupted (a different name), so deleting any
|
||||
// one inline site drops the count to 5 and turns this red. The parse and
|
||||
// namespace refusals correctly carry no call: they return before the
|
||||
// intent insert, so there is no aggregate to terminalize.
|
||||
assert_eq!(
|
||||
repos.matches("refuse_receive_pack_before_git(").count(),
|
||||
6,
|
||||
"P1 wiring: exactly 6 refuse_receive_pack_before_git calls expected (5 \
|
||||
inline refusal sites + guard pre-git spawn); deleting any inline site \
|
||||
must turn this red"
|
||||
);
|
||||
// Pin the five inline sites by their reason literals, so a site that stops
|
||||
// terminalizing (or is renumbered away) fails by name, not just by count.
|
||||
for reason in [
|
||||
"repo write-lease waiter cap reached",
|
||||
"admission per-source cap reached",
|
||||
"git write pool saturated",
|
||||
"acquire_write timed out",
|
||||
"acquire_write failed",
|
||||
"handler dropped before git started",
|
||||
] {
|
||||
assert!(
|
||||
repos.contains(reason),
|
||||
"P1 wiring: refusal reason literal missing: {reason}"
|
||||
);
|
||||
}
|
||||
|
||||
// P1 (cont): the intent drop-guard must be phase-aware. It is armed before
|
||||
// the insert resolves (covering the cancellation window), flipped to
|
||||
// git-started immediately before the receive_pack await (a drop from there
|
||||
// is a mid-git disconnect, terminalized via mark_receive_pack_interrupted
|
||||
// because the Err arm never runs on a drop), and disarmed once the outcome
|
||||
// commit resolves. Assert the ordering, not just presence: the phase flip
|
||||
// must precede the receive_pack call.
|
||||
assert!(
|
||||
repos.contains("IntentDropGuard::new") && repos.contains("notify_git_started()"),
|
||||
"P1 gate missing: IntentDropGuard must exist and flip to git-started phase \
|
||||
before receive_pack (a dropped future between intent insert and git start \
|
||||
strands a received+prepared aggregate)"
|
||||
);
|
||||
let flip_pos = repos
|
||||
.find("notify_git_started()")
|
||||
.expect("notify_git_started call must exist");
|
||||
let git_pos = repos
|
||||
.find("receive_pack_raw_with_reflog(")
|
||||
.expect("receive_pack call must exist");
|
||||
assert!(
|
||||
flip_pos < git_pos,
|
||||
"P1 ordering: notify_git_started() must precede the receive_pack call, \
|
||||
or a mid-git drop is misclassified as a pre-git shed"
|
||||
);
|
||||
assert!(
|
||||
repos.contains("_intent_guard.disarm()"),
|
||||
"P1 gate missing: the guard must disarm once the outcome commit resolves, \
|
||||
or every push spawns a wasteful cleanup txn at scope end"
|
||||
);
|
||||
// A sync Drop cannot await: cleanup is spawned via the current runtime
|
||||
// handle with an off-runtime fallback. A bare tokio::spawn panics
|
||||
// mid-unwind with no runtime (off-runtime drop, process teardown).
|
||||
assert!(
|
||||
repos.contains("Handle::try_current()"),
|
||||
"P1 gate missing: guard Drop must spawn via Handle::try_current with an \
|
||||
off-runtime arm, not a bare tokio::spawn"
|
||||
);
|
||||
|
||||
// P2: the post-git drop arm must exist and be wired. The guard's
|
||||
// GIT_STARTED phase spawns `mark_receive_pack_interrupted` (parent to
|
||||
// `rejected_at_git`, children to `uncertain` — the Err arm never runs on
|
||||
// a drop). Deleting that spawn keeps every count above green, so pin the
|
||||
// method name; its behavior is proved by
|
||||
// `post_git_interruption_marks_uncertain_and_terminalizes_parent`.
|
||||
assert!(
|
||||
repos.contains("mark_receive_pack_interrupted("),
|
||||
"P2 wiring: the guard's post-git phase must spawn \
|
||||
mark_receive_pack_interrupted (a mid-git drop otherwise strands \
|
||||
received+prepared with no Err arm to mark them uncertain)"
|
||||
);
|
||||
// The guard must commit computed fates rather than a blanket uncertain
|
||||
// once the report is parsed, and only disarm on a resolved commit: pin
|
||||
// both the stash call and the `outcome_commit_ok` gate. Making disarm()
|
||||
// unconditional strands the commit-failure path and misfiles proven
|
||||
// rejections as uncertain.
|
||||
assert!(
|
||||
repos.contains("set_fates(ComputedFates"),
|
||||
"P2 wiring: computed fates must be stashed on the guard once parsed, \
|
||||
or a post-commit drop can only apply a context-free uncertain"
|
||||
);
|
||||
assert!(
|
||||
repos.contains("if outcome_commit_ok"),
|
||||
"P2 wiring: disarm must be gated on outcome_commit_ok; unconditional \
|
||||
disarm strands the persistent-commit-failure path"
|
||||
);
|
||||
// The live-handler `AwaitingSiblings` arm is unreachable through the
|
||||
// current outcome builder (documented at the arm itself) and therefore
|
||||
// has no behavioral test; pin its presence so removing it is conscious.
|
||||
// The drain consumer IS behaviorally driven (sibling-wait tests).
|
||||
assert_eq!(
|
||||
repos.matches("EffectsOutcome::AwaitingSiblings").count(),
|
||||
1,
|
||||
"live AwaitingSiblings arm must exist (defensive symmetry with the \
|
||||
drain consumer); deleting it must turn this red"
|
||||
);
|
||||
|
||||
// P2: pin the mirror prune call sites. The prune helper is called from
|
||||
// both `clone_repo` and `fetch_repo` (plus its own definition) — deleting
|
||||
// either call leaves the direct-helper test green while mirrors import
|
||||
// evil refs again. Count occurrences of the call with its argument.
|
||||
let sync_src = src("sync.rs");
|
||||
assert_eq!(
|
||||
sync_src
|
||||
.matches("prune_non_exempt_gitlawb_refs(local_path)")
|
||||
.count(),
|
||||
2,
|
||||
"P2 wiring: prune must be called from both clone_repo and fetch_repo; \
|
||||
deleting either call must turn this red"
|
||||
);
|
||||
// Both call sites must route through `wrap_prune_error`, which preserves
|
||||
// deterministic `PruneInvalid` refusals instead of stringifying every
|
||||
// error into transient `PruneFailed` (an every-tick deferral spin for
|
||||
// permanently invalid mirrors). Pin the exact call shape at both sites.
|
||||
assert_eq!(
|
||||
sync_src.matches(".map_err(wrap_prune_error)").count(),
|
||||
2,
|
||||
"P2 wiring: both prune call sites must use wrap_prune_error; inline \
|
||||
rewrap must turn this red"
|
||||
);
|
||||
|
||||
// P2: the visibility exemption narrowing removed a serving path. The exemption
|
||||
// is now scoped to `requests/` and `issues/` only (visibility_pack.rs:371), and
|
||||
// the comment at :340 states the push gate is the premise. Tests must prove the
|
||||
// gate works, which the P1 findings above covered (non-UTF-8 line, bare name,
|
||||
// refusal wiring). This check verifies the exemption narrowing landed.
|
||||
assert!(
|
||||
vis.contains(
|
||||
r#"!r.starts_with("refs/gitlawb/requests/") && !r.starts_with("refs/gitlawb/issues/")"#
|
||||
),
|
||||
"P2 gate missing: the visibility exemption must be narrowed to requests/ and \
|
||||
issues/ only (the push gate is the premise; any other refs/gitlawb/* ref is \
|
||||
denied on push and must fail closed in the pack path)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Claimed-webhook delivery must not re-list hooks: the durable path claims
|
||||
/// synchronously, hands the claimed hook rows to the sender, and a list
|
||||
/// failure in the send task would silently drop already-claimed deliveries.
|
||||
#[test]
|
||||
fn inv26_claimed_hook_delivery_never_relists() {
|
||||
let webhooks = src("webhooks.rs");
|
||||
let production = webhooks
|
||||
.split("\nmod tests {")
|
||||
.next()
|
||||
.expect("split always yields a first chunk");
|
||||
|
||||
// The claimed entry point exists and passes hook ROWS (not ids) so the
|
||||
// sender never needs the hook table again.
|
||||
let entry = production
|
||||
.find("fire_event_occurrence_with_claimed_hooks(")
|
||||
.expect("claimed-hooks sender missing");
|
||||
let claimed_rows = production
|
||||
.find("claimed_hooks: Vec<crate::db::Webhook>")
|
||||
.expect("claimed path must carry Webhook rows, not ids");
|
||||
let list_call = production
|
||||
.find("db.list_webhooks_for_event(repo_id, event)")
|
||||
.expect("unclaimed path still lists hooks");
|
||||
assert!(
|
||||
entry < claimed_rows && claimed_rows < list_call,
|
||||
"claimed sender must hand rows through before any list fallback"
|
||||
);
|
||||
|
||||
// Inside the async worker, the claimed branch must bypass the list.
|
||||
// Inside the async worker (which starts after the claim helper), the
|
||||
// claimed branch must be selected before the list fallback can run.
|
||||
let worker = production
|
||||
.find("async fn fire_event_async_occurrence(")
|
||||
.expect("occurrence worker missing");
|
||||
let already_claimed = production[worker..]
|
||||
.find("let already_claimed = claimed_hooks.is_some();")
|
||||
.map(|i| worker + i)
|
||||
.expect("claimed path must short-circuit before re-listing");
|
||||
let worker_list = production[worker..]
|
||||
.find("db.list_webhooks_for_event(repo_id, event)")
|
||||
.map(|i| worker + i)
|
||||
.expect("unclaimed path still lists hooks");
|
||||
assert!(
|
||||
already_claimed < worker_list,
|
||||
"the claimed branch must be selected before list_webhooks_for_event can run"
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
//! #26 Split PR 4 — `GITLAWB_ENFORCE_OWNER_PUSH` default is consistent across surfaces.
|
||||
//!
|
||||
//! Reviewer 2 (2026-08-28) closed PR #224 with a P2 finding: SECURITY.md and
|
||||
//! README.md both stated the default was `false`, while `Config::enforce_owner_push`
|
||||
//! declared `default_value_t = true` and the runtime, .env.example, and the env
|
||||
//! var table on the README all agreed with the code. Two operator-facing
|
||||
//! surfaces lied about the authorization policy. PR #330 flipped the code
|
||||
//! default and updated .env.example / README env table / docs/RUN-A-NODE.md,
|
||||
//! but missed these two prose mentions.
|
||||
//!
|
||||
//! This guard pins the documentation and example surfaces together. Each one
|
||||
//! is read off the working tree at test time, not a copy in the binary, so a
|
||||
//! revert of the doc edit turns it red:
|
||||
//!
|
||||
//! 1. `SECURITY.md` says the default is `true`.
|
||||
//! 2. `README.md` says the default is `true` in the known-limitations prose.
|
||||
//! 3. `README.md` says the default is `true` in the env-var table row.
|
||||
//! 4. `.env.example` sets `GITLAWB_ENFORCE_OWNER_PUSH=true`.
|
||||
//!
|
||||
//! The runtime declared default (`Config::enforce_owner_push`'s
|
||||
//! `default_value_t = true`) is protected by the
|
||||
//! `enforce_owner_push_is_declared_true_independent_of_the_environment`
|
||||
//! unit test in `config.rs`, which reads the parser directly and is
|
||||
//! env-independent. The two checks together cover the surfaces that
|
||||
//! produced the original doc-vs-code drift; this guard cannot reach into
|
||||
//! the binary crate's `Config` type (it is private to the bin target,
|
||||
//! which has no `lib.rs`) without a larger refactor, so the contract is
|
||||
//! stated as the four documentation/example surfaces above.
|
||||
//!
|
||||
//! The four are checked independently. A check that just verified "at least
|
||||
//! one surface says true" would pass while a stale SECURITY.md lied; a check
|
||||
//! that only verified the binary would not catch the doc drift that
|
||||
//! produced this finding in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn read(rel: &str) -> String {
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join(rel);
|
||||
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
|
||||
}
|
||||
|
||||
/// Pin the prose mentions of `GITLAWB_ENFORCE_OWNER_PUSH` so a future doc
|
||||
/// edit cannot reintroduce the "defaults to false" wording the reviewer
|
||||
/// flagged. The wording in the test is what the docs MUST say about the
|
||||
/// default; absence of any prose mention is also a fail because operators
|
||||
/// rely on the docs to learn the default.
|
||||
#[test]
|
||||
fn security_md_states_owner_push_default_is_true() {
|
||||
let security = read("SECURITY.md");
|
||||
let section = section_after(&security, "### Repository write authorization defaults")
|
||||
.unwrap_or_else(|| panic!("SECURITY.md is missing the owner-push section"));
|
||||
|
||||
assert!(
|
||||
section.contains("defaults to `true`"),
|
||||
"SECURITY.md owner-push section must state the default is true. \
|
||||
Found:\n{section}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(§ion),
|
||||
"SECURITY.md owner-push section must not claim the default is false. \
|
||||
Found:\n{section}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_known_limitations_states_owner_push_default_is_true() {
|
||||
let readme = read("README.md");
|
||||
let limitations = section_after(&readme, "Known limitations:")
|
||||
.unwrap_or_else(|| panic!("README.md is missing the Known limitations: section"));
|
||||
|
||||
// The owner-push line is one bullet in the limitations list. Pull the
|
||||
// single line that mentions the env var so the assertion is local to
|
||||
// the claim, not a substring of unrelated text.
|
||||
let line = limitations
|
||||
.lines()
|
||||
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"README.md Known limitations must mention GITLAWB_ENFORCE_OWNER_PUSH.\n\
|
||||
Got:\n{limitations}"
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
line.contains("defaults to `true`") || line.contains("defaults to true"),
|
||||
"README Known-limitations owner-push bullet must state the default is true. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(line),
|
||||
"README Known-limitations owner-push bullet must not claim the default is false. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_env_var_table_states_owner_push_default_is_true() {
|
||||
let readme = read("README.md");
|
||||
let line = readme
|
||||
.lines()
|
||||
.find(|l| l.contains("GITLAWB_ENFORCE_OWNER_PUSH") && l.contains("|"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("README.md is missing the env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH")
|
||||
});
|
||||
|
||||
assert!(
|
||||
line.contains("Defaults to `true`") || line.contains("defaults to `true`"),
|
||||
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must state the default is true. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
assert!(
|
||||
!section_claims_default_is_false(line),
|
||||
"README env-var-table row for GITLAWB_ENFORCE_OWNER_PUSH must not claim the default is false. \
|
||||
Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_example_sets_owner_push_true() {
|
||||
let env = read(".env.example");
|
||||
let line = env
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("GITLAWB_ENFORCE_OWNER_PUSH"))
|
||||
.unwrap_or_else(|| panic!(".env.example is missing GITLAWB_ENFORCE_OWNER_PUSH"));
|
||||
|
||||
assert!(
|
||||
line.contains("=true"),
|
||||
".env.example GITLAWB_ENFORCE_OWNER_PUSH must be set to true. Found:\n{line}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A claim that the default is false can hide in many phrasings. Match the
|
||||
/// forms that have appeared in the wild on this branch: "defaults to `false`",
|
||||
/// "defaults to false", "default is false", "is `false` for compatibility",
|
||||
/// "is false for compatibility". The match is intentionally narrow — false
|
||||
/// positives would suppress a real doc edit.
|
||||
fn section_claims_default_is_false(section: &str) -> bool {
|
||||
let lower = section.to_ascii_lowercase();
|
||||
lower.contains("defaults to `false`")
|
||||
|| lower.contains("defaults to false")
|
||||
|| lower.contains("default is `false`")
|
||||
|| lower.contains("default is false")
|
||||
|| (lower.contains("`false`") && lower.contains("for compatibility"))
|
||||
}
|
||||
|
||||
/// Return the prose after a marker up to the next heading boundary. The
|
||||
/// marker is normally a markdown heading (e.g. `### Section`); if it has
|
||||
/// no `#` prefix it is treated as a plain-text marker and the slice is
|
||||
/// bounded by the next heading of any level. This matters because a
|
||||
/// plain-text marker would otherwise produce `header_level = 0`, making
|
||||
/// the same-or-higher-level stop condition unsatisfiable and letting the
|
||||
/// slice run to EOF.
|
||||
fn section_after(doc: &str, heading: &str) -> Option<String> {
|
||||
let start = doc.find(heading)?;
|
||||
let after = &doc[start..];
|
||||
let header_level = heading.chars().take_while(|c| *c == '#').count();
|
||||
|
||||
// Skip past the heading line itself.
|
||||
let body_start = after.find('\n')? + 1;
|
||||
let body = &after[body_start..];
|
||||
|
||||
// Stop at the next heading of the same or higher level, or at the next
|
||||
// heading of any level when the marker had no `#` prefix.
|
||||
let mut end = body.len();
|
||||
for line in body.lines() {
|
||||
let level = line.chars().take_while(|c| *c == '#').count();
|
||||
if level > 0 && (header_level == 0 || level <= header_level) {
|
||||
end = line.as_ptr() as usize - body.as_ptr() as usize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(body[..end].to_string())
|
||||
}
|
||||
@@ -26,10 +26,7 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
dirs = "5"
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
urlencoding = "2"
|
||||
libc = "0.2"
|
||||
alloy = { version = "1", default-features = false, features = [
|
||||
"contract",
|
||||
"provider-http",
|
||||
|
||||
@@ -88,7 +88,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
|
||||
let short = did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| crate::text::truncate(s, 16))
|
||||
.map(|s| &s[..s.len().min(16)])
|
||||
.unwrap_or("?");
|
||||
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
|
||||
let caps = agent["capabilities"]
|
||||
|
||||
@@ -275,7 +275,7 @@ async fn cmd_list(
|
||||
let title = b["title"].as_str().unwrap_or("?");
|
||||
let amount = b["amount"].as_i64().unwrap_or(0);
|
||||
let st = b["status"].as_str().unwrap_or("?");
|
||||
let short_id = crate::text::truncate(id, 8);
|
||||
let short_id = &id[..8.min(id.len())];
|
||||
println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}");
|
||||
}
|
||||
}
|
||||
@@ -403,7 +403,7 @@ async fn cmd_stats(node: String) -> Result<()> {
|
||||
let did = entry["did"].as_str().unwrap_or("?");
|
||||
let count = entry["completed"].as_i64().unwrap_or(0);
|
||||
let earned = entry["total_earned"].as_i64().unwrap_or(0);
|
||||
let short = crate::text::truncate_suffix(did, 8);
|
||||
let short = &did[did.len().saturating_sub(8)..];
|
||||
println!(
|
||||
" {}. ...{short} {count} bounties {earned} $GITLAWB",
|
||||
i + 1
|
||||
|
||||
+22
-545
@@ -4,7 +4,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -35,12 +34,7 @@ pub enum CertCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show a specific ref certificate and verify its signature.
|
||||
///
|
||||
/// `--verify` requires `--expect-node <did>`: the queried node's
|
||||
/// self-reported DID is not a trust anchor, because a node serving a
|
||||
/// forged certificate also controls its own self-report. Callers
|
||||
/// must name the issuer they trust.
|
||||
/// Show a specific ref certificate and verify its signature
|
||||
Show {
|
||||
/// Repository in <owner>/<repo> or <repo> format
|
||||
repo: String,
|
||||
@@ -55,17 +49,13 @@ pub enum CertCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Exit non-zero unless the Ed25519 signature verifies AND the
|
||||
/// issuing node matches --expect-node (which --verify requires:
|
||||
/// without a caller-supplied anchor there is no trusted issuer to
|
||||
/// verify against)
|
||||
/// 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. The queried node's self-reported DID is deliberately NOT
|
||||
/// accepted as a fallback anchor: the node that served the cert can
|
||||
/// serve a matching self-report.
|
||||
/// trust: this value when given, else the queried node's DID.
|
||||
#[arg(long, requires = "verify")]
|
||||
expect_node: Option<String>,
|
||||
},
|
||||
@@ -127,10 +117,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let id = cert["id"].as_str().unwrap_or("?");
|
||||
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
|
||||
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
|
||||
let issued_at = cert["issued_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 19))
|
||||
.unwrap_or("?");
|
||||
let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?");
|
||||
println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}");
|
||||
}
|
||||
Ok(())
|
||||
@@ -162,24 +149,6 @@ async fn cmd_show(
|
||||
let node_did = cert["node_did"].as_str().unwrap_or("?");
|
||||
let signature = cert["signature"].as_str().unwrap_or("?");
|
||||
let issued_at = cert["issued_at"].as_str().unwrap_or("?");
|
||||
// #26 Split PR 3: read the wire-format version. A MISSING key
|
||||
// selects legacy v1 (an old server that predates the field).
|
||||
// A PRESENT value must be a JSON integer that fits in u32 and
|
||||
// names a version this client supports — otherwise the response
|
||||
// declares a format we cannot represent and we refuse to verify
|
||||
// rather than guess the payload shape. Reviewer 2 finding:
|
||||
// collapsing `null`, a string, a float, or an overflow integer
|
||||
// onto v1 would let a well-signed v1 signature pass `--verify`
|
||||
// against a server that explicitly said "version 2 (or 99, or
|
||||
// 4294967297)" — that is the exact mismatch the field exists
|
||||
// to prevent. parse_cert_version is the load-bearing parser
|
||||
// that distinguishes missing-key (legacy v1) from invalid-value
|
||||
// (unsupported).
|
||||
let parsed_version = parse_cert_version(cert.get("version"));
|
||||
let version_display: String = match &parsed_version {
|
||||
Ok(v) => v.to_string(),
|
||||
Err(reason) => format!("unsupported ({reason})"),
|
||||
};
|
||||
|
||||
println!("Ref Certificate: {cert_id}");
|
||||
println!(" Ref: {ref_name}");
|
||||
@@ -188,7 +157,6 @@ async fn cmd_show(
|
||||
println!(" Pusher: {pusher}");
|
||||
println!(" Node DID: {node_did}");
|
||||
println!(" Issued at: {issued_at}");
|
||||
println!(" Version: {version_display}");
|
||||
println!(" Signature: {signature}");
|
||||
println!();
|
||||
|
||||
@@ -198,31 +166,9 @@ async fn cmd_show(
|
||||
// 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 = match parsed_version {
|
||||
// v1 is the only version this client verifies. The v1
|
||||
// signed payload is the 7-field canonical form with no
|
||||
// `version` key — see gitlawb-node/src/cert.rs. A future
|
||||
// v2+ cert has a different signed payload shape (the
|
||||
// version field becomes part of the signed JSON per
|
||||
// `v2_signing_payload`), so a v2+ cert from a server this
|
||||
// client does not know about must NOT be silently verified
|
||||
// as v1.
|
||||
// Reviewer 2: refuse rather than guess; the client and
|
||||
// server must agree on the version.
|
||||
// parse_cert_version admits exactly one Ok value: 1 (missing key and
|
||||
// integer 1 both land there; 2, floats, strings, overflow and every
|
||||
// other spelling come back Err with their own reason). A separate
|
||||
// `Ok(v != 1)` arm existed here and was DEAD CODE — a test aimed at it
|
||||
// could never catch drift in this match (review round 2) — so the
|
||||
// match now mirrors the parser's real contract, which the
|
||||
// `parse_cert_version_truth_table` test pins.
|
||||
Ok(_) => verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
),
|
||||
Err(ref reason) => Err(format!(
|
||||
"cert declared a version this client cannot represent ({reason}); refusing to verify"
|
||||
)),
|
||||
};
|
||||
let verdict = verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
);
|
||||
|
||||
println!("Signature verification:");
|
||||
match &verdict {
|
||||
@@ -232,11 +178,7 @@ async fn cmd_show(
|
||||
);
|
||||
}
|
||||
Err(reason) => {
|
||||
// A version refusal means no cryptographic check ran, so it must
|
||||
// not print as an INVALID signature: operators and log scrapers
|
||||
// would read that as a failed Ed25519 verification. INVALID is
|
||||
// reserved for failures out of verify_signature itself.
|
||||
println!(" {} — {reason}", signature_verdict_label(&parsed_version));
|
||||
println!(" INVALID — {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,39 +195,29 @@ async fn cmd_show(
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
let current_node_did = current.as_ref().ok().cloned();
|
||||
for line in did_check_report(¤t, node_did) {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
if require_valid {
|
||||
if let Err(reason) = verdict {
|
||||
// Round 3: do not claim the signature failed when it was
|
||||
// never checked. A version refusal means the payload
|
||||
// shape is unknown, so no signature verification ran.
|
||||
anyhow::bail!("certificate could not be verified: {reason}");
|
||||
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 — and the anchor must be
|
||||
// a DID the CALLER trusts, which is exactly what --expect-node is.
|
||||
// The DID the queried node reports at `/` is NOT an anchor: the same
|
||||
// node that served a forged certificate can serve a matching
|
||||
// self-report, and with that fallback a wholly forged cert printed
|
||||
// VALID and exited 0 (review round 2, demonstrated by execution). So
|
||||
// --verify without --expect-node refuses to claim a trusted issuer at
|
||||
// all, rather than laundering the server's self-assertion into one.
|
||||
match expect_node.as_deref() {
|
||||
// --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!(
|
||||
"--verify needs --expect-node <did> to anchor the issuer: the signature is \
|
||||
valid, but it only proves the cert is self-consistent with the DID it \
|
||||
names ({node_did}); the queried node's self-reported DID is not a trust \
|
||||
anchor"
|
||||
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -333,18 +265,6 @@ fn did_check_report(current: &std::result::Result<String, String>, node_did: &st
|
||||
}
|
||||
}
|
||||
|
||||
/// The stdout label for a failed verification verdict. A `parsed_version`
|
||||
/// error means `verify_signature` never ran, so the refusal must print as
|
||||
/// UNSUPPORTED VERSION rather than INVALID, which operators and log
|
||||
/// scrapers would read as a failed Ed25519 check.
|
||||
fn signature_verdict_label(parsed_version: &std::result::Result<u32, String>) -> &'static str {
|
||||
if parsed_version.is_err() {
|
||||
"UNSUPPORTED VERSION"
|
||||
} else {
|
||||
"INVALID"
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -391,65 +311,6 @@ fn verify_signature(
|
||||
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
|
||||
}
|
||||
|
||||
/// Parse the wire-format `version` field of a certificate response.
|
||||
///
|
||||
/// Semantics (Reviewer 2):
|
||||
/// - MISSING key (`None`) → `Ok(1)`. An old server predates the field, so
|
||||
/// the cert is by definition the v1 (pre-versioning) shape; the v1
|
||||
/// verify path is the only path that can possibly match the bytes.
|
||||
/// - PRESENT key must be a JSON integer that fits losslessly in `u32`
|
||||
/// and equals a version this client supports. `null`, a string,
|
||||
/// a float, an overflow integer, or any version other than 1 all
|
||||
/// yield `Err`. Collapsing any of these onto v1 would let a
|
||||
/// well-signed v1 signature pass `--verify` against a response
|
||||
/// that explicitly declared a format this client cannot represent
|
||||
/// — which is exactly the mismatch the version field exists to
|
||||
/// prevent.
|
||||
///
|
||||
/// The supported-version set is hard-coded to `{1}` because PR 3 ships
|
||||
/// only v1 verification; bump this set (and the verdict branch) when
|
||||
/// v2 verification lands.
|
||||
fn parse_cert_version(value: Option<&Value>) -> Result<u32, String> {
|
||||
let v = match value {
|
||||
None => return Ok(1),
|
||||
Some(v) => v,
|
||||
};
|
||||
|
||||
// serde_json::Value::as_u64 rejects strings, floats, booleans,
|
||||
// nulls, arrays, and objects — but it silently truncates floats
|
||||
// that are whole numbers (e.g. 2.0 → 2). We must reject floats
|
||||
// explicitly so a server cannot smuggle a v2 cert through the
|
||||
// float shape.
|
||||
if v.is_f64() {
|
||||
return Err(format!(
|
||||
"version is a JSON number with a fractional part ({v}); expected an integer"
|
||||
));
|
||||
}
|
||||
|
||||
let n = v.as_u64().ok_or_else(|| {
|
||||
// null / string / bool / array / object — anything that is
|
||||
// not a JSON integer.
|
||||
format!("version is {v}; expected a JSON integer")
|
||||
})?;
|
||||
|
||||
// Lossy narrowing: refuse anything that does not fit in u32.
|
||||
// u32::MAX is 4_294_967_295; serde_json only goes up to u64.
|
||||
if n > u32::MAX as u64 {
|
||||
return Err(format!(
|
||||
"version {n} does not fit in u32 (max {})",
|
||||
u32::MAX
|
||||
));
|
||||
}
|
||||
|
||||
let n = n as u32;
|
||||
if n != 1 {
|
||||
return Err(format!(
|
||||
"this client supports cert version 1 only; server returned {n}"
|
||||
));
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
|
||||
if id.len() >= 36 {
|
||||
return Ok(id.to_string());
|
||||
@@ -947,15 +808,7 @@ mod tests {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let got = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(kp.did().as_str().to_string()),
|
||||
)
|
||||
.await;
|
||||
let got = cmd_show("alice/r".to_string(), id, server.url(), None, true, None).await;
|
||||
assert!(got.is_ok(), "valid sig + matching issuer must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
@@ -971,17 +824,10 @@ mod tests {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let err = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(other.did().as_str().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("expected issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
@@ -999,7 +845,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("--expect-node"), "got: {err}");
|
||||
assert!(err.contains("cannot anchor the issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -1040,7 +886,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("does not match"), "got: {err}");
|
||||
assert!(err.contains("signature did not verify"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -1124,373 +970,4 @@ mod tests {
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
}
|
||||
|
||||
/// #26 Split PR 3: a missing `version` field on a cert defaults
|
||||
/// to 1, so an old server's response is forward-compatible with
|
||||
/// a new client. The v1 verify path is taken, and a
|
||||
/// well-signed v1 cert round-trips through `verify_signature`.
|
||||
///
|
||||
/// Reviewer 1 finding: the previous shape only parsed the field
|
||||
/// and never called `verify_signature`, so the test could not
|
||||
/// fail if the verdict branch was wired to the wrong path. This
|
||||
/// version signs and verifies the full round-trip.
|
||||
#[test]
|
||||
fn missing_version_defaults_to_1_and_verifies() {
|
||||
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());
|
||||
|
||||
// Simulate the JSON a v1 server returns: no `version` key.
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "test-id",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig.clone(),
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
// no `version` field
|
||||
});
|
||||
|
||||
// Forward-compat: the parse yields v1 (the only version this
|
||||
// client verifies), and the verdict arm matching Ok(1) feeds
|
||||
// the v1 payload through verify_signature end to end.
|
||||
assert_eq!(parse_cert_version(cert_json.get("version")).unwrap(), 1);
|
||||
let verdict = 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!(
|
||||
verdict.is_ok(),
|
||||
"missing version must take the v1 verify path: {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3: an explicit `version: 1` on a cert is the v1
|
||||
/// verify path. A v1 cert with an explicit version verifies the
|
||||
/// same as a v1 cert without one — round-trip through
|
||||
/// `verify_signature`.
|
||||
#[test]
|
||||
fn explicit_version_1_takes_v1_path() {
|
||||
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());
|
||||
|
||||
// Simulate a v1 cert with an explicit `version: 1` field.
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "test-id",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig.clone(),
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
"version": 1,
|
||||
});
|
||||
|
||||
// The parse arms the Ok(1) verdict branch, which calls
|
||||
// verify_signature on the v1 payload. A signature mismatch
|
||||
// here would mean the verdict branch was wired to the
|
||||
// wrong path or the payload drifted.
|
||||
assert_eq!(parse_cert_version(cert_json.get("version")).unwrap(), 1);
|
||||
let verdict = 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!(
|
||||
verdict.is_ok(),
|
||||
"explicit version: 1 must take the v1 verify path: {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3 + Reviewer 2: parse_cert_version is the
|
||||
/// load-bearing gate that distinguishes a missing-key (legacy
|
||||
/// server) from a present-but-malformed value. The four cases
|
||||
/// below pin every cell of that truth table; collapsing any
|
||||
/// pair would let a server advertise "version 2" (or 99, or
|
||||
/// 4294967297, or "two") while the client runs the v1
|
||||
/// signature path on a different signed payload — a hostile
|
||||
/// misconfiguration that `--verify` must not silently accept.
|
||||
#[test]
|
||||
fn parse_cert_version_truth_table() {
|
||||
// MISSING key → legacy v1 (the v1 path is the only one that
|
||||
// could match pre-versioning bytes).
|
||||
assert_eq!(
|
||||
parse_cert_version(None).unwrap(),
|
||||
1,
|
||||
"a missing version field is the legacy v1 path"
|
||||
);
|
||||
|
||||
// Explicit 1 → v1.
|
||||
assert_eq!(
|
||||
parse_cert_version(Some(&serde_json::json!(1))).unwrap(),
|
||||
1,
|
||||
"explicit version 1 is the v1 path"
|
||||
);
|
||||
|
||||
// Explicit 2 → Err, NOT Ok(1). Reviewer 2: a v1 signature
|
||||
// must not verify against a server that said "version 2".
|
||||
assert!(
|
||||
parse_cert_version(Some(&serde_json::json!(2))).is_err(),
|
||||
"explicit version 2 must not collapse to v1"
|
||||
);
|
||||
|
||||
// u32 overflow (2^32 + 1) → Err, NOT Ok(1). The previous
|
||||
// `.map(|v| v as u32)` silently truncated this to 1.
|
||||
let overflow = serde_json::json!(u64::from(u32::MAX) + 1);
|
||||
assert!(
|
||||
parse_cert_version(Some(&overflow)).is_err(),
|
||||
"a version that overflows u32 must not truncate to 1"
|
||||
);
|
||||
|
||||
// Non-numeric: a string, a float, null, bool — every shape
|
||||
// that is not a JSON integer is rejected. None of these
|
||||
// may collapse to v1.
|
||||
for bad in [
|
||||
serde_json::json!("two"),
|
||||
serde_json::json!(2.0), // serde_json::Value::is_f64 is true for this
|
||||
serde_json::json!(2.5), // obviously fractional
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([2]),
|
||||
serde_json::json!({"v": 2}),
|
||||
] {
|
||||
assert!(
|
||||
parse_cert_version(Some(&bad)).is_err(),
|
||||
"non-integer version {bad} must not collapse to v1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A version refusal must not print as INVALID — no cryptographic
|
||||
/// check ran, so labelling it INVALID would tell operators an Ed25519
|
||||
/// verification failed when the payload shape was the disqualifier.
|
||||
/// Removing the label branch fails this test.
|
||||
#[test]
|
||||
fn signature_verdict_label_distinguishes_refusal_from_bad_signature() {
|
||||
assert_eq!(
|
||||
signature_verdict_label(&Err("version 2 not supported".to_string())),
|
||||
"UNSUPPORTED VERSION"
|
||||
);
|
||||
assert_eq!(signature_verdict_label(&Ok(1)), "INVALID");
|
||||
}
|
||||
|
||||
/// #26 Split PR 3, round 2: a `version: 2` certificate must fail
|
||||
/// `--verify` THROUGH `cmd_show` itself, not through a copy of its
|
||||
/// match. The prior form of this test duplicated the verdict match in
|
||||
/// the test body and aimed at an `Ok(v != 1)` arm the real parser can
|
||||
/// never produce, so gutting `cmd_show`'s rejection kept every test
|
||||
/// green (demonstrated by the reviewer by execution). Driving the
|
||||
/// command end to end is what makes this a guard: the mock serves a
|
||||
/// cert whose SIGNATURE IS VALID for v1 bytes but which declares
|
||||
/// version 2, and the command must exit Err with the version named —
|
||||
/// the v1 verify path must not launder it through.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_verify_rejects_v2_even_with_valid_v1_signature() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
|
||||
// Sign a v1-shaped payload so the ONLY disqualifier is the version.
|
||||
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 cert_json = serde_json::json!({
|
||||
"id": "0123456789abcdef0123456789abcdef0123",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
"version": 2,
|
||||
});
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// 36-char id short-circuits resolve_cert_id; "owner/repo" input
|
||||
// short-circuits resolve_repo — the cert GET is the only required
|
||||
// route. `/` may be probed for the contextual DID line; serving it
|
||||
// keeps the test about the verdict, not about network noise.
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
"/api/v1/repos/o/r/certs/0123456789abcdef0123456789abcdef0123",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_json.to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::json!({ "did": node_did }).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(node_did.clone()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a version-2 cert must fail --verify even with a valid v1 signature");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("version"),
|
||||
"the failure must name the version mismatch, not a generic invalid signature: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Round 2's second demonstrated escape: with no --expect-node, the old
|
||||
/// fallback anchored the issuer to the DID the QUERIED NODE reports at
|
||||
/// `/` — which a forger controls along with the cert, so a wholly forged
|
||||
/// self-signed certificate passed --verify with exit 0. `cmd_show` with
|
||||
/// --verify and no --expect-node must now refuse to claim a trusted
|
||||
/// issuer, even when the served cert's signature is valid and the node's
|
||||
/// self-report matches it.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_verify_without_expect_node_refuses_self_asserted_anchor() {
|
||||
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 cert_json = serde_json::json!({
|
||||
"id": "0123456789abcdef0123456789abcdef0123",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// P3 (reviewer round 2): both mocks are required to be
|
||||
// hit for the test to mean anything. `mockito 1.7`'s
|
||||
// `assert_on_drop` is off by default, so the previous
|
||||
// `_cert` and `_root` bindings would silently let a
|
||||
// regression that skipped the root fetch pass.
|
||||
// `.expect(2)` pins each mock's call count (the cert mock
|
||||
// is hit twice because the test runs `cmd_show` twice) and
|
||||
// `.assert_async()` at the end fires if any are unmet.
|
||||
let cert_mock = server
|
||||
.mock(
|
||||
"GET",
|
||||
"/api/v1/repos/o/r/certs/0123456789abcdef0123456789abcdef0123",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_json.to_string())
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
// The forger's matching self-report — the exact shape
|
||||
// that used to convert into a trust anchor. The guard's
|
||||
// whole point is to make the test FAIL if a future
|
||||
// regression stops calling `/` here: `.expect(2)` +
|
||||
// `.assert_async()` makes that dependency visible.
|
||||
let root_mock = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::json!({ "did": node_did }).to_string())
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("--verify without --expect-node must not mint a trust anchor from the node's self-report");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("--expect-node"),
|
||||
"the refusal must tell the caller how to anchor trust: {msg}"
|
||||
);
|
||||
|
||||
// Positive control: the same cert with the DID explicitly trusted
|
||||
// passes, so the guard is about the anchor, not the signature.
|
||||
cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(node_did),
|
||||
)
|
||||
.await
|
||||
.expect("explicitly anchored --verify of a valid v1 cert must pass");
|
||||
|
||||
// P3 (reviewer round 2): confirm the mocks were actually
|
||||
// hit. Without this, a regression that skipped the cert or
|
||||
// root fetches would still produce a passing test (the
|
||||
// `expect_err` arm succeeds, the positive control succeeds)
|
||||
// and the test would mean nothing.
|
||||
cert_mock.assert_async().await;
|
||||
root_mock.assert_async().await;
|
||||
}
|
||||
}
|
||||
@@ -75,14 +75,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
|
||||
for event in &events {
|
||||
let ts = event["timestamp"].as_str().unwrap_or("?");
|
||||
let date = crate::text::truncate(ts, 10);
|
||||
let date = &ts[..ts.len().min(10)];
|
||||
|
||||
match event["type"].as_str().unwrap_or("") {
|
||||
"commit" => {
|
||||
let sha = event["sha"].as_str().unwrap_or("?");
|
||||
let msg = event["message"].as_str().unwrap_or("?");
|
||||
let first_line = msg.lines().next().unwrap_or(msg);
|
||||
let short_sha = crate::text::truncate(sha, 8);
|
||||
let short_sha = &sha[..sha.len().min(8)];
|
||||
println!(" {date} commit {short_sha} {first_line}");
|
||||
}
|
||||
"pr_merged" => {
|
||||
|
||||
+5
-89
@@ -163,8 +163,8 @@ pub fn setup_partial_clone(
|
||||
|
||||
if withheld_globs.is_empty() {
|
||||
match branch {
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, "--", remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", "--", remote_url, dest_str])?,
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", remote_url, dest_str])?,
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -174,7 +174,6 @@ pub fn setup_partial_clone(
|
||||
"-q",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
"--",
|
||||
remote_url,
|
||||
dest_str,
|
||||
])?;
|
||||
@@ -389,7 +388,7 @@ async fn recover_encrypted_blobs(
|
||||
let plaintext = match open_blob(&envelope, keypair) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
emit_warning(&format!("warning: could not decrypt {oid}: {e}"));
|
||||
eprintln!("warning: could not decrypt {oid}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -407,9 +406,7 @@ async fn recover_encrypted_blobs(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -807,9 +804,7 @@ async fn recover_from_arweave(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -1095,85 +1090,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_plain_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], None).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_with_branch_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let branch_out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
td.path().join("origin").to_str().unwrap(),
|
||||
"branch",
|
||||
"--show-current",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
let branch = String::from_utf8(branch_out.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], Some(&branch)).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
/// #374: every `setup_partial_clone` shape interpolates `remote_url` into a
|
||||
/// `git clone` argv. The `--` delimiter is what forces an option-shaped
|
||||
/// remote to be read as a repository rather than parsed as an option.
|
||||
///
|
||||
/// The assertion is on git's own words — `repository '<value>'`, single-quoted
|
||||
/// — and not merely on the value appearing somewhere in the error. `git_global`
|
||||
/// formats its failure as `git {args:?} failed: {stderr}`, so the injected
|
||||
/// string is already present in the debug-printed argv whether or not the
|
||||
/// delimiter is there; asserting on containment alone would pass with `--`
|
||||
/// deleted. Undelimited, git consumes the value as an option, leaving the
|
||||
/// destination as the only positional, and names *that* instead
|
||||
/// (`repository '<dest>' does not exist`) — so this goes red without the fix.
|
||||
///
|
||||
/// The payload carries no `:` or space: a colon would make git read the
|
||||
/// argument as an ssh-style `host:path` URL and report a hostname, which is a
|
||||
/// different parse.
|
||||
fn assert_option_shaped_remote_is_a_repository(withheld: &[String], branch: Option<&str>) {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("dest");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = setup_partial_clone(&dest, injected, withheld, &[], branch)
|
||||
.expect_err("an option-shaped remote is not a repository and must fail")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(&format!("repository '{injected}'")),
|
||||
"git must name the argument as the repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_clone_never_parses_a_remote_as_a_git_option() {
|
||||
assert_option_shaped_remote_is_a_repository(&[], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// Pins that `--branch <b>` stays ahead of the delimiter: if `--` were moved
|
||||
// before `--branch`, git would read the branch name as a positional instead.
|
||||
assert_option_shaped_remote_is_a_repository(&[], Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// The withheld-globs arm is a separate argv construction
|
||||
// (`--filter=blob:none --no-checkout`) and needs its own proof.
|
||||
assert_option_shaped_remote_is_a_repository(&["/secret/**".to_string()], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_patterns_subtree_and_exact() {
|
||||
assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]);
|
||||
|
||||
+43
-185
@@ -3,7 +3,7 @@
|
||||
//! Checks:
|
||||
//! 1. Identity — ~/.gitlawb/identity.pem exists, DID parseable
|
||||
//! 2. Registration — ~/.gitlawb/ucan.json exists (registered with a node)
|
||||
//! 3. GITLAWB_NODE — env var points at a public node (unset is advisory)
|
||||
//! 3. GITLAWB_NODE — env var is set to a non-localhost URL
|
||||
//! 4. Node — GITLAWB_NODE is reachable (default: https://node.twigpine.com)
|
||||
//! 5. git helper — git-remote-gitlawb is in PATH
|
||||
//! 6. git — git is installed
|
||||
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -73,17 +73,17 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
println!("gl doctor — checking your Twigpine setup");
|
||||
println!();
|
||||
|
||||
// The same resolver every other command uses. `doctor` reporting on
|
||||
// `~/.gitlawb` while `gl register` writes to the parent of `GITLAWB_KEY` would
|
||||
// make the one command whose job is to explain a broken setup the one that
|
||||
// misreports it.
|
||||
let args_dir = args.dir.clone();
|
||||
let dir = crate::identity::gitlawb_dir(args.dir)?;
|
||||
let dir = args.dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
|
||||
let mut checks = Vec::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
// ── 1. Identity ───────────────────────────────────────────────────────
|
||||
let pem_path = crate::identity::key_path_for(args_dir.as_deref())?;
|
||||
let pem_path = dir.join("identity.pem");
|
||||
if pem_path.exists() {
|
||||
match std::fs::read_to_string(&pem_path)
|
||||
.ok()
|
||||
@@ -141,16 +141,32 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
|
||||
checks.push(gitlawb_node_env_check(
|
||||
std::env::var("GITLAWB_NODE").ok().as_deref(),
|
||||
));
|
||||
match std::env::var("GITLAWB_NODE") {
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Ok(v) if is_loopback_url(&v) => {
|
||||
checks.push(Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(v) if !v.is_empty() => {
|
||||
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
|
||||
}
|
||||
_ => {
|
||||
checks.push(Check::fail(
|
||||
"GITLAWB_NODE",
|
||||
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
|
||||
"export GITLAWB_NODE=https://node.twigpine.com",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Node connectivity ──────────────────────────────────────────────
|
||||
// GITLAWB_NODE="" binds args.node to "" through clap, and check 3 groups
|
||||
// empty with unset. The probe must apply the same normalization or it
|
||||
// would dial "" and Fail while the env row only Warns.
|
||||
let node_url = effective_node_url(&args.node);
|
||||
let client = NodeClient::new(node_url, None);
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
match client.get("/").await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let info = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
@@ -159,7 +175,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
let short_did = did.chars().take(30).collect::<String>();
|
||||
checks.push(Check::pass(
|
||||
"node",
|
||||
format!("{} — v{version} ({short_did}…)", node_url),
|
||||
format!("{} — v{version} ({short_did}…)", args.node),
|
||||
));
|
||||
// Capability drift: a node newer than this CLI may require features
|
||||
// (RFC 9421 signing, iCaptcha) the CLI doesn't speak.
|
||||
@@ -178,14 +194,14 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
Ok(resp) => {
|
||||
checks.push(Check::fail(
|
||||
"node",
|
||||
format!("{} returned HTTP {}", node_url, resp.status()),
|
||||
format!("{} returned HTTP {}", args.node, resp.status()),
|
||||
"check GITLAWB_NODE env var or try gl register --node <url>",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(Check::fail(
|
||||
"node",
|
||||
format!("{} unreachable: {e}", node_url),
|
||||
format!("{} unreachable: {e}", args.node),
|
||||
"check your internet connection or set GITLAWB_NODE",
|
||||
));
|
||||
}
|
||||
@@ -269,21 +285,23 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
CheckState::Fail => "✗",
|
||||
};
|
||||
println!(" {icon} {:<24} {}", check.label, check.detail);
|
||||
if matches!(check.state, CheckState::Fail) {
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
let has_failures_now = has_failures(&checks);
|
||||
let has_issues = checks
|
||||
.iter()
|
||||
.any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
|
||||
if !has_issues {
|
||||
println!("Everything looks good. Run `gl quickstart` to create your first repo.");
|
||||
} else {
|
||||
if has_failures_now {
|
||||
println!("Some checks failed. Suggested fixes:");
|
||||
} else {
|
||||
if all_ok {
|
||||
println!("Setup looks good with some warnings:");
|
||||
} else {
|
||||
println!("Some checks failed. Suggested fixes:");
|
||||
}
|
||||
for check in &checks {
|
||||
if matches!(check.state, CheckState::Fail | CheckState::Warn) {
|
||||
@@ -293,75 +311,14 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if has_failures_now {
|
||||
if !all_ok {
|
||||
println!("Run `gl quickstart` for a guided setup.");
|
||||
}
|
||||
}
|
||||
|
||||
// #357: a non-zero exit is the only signal scripting and CI have. Warn-only
|
||||
// rows stay exit 0 (iCaptcha offline, version drift, shell-alias shadowing
|
||||
// are all non-fatal); Fail-class rows (identity, registration, node
|
||||
// reachable, git-remote-gitlawb present, git present) flip to exit 1.
|
||||
// Direct `process::exit` rather than returning Err: the prose above is the
|
||||
// user-facing summary, and an anyhow Error frame would just duplicate it.
|
||||
if has_failures(&checks) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify the `GITLAWB_NODE` env var for `gl doctor`. Extracted so unit
|
||||
/// tests can pin the Warn tier directly (#357): an unset env is advisory, not
|
||||
/// a failure, because `--node` defaults to `https://node.twigpine.com` and the
|
||||
/// CLI works without it. Flipping this back to `Check::fail` must break a
|
||||
/// test, not just change prose.
|
||||
fn gitlawb_node_env_check(env_val: Option<&str>) -> Check {
|
||||
match env_val {
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Some(v) if is_loopback_url(v) => Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
),
|
||||
Some(v) if !v.is_empty() => Check::pass("GITLAWB_NODE", v.to_string()),
|
||||
// `--node` defaults to `https://node.twigpine.com` and the CLI works
|
||||
// fine without the variable, so an unset env is advisory, not a
|
||||
// failure. Warn-class keeps the exit code 0 for a stock working
|
||||
// install; this used to be Fail, which the obvious "return non-zero
|
||||
// on any failure" change would have flipped to exit 1 on every
|
||||
// default-config install (#357).
|
||||
_ => Check::warn(
|
||||
"GITLAWB_NODE",
|
||||
"not set — git-remote-gitlawb will fall back to http://127.0.0.1:7545",
|
||||
format!("export GITLAWB_NODE={PUBLIC_NODE}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL the connectivity probe should dial. `GITLAWB_NODE` set but empty
|
||||
/// (or whitespace-only) binds `args.node` to `""` through clap rather than
|
||||
/// to the `default_value`, and check 3 already groups empty with unset, so
|
||||
/// the probe applies the same normalization: an empty effective URL means
|
||||
/// the public node, same as unset.
|
||||
fn effective_node_url(node: &str) -> &str {
|
||||
if node.trim().is_empty() {
|
||||
PUBLIC_NODE
|
||||
} else {
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one check is Fail-class. Warn-only runs (iCaptcha offline,
|
||||
/// version drift, shell-alias shadowing, GITLAWB_NODE unset) keep exit 0;
|
||||
/// this is the gating predicate for #357's exit-status fix.
|
||||
fn has_failures(checks: &[Check]) -> bool {
|
||||
checks.iter().any(|c| matches!(c.state, CheckState::Fail))
|
||||
}
|
||||
|
||||
/// Check if a binary name exists anywhere on PATH.
|
||||
/// True when the rc file contains a real `unalias` command naming `gl` —
|
||||
/// not a comment, and not a longer word like `unalias global`. Ordering
|
||||
@@ -687,103 +644,4 @@ mod tests {
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("GitHub API returned HTTP 403"));
|
||||
}
|
||||
|
||||
/// #357: an all-Ok run does not flip the exit code to non-zero — a healthy
|
||||
/// install must keep returning 0 so scripts like `gl doctor && gl push`
|
||||
/// keep working.
|
||||
#[test]
|
||||
fn exit_predicate_is_false_for_all_ok() {
|
||||
let checks = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::pass("registration", "ok"),
|
||||
Check::pass("git", "ok"),
|
||||
];
|
||||
assert!(!has_failures(&checks));
|
||||
}
|
||||
|
||||
/// #357: Warn-class rows (iCaptcha offline, version drift, shell-alias
|
||||
/// shadowing, GITLAWB_NODE unset on a default-config install) are NOT
|
||||
/// exit-status failures — they are still printed to the user but the
|
||||
/// process exits 0. This is the rule that the previous "any
|
||||
/// Fail-or-Warn row → exit 1" change would have broken for stock
|
||||
/// installs that simply have not set the env var.
|
||||
#[test]
|
||||
fn exit_predicate_is_false_for_warn_only() {
|
||||
let checks = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::warn("GITLAWB_NODE", "unset", "export GITLAWB_NODE=..."),
|
||||
Check::warn("iCaptcha", "offline", "check connectivity"),
|
||||
Check::warn("version", "drift", "upgrade"),
|
||||
Check::warn("shell alias", "omz git plugin", "unalias gl"),
|
||||
];
|
||||
assert!(!has_failures(&checks));
|
||||
}
|
||||
|
||||
/// #357: a single Fail-class row trips the exit code. The set of Fail
|
||||
/// rows is whatever `run` writes through `Check::fail`: identity missing
|
||||
/// or unparseable, registration missing/malformed, node unreachable,
|
||||
/// git-remote-gitlawb absent, git absent.
|
||||
#[test]
|
||||
fn exit_predicate_is_true_when_a_fail_row_is_present() {
|
||||
let checks = vec![
|
||||
Check::pass("registration", "ok"),
|
||||
Check::fail("identity", "missing", "gl identity new"),
|
||||
Check::pass("git", "ok"),
|
||||
];
|
||||
assert!(has_failures(&checks));
|
||||
|
||||
let mixed_warns = vec![
|
||||
Check::pass("identity", "ok"),
|
||||
Check::warn("GITLAWB_NODE", "unset", "export"),
|
||||
Check::fail("node", "unreachable", "check network"),
|
||||
Check::warn("iCaptcha", "offline", "check"),
|
||||
];
|
||||
assert!(has_failures(&mixed_warns));
|
||||
}
|
||||
|
||||
/// #357: `GITLAWB_NODE` unset must be Warn, not Fail. This pins the
|
||||
/// re-tier so flipping it back to `Check::fail` breaks the suite — the
|
||||
/// previous `has_failures`-only tests built their own `Check::warn` rows
|
||||
/// and could not see the re-tier at all.
|
||||
#[test]
|
||||
fn gitlawb_node_env_unset_is_warn() {
|
||||
let check = gitlawb_node_env_check(None);
|
||||
assert!(matches!(check.state, CheckState::Warn));
|
||||
assert!(check.detail.contains("not set"));
|
||||
let empty = gitlawb_node_env_check(Some(""));
|
||||
assert!(matches!(empty.state, CheckState::Warn));
|
||||
}
|
||||
|
||||
/// `GITLAWB_NODE=""` binds `args.node` to `""` through clap, and check 3
|
||||
/// groups empty with unset. The probe must dial the same URL unset
|
||||
/// would — removing the normalization makes check 4 probe `""` and Fail
|
||||
/// while the env row only Warns, so an empty env install exits 1.
|
||||
#[test]
|
||||
fn effective_node_url_treats_empty_and_blank_as_unset() {
|
||||
assert_eq!(effective_node_url(""), PUBLIC_NODE);
|
||||
assert_eq!(effective_node_url(" "), PUBLIC_NODE);
|
||||
assert_eq!(effective_node_url("\t\n"), PUBLIC_NODE);
|
||||
assert_eq!(
|
||||
effective_node_url("http://127.0.0.1:7545"),
|
||||
"http://127.0.0.1:7545"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlawb_node_env_set_is_pass() {
|
||||
let check = gitlawb_node_env_check(Some("https://node.twigpine.com"));
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("https://node.twigpine.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlawb_node_env_loopback_is_pass() {
|
||||
for url in ["http://127.0.0.1:7545", "http://localhost:7545"] {
|
||||
let check = gitlawb_node_env_check(Some(url));
|
||||
assert!(
|
||||
matches!(check.state, CheckState::Ok),
|
||||
"loopback {url} must be Ok, not Warn/Fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,8 +324,8 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
|
||||
/// 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 {
|
||||
gitlawb_core::sanitize::strip_terminal_controls(s)
|
||||
.chars()
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
+53
-300
@@ -9,8 +9,7 @@ use std::path::{Path, PathBuf};
|
||||
pub enum IdentityCmd {
|
||||
/// Generate a new Ed25519 keypair and DID
|
||||
New {
|
||||
/// Output directory for key files
|
||||
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Output directory for key files (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Overwrite existing keys if present
|
||||
@@ -64,51 +63,16 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the identity directory, honouring an explicit override.
|
||||
/// Public so sibling commands (`gl ucan import`, `gl doctor`) look in the same
|
||||
/// place the identity itself lives.
|
||||
///
|
||||
/// Without an override this is [`gitlawb_core::identity_path::identity_dir`] — the
|
||||
/// parent of `GITLAWB_KEY`, else `~/.gitlawb`. The rules live in `gitlawb-core`
|
||||
/// because `git-remote-gitlawb` needs the identical answer: an operator who moved
|
||||
/// their key (`GITLAWB_KEY=/data/keys/identity.pem`, the shape `.env.example`
|
||||
/// documents) would otherwise have `gl ucan import` write the delegation to
|
||||
/// `~/.gitlawb/delegations` while the helper reads `/data/keys/delegations` and
|
||||
/// finds it empty. The push then goes out with no `X-Ucan` and the delegate is
|
||||
/// refused, with nothing on either side to indicate why.
|
||||
pub fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(d) = override_dir {
|
||||
return Ok(d);
|
||||
}
|
||||
// `home_dir()` is passed as an Option rather than demanded here: an absolute
|
||||
// GITLAWB_KEY resolves without a home directory, and a host that has none is a
|
||||
// normal container shape, not a reason to refuse a correctly-configured key.
|
||||
let home = dirs::home_dir();
|
||||
gitlawb_core::identity_path::identity_dir(home.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))
|
||||
let home = dirs::home_dir().context("could not determine home directory")?;
|
||||
Ok(home.join(".gitlawb"))
|
||||
}
|
||||
|
||||
/// The identity PEM to read or write.
|
||||
///
|
||||
/// With an explicit `--dir` this is `<dir>/identity.pem`, the conventional layout.
|
||||
/// Without one it is [`gitlawb_core::identity_path::identity_key_path`] — the whole
|
||||
/// of `GITLAWB_KEY`, basename included.
|
||||
///
|
||||
/// Taking only the parent and re-appending `identity.pem` was a real divergence,
|
||||
/// not a tidy-up: `GITLAWB_KEY` is documented as a path to a PEM, and
|
||||
/// `git-remote-gitlawb` opens exactly that path. With
|
||||
/// `GITLAWB_KEY=/data/keys/ci-agent.pem`, `gl identity new` wrote
|
||||
/// `/data/keys/identity.pem` while every push loaded `/data/keys/ci-agent.pem`, so
|
||||
/// the two either disagreed on identity or the helper found no key at all — and
|
||||
/// owner enforcement and the delegation proof both key off that identity.
|
||||
pub(crate) fn key_path_for(dir: Option<&Path>) -> Result<PathBuf> {
|
||||
match dir {
|
||||
Some(d) => Ok(d.join(gitlawb_core::identity_path::KEY_FILE_NAME)),
|
||||
None => {
|
||||
let home = dirs::home_dir();
|
||||
gitlawb_core::identity_path::identity_key_path(home.as_deref())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
fn key_path(dir: &Path) -> PathBuf {
|
||||
dir.join("identity.pem")
|
||||
}
|
||||
|
||||
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
@@ -117,14 +81,15 @@ fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
|
||||
/// Load keypair from an optional directory override.
|
||||
/// Used by other modules (register, repo, mcp).
|
||||
///
|
||||
/// Routed through [`gitlawb_dir`] rather than reaching for `~/.gitlawb` directly:
|
||||
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so a second
|
||||
/// resolver here would have every other command read a different file than the one
|
||||
/// just created — `gl ucan delegate` would either fail to find an identity or sign
|
||||
/// with a stale DID that is not the repo owner.
|
||||
pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
|
||||
let path = key_path_for(dir)?;
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
let path = key_path(&base);
|
||||
let pem = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no identity found at {}\nRun `gl identity new` to create one",
|
||||
@@ -134,32 +99,6 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
|
||||
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
|
||||
}
|
||||
|
||||
/// Load keypair from an optional directory override, distinguishing missing default
|
||||
/// identity (clean anonymous) from an explicit directory error or corrupt PEM.
|
||||
pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Keypair>> {
|
||||
if let Some(d) = dir {
|
||||
return load_keypair_from_dir(Some(d)).map(Some);
|
||||
}
|
||||
// Route the default lookup through key_path_for(None), not a hand-built
|
||||
// ~/.gitlawb/identity.pem: that path pinned the conventional basename and
|
||||
// ignored GITLAWB_KEY, so callers on this helper (task list/view, the MCP
|
||||
// identity fallback) signed as a stale DID or went anonymous while every
|
||||
// sibling command read the configured key. A host with no home and no
|
||||
// GITLAWB_KEY still resolves to anonymous rather than refusing.
|
||||
if std::env::var_os("GITLAWB_KEY").is_none() && dirs::home_dir().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = key_path_for(None)?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(pem) => Keypair::from_pem(&pem)
|
||||
.context("failed to load keypair from PEM")
|
||||
.map(Some),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(anyhow::Error::from(e)
|
||||
.context(format!("failed to read identity from {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_new(dir: Option<PathBuf>, force: bool) -> Result<()> {
|
||||
cmd_new_with_reader(dir, force, &mut std::io::stdin().lock()).await
|
||||
}
|
||||
@@ -169,11 +108,8 @@ async fn cmd_new_with_reader(
|
||||
force: bool,
|
||||
reader: &mut impl std::io::BufRead,
|
||||
) -> Result<()> {
|
||||
let path = key_path_for(dir.as_deref())?;
|
||||
let dir = path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let dir = gitlawb_dir(dir)?;
|
||||
let path = key_path(&dir);
|
||||
|
||||
if path.exists() {
|
||||
if force {
|
||||
@@ -195,13 +131,23 @@ async fn cmd_new_with_reader(
|
||||
}
|
||||
}
|
||||
|
||||
crate::secret_file::create_dir(&dir)
|
||||
fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("failed to create directory {}", dir.display()))?;
|
||||
|
||||
let keypair = Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
// Write with restricted permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::write(&path, pem.as_bytes())?;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&path, pem.as_bytes())?;
|
||||
}
|
||||
|
||||
let did = keypair.did();
|
||||
println!("✓ Generated new identity");
|
||||
@@ -237,7 +183,8 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
|
||||
let src = key_path_for(dir.as_deref())?;
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let src = key_path(&base);
|
||||
|
||||
let pem = fs::read_to_string(&src).with_context(|| {
|
||||
format!(
|
||||
@@ -255,7 +202,16 @@ async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
|
||||
.join("identity.pem.bak")
|
||||
});
|
||||
|
||||
crate::secret_file::write(&dest, pem.as_bytes())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
}
|
||||
|
||||
println!("✓ Identity backed up");
|
||||
println!(" DID: {}", keypair.did());
|
||||
@@ -283,11 +239,8 @@ async fn cmd_restore_with_reader(
|
||||
// Verify it's a valid keypair before writing anything
|
||||
let keypair = Keypair::from_pem(&pem).context("backup file is not a valid identity PEM")?;
|
||||
|
||||
let dest = key_path_for(dir.as_deref())?;
|
||||
let base = dest
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let dest = key_path(&base);
|
||||
|
||||
if dest.exists() {
|
||||
if force {
|
||||
@@ -309,10 +262,19 @@ async fn cmd_restore_with_reader(
|
||||
}
|
||||
}
|
||||
|
||||
crate::secret_file::create_dir(&base)
|
||||
fs::create_dir_all(&base)
|
||||
.with_context(|| format!("failed to create directory {}", base.display()))?;
|
||||
|
||||
crate::secret_file::write(&dest, pem.as_bytes())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
}
|
||||
|
||||
println!("✓ Identity restored");
|
||||
println!(" DID: {}", keypair.did());
|
||||
@@ -326,36 +288,6 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn load_optional_keypair_honors_gitlawb_key_basename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = Keypair::generate();
|
||||
let custom = dir.path().join("ci-agent.pem");
|
||||
std::fs::write(&custom, kp.to_pem().unwrap()).unwrap();
|
||||
crate::identity::test_env::with_key(Some(custom.as_os_str()), || {
|
||||
let loaded = load_optional_keypair(None).unwrap();
|
||||
assert_eq!(
|
||||
loaded.map(|k| k.did().to_string()),
|
||||
Some(kp.did().to_string()),
|
||||
"the configured basename must be read, not ~/.gitlawb/identity.pem"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_optional_keypair_none_when_unset_and_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
crate::identity::test_env::with_key(
|
||||
Some(dir.path().join("absent.pem").as_os_str()),
|
||||
|| {
|
||||
assert!(
|
||||
load_optional_keypair(None).unwrap().is_none(),
|
||||
"a configured-but-absent key resolves anonymous, not an error"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_new_creates_pem() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -577,182 +509,3 @@ mod tests {
|
||||
assert_eq!(original_did, dst_kp.did());
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoped `GITLAWB_KEY` for tests, shared crate-wide.
|
||||
///
|
||||
/// The process environment is global and more than one suite in this crate
|
||||
/// depends on it — the resolver's own cases here, and `gl register`'s check that
|
||||
/// the bootstrap token lands beside the key. They take one lock rather than each
|
||||
/// declaring its own, which would not serialise them against each other.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_env {
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Restores the previous value and releases the lock on drop.
|
||||
pub(crate) struct KeyEnv {
|
||||
_guard: MutexGuard<'static, ()>,
|
||||
restore: Option<OsString>,
|
||||
}
|
||||
|
||||
impl Drop for KeyEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.restore.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `GITLAWB_KEY` set, restoring it afterwards.
|
||||
pub(crate) fn with_key<T, V: AsRef<OsStr>>(value: Option<V>, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = set_key(value);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Set `GITLAWB_KEY` (or remove it, for `None`) until the guard drops.
|
||||
pub(crate) fn set_key<V: AsRef<OsStr>>(value: Option<V>) -> KeyEnv {
|
||||
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let restore = std::env::var_os("GITLAWB_KEY");
|
||||
match value {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
KeyEnv {
|
||||
_guard: guard,
|
||||
restore,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gitlawb_dir_tests {
|
||||
use super::{gitlawb_dir, load_keypair_from_dir};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Run `f` with `GITLAWB_KEY` set to `value` (or removed for `None`), restoring
|
||||
/// whatever was there before.
|
||||
fn with_key_env<T>(value: Option<OsString>, f: impl FnOnce() -> T) -> T {
|
||||
let _env = crate::identity::test_env::set_key(value);
|
||||
f()
|
||||
}
|
||||
|
||||
/// An explicit --dir always wins and is never validated against GITLAWB_KEY.
|
||||
#[test]
|
||||
fn explicit_override_wins() {
|
||||
let d = PathBuf::from("/tmp/explicit");
|
||||
assert_eq!(gitlawb_dir(Some(d.clone())).unwrap(), d);
|
||||
}
|
||||
|
||||
/// A relative GITLAWB_KEY must fail loudly. `gl` and `git-remote-gitlawb` run
|
||||
/// from different working directories, so resolving one relatively sends the
|
||||
/// import and the lookup to different stores; a one-component value yields an
|
||||
/// empty parent and puts the store in `./delegations`.
|
||||
#[test]
|
||||
fn relative_key_paths_are_refused() {
|
||||
for raw in ["identity.pem", "keys/identity.pem"] {
|
||||
let result = with_key_env(Some(OsString::from(raw)), || gitlawb_dir(None));
|
||||
assert!(result.is_err(), "{raw} is relative and must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty value is what a shell leaves behind for `FOO=` and for an unset
|
||||
/// variable expanded into a wrapper script. Treated as unset, not as an error.
|
||||
#[test]
|
||||
fn an_empty_key_path_is_treated_as_unset() {
|
||||
let result = with_key_env(Some(OsString::new()), || gitlawb_dir(None));
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
dirs::home_dir().unwrap().join(".gitlawb"),
|
||||
"an empty value selects the default directory"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reason `gitlawb_dir` reads through `var_os`: `env::var` folds a non-UTF-8
|
||||
/// value into the same `Err` as unset, so a bad path would silently resolve to
|
||||
/// `~/.gitlawb` instead of being reported. Byte 0xFF is not valid UTF-8 in any
|
||||
/// position, so this value is unreachable through `env::var`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_utf8_key_path_is_not_mistaken_for_unset() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let raw = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
|
||||
let result = with_key_env(Some(raw), || gitlawb_dir(None));
|
||||
|
||||
let err = result.expect_err("a non-UTF-8 relative path must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("absolute"),
|
||||
"the error must name the real problem, not fall back to the default: {err}"
|
||||
);
|
||||
|
||||
let mut absolute = OsString::from("/data/");
|
||||
absolute.push(OsString::from_vec(vec![0xFF]));
|
||||
absolute.push("/identity.pem");
|
||||
let resolved = with_key_env(Some(absolute.clone()), || gitlawb_dir(None)).unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from(&absolute).parent().unwrap(),
|
||||
"an absolute non-UTF-8 path resolves to its own parent, not to ~/.gitlawb"
|
||||
);
|
||||
}
|
||||
|
||||
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so every
|
||||
/// other command has to read it back from there. `load_keypair_from_dir(None)`
|
||||
/// used to hardcode `~/.gitlawb`, which made `gl ucan delegate` sign with a
|
||||
/// stale DID — or fail outright — for exactly the operators who moved the key.
|
||||
#[test]
|
||||
fn load_keypair_from_dir_honours_the_key_env() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = dir.path().join("identity.pem");
|
||||
let expected = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
|
||||
|
||||
let loaded = with_key_env(Some(key.into_os_string()), || load_keypair_from_dir(None))
|
||||
.expect("the identity beside GITLAWB_KEY must be found");
|
||||
|
||||
assert_eq!(loaded.did(), expected.did());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod key_basename_tests {
|
||||
use super::*;
|
||||
|
||||
/// `GITLAWB_KEY` names a FILE. `gl` used to keep only its parent and re-append
|
||||
/// `identity.pem`, while `git-remote-gitlawb` opened the configured path — so
|
||||
/// with `GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI and the push path loaded
|
||||
/// different files, and owner enforcement and the delegation proof both key off
|
||||
/// whichever identity that was.
|
||||
#[test]
|
||||
fn a_non_default_key_basename_is_honoured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = dir.path().join("ci-agent.pem");
|
||||
let expected = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
|
||||
|
||||
let (resolved, loaded) = crate::identity::test_env::with_key(Some(key.clone()), || {
|
||||
(key_path_for(None).unwrap(), load_keypair_from_dir(None))
|
||||
});
|
||||
|
||||
assert_eq!(resolved, key, "the configured basename must survive");
|
||||
assert_eq!(
|
||||
loaded.expect("the key at GITLAWB_KEY must load").did(),
|
||||
expected.did(),
|
||||
"gl must load the same file the helper opens"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit --dir keeps the conventional layout.
|
||||
#[test]
|
||||
fn an_explicit_dir_still_uses_identity_pem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
key_path_for(Some(dir.path())).unwrap(),
|
||||
dir.path().join("identity.pem")
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-13
@@ -26,7 +26,7 @@ pub struct InitArgs {
|
||||
)]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -105,17 +105,20 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
// Save UCAN if returned
|
||||
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
|
||||
if !ucan.is_empty() {
|
||||
let ucan_dir = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
crate::secret_file::create_dir(&ucan_dir)?;
|
||||
let ucan_dir = args
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
|
||||
std::fs::create_dir_all(&ucan_dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
crate::secret_file::write(
|
||||
&ucan_dir.join("ucan.json"),
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
std::fs::write(
|
||||
ucan_dir.join("ucan.json"),
|
||||
serde_json::to_string_pretty(&record)?,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -222,17 +225,29 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
|
||||
let path = crate::identity::key_path_for(dir)?;
|
||||
let base = path
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
std::fs::create_dir_all(&base)?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = base.join("identity.pem");
|
||||
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
}
|
||||
|
||||
Ok(keypair)
|
||||
}
|
||||
|
||||
+13
-179
@@ -28,7 +28,7 @@ pub enum IpfsCmd {
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
@@ -127,93 +127,23 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
println!("IPFS pins ({count}) on {node}");
|
||||
println!();
|
||||
for pin in &pins {
|
||||
for line in render_pin(pin) {
|
||||
println!("{line}");
|
||||
}
|
||||
let cid = pin["cid"].as_str().unwrap_or("?");
|
||||
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
|
||||
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
|
||||
// Trim pinned_at to date+time without subseconds
|
||||
let ts = if pinned_at.len() >= 19 {
|
||||
&pinned_at[..19]
|
||||
} else {
|
||||
pinned_at
|
||||
};
|
||||
println!(" {cid}");
|
||||
println!(" sha256: {sha}");
|
||||
println!(" pinned: {ts}");
|
||||
println!();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rendered lines for one pins-listing entry.
|
||||
///
|
||||
/// #218 review round 8 P2 — why this is not just `pin["cid"]`: the wire format
|
||||
/// gained a provenance split, and `cid` is now NULLABLE. The node stopped
|
||||
/// aliasing a Pinata provider CID into `cid` (round 3) because `cid` is the
|
||||
/// key `GET /ipfs/{cid}` resolves against, and a Pinata provider CID is not
|
||||
/// resolvable there — advertising one would send clients to a guaranteed 404.
|
||||
/// The truthful representation of a Pinata-only row is therefore `cid: null`
|
||||
/// plus a `pinata_cid`. This function was reading only `cid`, so such a row
|
||||
/// printed as a bare `?` — the CLI showed nothing usable for an object that is
|
||||
/// in fact durably stored, and nothing in the client read `pinata_cid` at all.
|
||||
///
|
||||
/// So: the heading is the node-resolvable CID when there is one, and otherwise
|
||||
/// the Pinata provider CID, labelled as such so nobody feeds it back to this
|
||||
/// node's resolver. A `backends:` line names where the bytes actually are,
|
||||
/// taken from the writer-owned `local_pinned` / `pinata_pinned` booleans rather
|
||||
/// than re-inferred from CID shape (which is what the node's own comment warns
|
||||
/// against). Older nodes send neither flag; their `cid`-only rows fall back to
|
||||
/// CID presence and render exactly as they did before.
|
||||
fn render_pin(pin: &Value) -> Vec<String> {
|
||||
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
|
||||
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
|
||||
// Trim pinned_at to date+time without subseconds; truncate() is
|
||||
// boundary-safe (a raw &s[..19] panics on a split code point).
|
||||
let ts = if pinned_at.len() >= 19 {
|
||||
crate::text::truncate(pinned_at, 19)
|
||||
} else {
|
||||
pinned_at
|
||||
};
|
||||
|
||||
// `cid` and `local_cid` carry the same raw CID; read both so a node that
|
||||
// ships only one of them still renders.
|
||||
let local_cid = pin["cid"]
|
||||
.as_str()
|
||||
.or_else(|| pin["local_cid"].as_str())
|
||||
.filter(|s| !s.is_empty());
|
||||
let pinata_cid = pin["pinata_cid"].as_str().filter(|s| !s.is_empty());
|
||||
|
||||
// Writer-owned flags when present; CID presence is the pre-split fallback.
|
||||
let local_pinned = pin["local_pinned"]
|
||||
.as_bool()
|
||||
.unwrap_or_else(|| local_cid.is_some());
|
||||
let pinata_pinned = pin["pinata_pinned"]
|
||||
.as_bool()
|
||||
.unwrap_or_else(|| pinata_cid.is_some());
|
||||
|
||||
let mut lines = Vec::new();
|
||||
match (local_cid, pinata_cid) {
|
||||
// Node-resolvable CID present: it leads, as it always has.
|
||||
(Some(cid), _) => lines.push(format!(" {cid}")),
|
||||
// Pinata-only: show the provider CID rather than "?", and say plainly
|
||||
// that this node's resolver will not serve it.
|
||||
(None, Some(p)) => lines.push(format!(" {p} (pinata provider CID)")),
|
||||
(None, None) => lines.push(" ?".to_string()),
|
||||
}
|
||||
lines.push(format!(" sha256: {sha}"));
|
||||
lines.push(format!(" pinned: {ts}"));
|
||||
|
||||
// A dual row keeps the provider CID visible too; it differs from the local
|
||||
// CID and is the only key that resolves on Pinata's gateway.
|
||||
if local_cid.is_some() {
|
||||
if let Some(p) = pinata_cid {
|
||||
lines.push(format!(" pinata: {p}"));
|
||||
}
|
||||
}
|
||||
|
||||
let backends = match (local_pinned, pinata_pinned) {
|
||||
(true, true) => "local ipfs, pinata",
|
||||
(true, false) => "local ipfs",
|
||||
(false, true) => "pinata",
|
||||
// Neither flag set: the node filters such rows out, so this is only
|
||||
// reachable from a hand-rolled response. Say so rather than imply
|
||||
// durability the row does not claim.
|
||||
(false, false) => "none recorded",
|
||||
};
|
||||
lines.push(format!(" backends: {backends}"));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Automatic resumes attempted after the initial request when the node reports a
|
||||
/// truncated legacy scan, so at most `MAX_SCAN_RESUMES + 1` node calls per invocation.
|
||||
const MAX_SCAN_RESUMES: usize = 8;
|
||||
@@ -707,102 +637,6 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// #218 review round 8 P2: a Pinata-only row must render its provider CID,
|
||||
/// not a bare `?`. The node stopped aliasing that CID into `cid` because
|
||||
/// `cid` is the key `GET /ipfs/{cid}` resolves on and a provider CID 404s
|
||||
/// there — so `cid` is now legitimately null for such a row, and a client
|
||||
/// that reads only `cid` shows the user nothing for an object that IS
|
||||
/// durably stored. The heading must carry the provider CID and label it,
|
||||
/// and the row must not claim a local pin.
|
||||
#[test]
|
||||
fn render_pin_shows_provider_cid_for_a_pinata_only_row() {
|
||||
let pin: Value = serde_json::from_str(
|
||||
r#"{"sha256_hex":"abc123","cid":null,"local_cid":null,
|
||||
"pinata_cid":"QmProviderOnly","local_pinned":false,
|
||||
"pinata_pinned":true,"pinned_at":"2026-07-02T12:00:00.123456Z"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = render_pin(&pin).join("\n");
|
||||
assert!(
|
||||
out.contains("QmProviderOnly"),
|
||||
"the provider CID must be shown; got:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains(" ?"),
|
||||
"a Pinata-only row must not render as a bare `?`; got:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("backends: pinata"),
|
||||
"the row must say where the bytes are; got:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("pinata provider CID"),
|
||||
"the heading must be labelled so it is not fed back to this node's \
|
||||
resolver, which cannot serve it; got:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dual row leads with the node-resolvable CID (unchanged behavior) and
|
||||
/// additionally surfaces the provider CID, which is a different key and the
|
||||
/// only one Pinata's gateway answers for.
|
||||
#[test]
|
||||
fn render_pin_shows_both_cids_for_a_dual_row() {
|
||||
let pin: Value = serde_json::from_str(
|
||||
r#"{"sha256_hex":"abc123","cid":"bafyLocal","local_cid":"bafyLocal",
|
||||
"pinata_cid":"QmProvider","local_pinned":true,
|
||||
"pinata_pinned":true,"pinned_at":"2026-07-02T12:00:00.123456Z"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = render_pin(&pin).join("\n");
|
||||
assert!(
|
||||
out.starts_with(" bafyLocal"),
|
||||
"the resolver key leads; got:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("pinata: QmProvider"),
|
||||
"the provider CID must still be reachable from the listing; got:\n{out}"
|
||||
);
|
||||
assert!(out.contains("backends: local ipfs, pinata"), "got:\n{out}");
|
||||
}
|
||||
|
||||
/// Backward compatibility: a pre-provenance node sends `cid` alone, with no
|
||||
/// `local_pinned` / `pinata_pinned` flags. That row must render as it always
|
||||
/// did, with the backends line inferred from CID presence.
|
||||
#[test]
|
||||
fn render_pin_handles_a_legacy_cid_only_row() {
|
||||
let pin: Value = serde_json::from_str(
|
||||
r#"{"sha256_hex":"abc123","cid":"bafyone",
|
||||
"pinned_at":"2026-07-02T12:00:00.123456Z"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = render_pin(&pin).join("\n");
|
||||
assert!(out.starts_with(" bafyone"), "got:\n{out}");
|
||||
assert!(out.contains("sha256: abc123"), "got:\n{out}");
|
||||
assert!(
|
||||
out.contains("pinned: 2026-07-02T12:00:00"),
|
||||
"the timestamp is still trimmed to seconds; got:\n{out}"
|
||||
);
|
||||
assert!(out.contains("backends: local ipfs"), "got:\n{out}");
|
||||
}
|
||||
|
||||
/// A node-supplied timestamp whose 19th byte splits a multi-byte code
|
||||
/// point must not panic the CLI — `&s[..19]` panics there; `truncate`
|
||||
/// returns the boundary-safe prefix.
|
||||
#[test]
|
||||
fn render_pin_does_not_panic_on_a_non_boundary_timestamp() {
|
||||
let mut ts = "2026-07-02T12:00:0".to_string();
|
||||
ts.push('é'); // 2-byte char starting at byte 18: byte 19 is its tail
|
||||
assert_eq!(ts.len(), 20);
|
||||
assert!(std::panic::catch_unwind(|| &ts[..19]).is_err());
|
||||
let pin: Value = serde_json::json!({
|
||||
"sha256_hex":"abc123","cid":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4",
|
||||
"local_cid":null,"pinata_cid":null,"local_pinned":true,
|
||||
"pinata_pinned":false,"pinned_at":ts,
|
||||
});
|
||||
let out = render_pin(&pin).join("\n");
|
||||
assert!(out.contains("pinned: 2026-07-02T12:00:0"), "got:\n{out}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_signs_request_and_renders_pins() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
+3
-34
@@ -251,7 +251,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let status = issue["status"].as_str().unwrap_or("?");
|
||||
let created = issue["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.map(|s| &s[..10])
|
||||
.unwrap_or("?");
|
||||
let icon = match status {
|
||||
"open" => "○",
|
||||
@@ -365,13 +365,10 @@ async fn cmd_issue_comments(
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.unwrap_or("?");
|
||||
let cbody = c["body"].as_str().unwrap_or("");
|
||||
let created = c["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
println!(" · {author_short} ({created})");
|
||||
println!(" {cbody}");
|
||||
println!();
|
||||
@@ -442,34 +439,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_survives_malformed_timestamps() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// A short timestamp used to panic on `&s[..10]`; a timestamp whose
|
||||
// byte 10 is mid-char panics even with a len() guard.
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"issues":[{"id":"a","title":"short ts","status":"open","created_at":"2026"},{"id":"b","title":"mb ts","status":"open","created_at":"2026-08-1é5T00:00:00Z"},{"id":"c","title":"empty ts","status":"open","created_at":""}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_create_success() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -26,12 +26,10 @@ mod protect;
|
||||
mod quickstart;
|
||||
mod register;
|
||||
mod repo;
|
||||
mod secret_file;
|
||||
mod star;
|
||||
mod status;
|
||||
mod sync;
|
||||
mod task;
|
||||
mod text;
|
||||
mod ucan_cmd;
|
||||
mod visibility;
|
||||
mod webhook;
|
||||
|
||||
+46
-803
@@ -43,6 +43,7 @@ use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct McpArgs {
|
||||
@@ -286,7 +287,7 @@ fn tool_definitions() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "ucan_show",
|
||||
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
|
||||
"description": "Show the saved bootstrap UCAN token for this agent.",
|
||||
"inputSchema": { "type": "object", "properties": {} }
|
||||
},
|
||||
{
|
||||
@@ -515,8 +516,7 @@ fn tool_definitions() -> Value {
|
||||
"properties": {
|
||||
"status": { "type": "string", "description": "Filter by status: pending, claimed, completed, failed" },
|
||||
"assignee_did": { "type": "string", "description": "Filter by assignee DID" },
|
||||
"limit": { "type": "integer", "minimum": 1, "description": "Total results to return (default: 50). Must be positive; a non-positive limit is rejected rather than answered with an empty list. Values above the node's 200-row page cap are gathered by following continuation tokens.", "default": 50 },
|
||||
"cursor": { "type": "string", "description": "Resume from a previous call's next_cursor. Must be paired with the same status/assignee_did filter that produced it." }
|
||||
"limit": { "type": "integer", "description": "Max results (default: 50)", "default": 50 }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -564,7 +564,7 @@ fn tool_definitions() -> Value {
|
||||
// ── UCAN delegation tools ───────────────────────────────────────────
|
||||
{
|
||||
"name": "ucan_delegate",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["to", "resource", "action"],
|
||||
@@ -572,13 +572,13 @@ fn tool_definitions() -> Value {
|
||||
"to": { "type": "string", "description": "Audience DID — who receives this capability (e.g. did:key:z6Mk...)" },
|
||||
"resource": { "type": "string", "description": "Resource URI (e.g. gitlawb://repos/owner/repo)" },
|
||||
"action": { "type": "string", "description": "Action to grant (e.g. git/push, pr/open, repo/admin)" },
|
||||
"expiry_hours": { "type": "integer", "minimum": 1, "description": "Expiry in hours (default: 720, the same as `gl ucan delegate`). Every link of a push chain must expire, so there is no unbounded option." }
|
||||
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_verify",
|
||||
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
|
||||
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
@@ -587,17 +587,6 @@ fn tool_definitions() -> Value {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_import",
|
||||
"description": "Store a delegation received from a repository owner where `git push` (git-remote-gitlawb) will present it. Same checks as `gl ucan import`: addressed to this identity, owner-rooted, every link bounded and naming one repository. Requires agent identity.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
"properties": {
|
||||
"token": { "type": "string", "description": "UCAN token JSON, as returned by ucan_delegate" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Issue tools ─────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -651,14 +640,7 @@ async fn call_tool(
|
||||
node: &str,
|
||||
dir: Option<&std::path::Path>,
|
||||
) -> Result<String> {
|
||||
// An explicit dir with no identity file is a clean anonymous caller
|
||||
// (main's unsigned `webhook_list` path lets the node answer the denial);
|
||||
// a present but unloadable identity is an error, never a silent
|
||||
// downgrade to anonymous (this PR's `load_optional_keypair` contract).
|
||||
let keypair = match dir {
|
||||
Some(d) if !d.join("identity.pem").exists() => None,
|
||||
_ => crate::identity::load_optional_keypair(dir)?,
|
||||
};
|
||||
let keypair = load_keypair_from_dir(dir).ok();
|
||||
let client = NodeClient::new(node, keypair.clone());
|
||||
|
||||
match name {
|
||||
@@ -797,45 +779,15 @@ async fn call_tool(
|
||||
]))?),
|
||||
|
||||
"ucan_show" => {
|
||||
// The directory the server was started with, like every sibling tool.
|
||||
// Reading the default here while the rest honour `--dir` splits one MCP
|
||||
// session across two identity directories.
|
||||
let ucan_path = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?
|
||||
.join("ucan.json");
|
||||
if !ucan_path.exists() {
|
||||
return Ok(
|
||||
"No UCAN saved. Run `gl register` or use the agent_register tool.".to_string(),
|
||||
);
|
||||
let ucan_path = dirs::home_dir()
|
||||
.context("no home dir")?
|
||||
.join(".gitlawb/ucan.json");
|
||||
if ucan_path.exists() {
|
||||
let content = std::fs::read_to_string(ucan_path)?;
|
||||
Ok(content)
|
||||
} else {
|
||||
Ok("No UCAN saved. Run `gl register` or use the agent_register tool.".to_string())
|
||||
}
|
||||
let content = std::fs::read_to_string(&ucan_path)?;
|
||||
// Decoded fields, matching what `gl ucan show` reports — the two are the
|
||||
// same question asked through different surfaces and should not answer
|
||||
// it in different shapes.
|
||||
//
|
||||
// Returning the file verbatim also handed the caller the bootstrap token
|
||||
// itself. That is the credential the agent presents, and an MCP response
|
||||
// travels further than a terminal: into a model's context, transcripts,
|
||||
// and logs. The fields below are what a caller actually needs to know
|
||||
// whether it is registered and until when.
|
||||
let ucan = crate::ucan_cmd::decode_saved_ucan(&content).with_context(|| {
|
||||
format!("could not read the saved UCAN at {}", ucan_path.display())
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"issuer": ucan.payload.iss.to_string(),
|
||||
"audience": ucan.payload.aud.to_string(),
|
||||
"version": ucan.payload.ucan,
|
||||
"capabilities": ucan.payload.att.iter()
|
||||
.map(|c| json!({ "with": c.with, "can": c.can }))
|
||||
.collect::<Vec<_>>(),
|
||||
"expires": ucan.payload.exp.map(|e| {
|
||||
chrono::DateTime::from_timestamp(e, 0)
|
||||
.map(|d| d.to_rfc3339())
|
||||
.unwrap_or_else(|| e.to_string())
|
||||
}),
|
||||
"expired": ucan.is_expired(),
|
||||
"signature_valid": ucan.verify_signature().is_ok(),
|
||||
"path": ucan_path.display().to_string(),
|
||||
}))?)
|
||||
}
|
||||
|
||||
"did_resolve" => {
|
||||
@@ -1162,31 +1114,16 @@ async fn call_tool(
|
||||
|
||||
// ── Task tools ────────────────────────────────────────────────────
|
||||
"task_list" => {
|
||||
// Follows the node's opaque `next_cursor` so a limit above the
|
||||
// 200-row server page cap returns what was asked for instead of a
|
||||
// silently truncated page, and reports an explicit incomplete
|
||||
// result when a guard or the node's scan ceiling stops it (#327).
|
||||
let limit = match args.get("limit") {
|
||||
None | Some(Value::Null) => 50,
|
||||
Some(val) => val.as_i64().context("invalid limit: expected an integer")?,
|
||||
};
|
||||
let cursor = match args.get("cursor") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(val) => Some(val.as_str().context("invalid cursor: expected a string")?),
|
||||
};
|
||||
let result = crate::task::fetch_tasks(
|
||||
&client,
|
||||
args.get("status").and_then(|v| v.as_str()),
|
||||
args.get("assignee_did").and_then(|v| v.as_str()),
|
||||
limit,
|
||||
cursor,
|
||||
)
|
||||
.await?;
|
||||
let mut out = result.to_json();
|
||||
if let Some(warning) = result.truncation_warning() {
|
||||
out["warning"] = Value::String(warning);
|
||||
let limit = args["limit"].as_i64().unwrap_or(50);
|
||||
let mut path = format!("/api/v1/tasks?limit={limit}");
|
||||
if let Some(s) = args.get("status").and_then(|v| v.as_str()) {
|
||||
path.push_str(&format!("&status={}", urlencoding::encode(s)));
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(&out)?)
|
||||
if let Some(a) = args.get("assignee_did").and_then(|v| v.as_str()) {
|
||||
path.push_str(&format!("&assignee_did={}", urlencoding::encode(a)));
|
||||
}
|
||||
let resp = crate::http::read_json(client.get(&path).await?, "list tasks").await?;
|
||||
Ok(serde_json::to_string_pretty(&resp)?)
|
||||
}
|
||||
|
||||
"task_create" => {
|
||||
@@ -1248,39 +1185,14 @@ async fn call_tool(
|
||||
let resource = args["resource"].as_str().context("missing 'resource'")?;
|
||||
let action = args["action"].as_str().context("missing 'action'")?;
|
||||
|
||||
// The same refusal `gl ucan delegate` applies. A push-class wildcard cannot
|
||||
// be honoured anywhere downstream — import refuses it, the helper refuses
|
||||
// it, the node refuses it — so issuing one only manufactures a token that
|
||||
// fails later with less context than this.
|
||||
if gitlawb_core::ucan::push::is_push_wildcard(resource, action) {
|
||||
anyhow::bail!(
|
||||
"a wildcard resource cannot carry a push capability: a delegation is \n scoped to the repositories it names when issued, and `*` cannot say \n which those were. Use resource gitlawb://repos/<owner>/<repo>."
|
||||
);
|
||||
}
|
||||
|
||||
let audience: gitlawb_core::did::Did = to_str
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// The CLI defaults to `DEFAULT_DELEGATION_EXPIRY_HOURS` and has no way
|
||||
// to issue an unbounded token. An omitted field here used to mean "no
|
||||
// expiry", which for a push capability is dead on arrival: import
|
||||
// refuses it and so does the node (`chain_lifetime_is_bounded`). Same
|
||||
// default, same rule.
|
||||
let hours = match args.get("expiry_hours") {
|
||||
None | Some(Value::Null) => crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64,
|
||||
Some(v) => v
|
||||
.as_i64()
|
||||
.context("invalid expiry_hours: expected an integer")?,
|
||||
};
|
||||
if hours < 1 {
|
||||
anyhow::bail!("expiry_hours must be at least 1: a token that has already expired grants nothing");
|
||||
}
|
||||
anyhow::ensure!(
|
||||
hours <= 900_000_000,
|
||||
"expiry_hours is out of range ({hours}): a DateTime cannot represent it"
|
||||
);
|
||||
let exp = Some(chrono::Utc::now() + chrono::Duration::hours(hours));
|
||||
let exp = args
|
||||
.get("expiry_hours")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
|
||||
|
||||
let ucan = gitlawb_core::ucan::Ucan::issue(
|
||||
&kp,
|
||||
@@ -1303,26 +1215,23 @@ async fn call_tool(
|
||||
let token = args["token"].as_str().context("missing 'token'")?;
|
||||
let ucan =
|
||||
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
|
||||
// The same report `gl ucan verify` prints, so the two cannot disagree
|
||||
// about what "valid" means. In particular the proof chain is walked:
|
||||
// a leaf with a good signature on a broken chain used to report valid
|
||||
// here and then fail at import and at the node.
|
||||
let report = crate::ucan_cmd::VerifyReport::of(&ucan);
|
||||
Ok(serde_json::to_string_pretty(&report.to_json())?)
|
||||
}
|
||||
let sig_valid = ucan.verify_signature().is_ok();
|
||||
let expired = ucan.is_expired();
|
||||
let caps: Vec<Value> = ucan
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.map(|c| json!({ "with": c.with, "can": c.can }))
|
||||
.collect();
|
||||
|
||||
"ucan_import" => {
|
||||
let token = args["token"].as_str().context("missing 'token'")?;
|
||||
// `gl ucan import` with a JSON answer: one set of checks, one store, so
|
||||
// an MCP-only agent can complete the delegated-push workflow without
|
||||
// a shell and without a second copy of the rules.
|
||||
let imported = crate::ucan_cmd::import_delegation(token, dir).await?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"owner": imported.owner,
|
||||
"repo": imported.repo,
|
||||
"root_issuer": imported.root,
|
||||
"expires": imported.expires,
|
||||
"path": imported.path.display().to_string(),
|
||||
"valid": sig_valid && !expired,
|
||||
"signature_valid": sig_valid,
|
||||
"expired": expired,
|
||||
"issuer": ucan.payload.iss.to_string(),
|
||||
"audience": ucan.payload.aud.to_string(),
|
||||
"capabilities": caps,
|
||||
"expires": ucan.payload.exp,
|
||||
}))?)
|
||||
}
|
||||
|
||||
@@ -1791,7 +1700,6 @@ mod tests {
|
||||
.collect();
|
||||
assert!(names.contains(&"ucan_delegate"));
|
||||
assert!(names.contains(&"ucan_verify"));
|
||||
assert!(names.contains(&"ucan_import"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1818,7 +1726,7 @@ mod tests {
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1832,275 +1740,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["tasks"][0]["id"], "t1");
|
||||
assert_eq!(parsed["complete"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_uses_loaded_identity() {
|
||||
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/tasks\?".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#"{"tasks":[{"id":"t1"}],"has_more":false,"incomplete":false,"next_cursor":null}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({}), &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["tasks"][0]["id"], "t1");
|
||||
assert_eq!(parsed["complete"], json!(true));
|
||||
}
|
||||
|
||||
/// #327 review: MCP `task_list` issued one request and exposed no
|
||||
/// continuation, so a limit above the node's 200-row page cap returned a
|
||||
/// silently truncated result the model had no way to detect. It now
|
||||
/// follows cursors and, when a guard stops it, says so in the payload.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_follows_cursors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let first = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/tasks\?limit=200$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"tasks":[{"id":"t1"}],"has_more":true,"incomplete":false,"next_cursor":"c1"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let second = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"cursor=c1".to_string()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[{"id":"t2"}],"has_more":false,"next_cursor":null}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 500}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
first.assert_async().await;
|
||||
second.assert_async().await;
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["count"], 2);
|
||||
assert_eq!(parsed["tasks"][1]["id"], "t2");
|
||||
assert_eq!(parsed["complete"], json!(true));
|
||||
assert!(parsed.get("warning").is_none());
|
||||
}
|
||||
|
||||
/// A truncated MCP result must carry an explicit warning and a resume
|
||||
/// cursor, so the model cannot read it as the whole answer.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_flags_incomplete_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"tasks":[{"id":"t1"}],"has_more":true,"incomplete":true,"next_cursor":"resume-me"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 1}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["complete"], json!(false));
|
||||
assert_eq!(parsed["incomplete"], json!(true));
|
||||
assert_eq!(parsed["next_cursor"], "resume-me");
|
||||
assert!(
|
||||
parsed["warning"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("result incomplete"),
|
||||
"{parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A legacy response without pagination metadata is reported as incomplete via MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_legacy_response_is_incomplete() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[{"id":"t1"}],"count":1}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 50}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["complete"], json!(false));
|
||||
assert_eq!(parsed["incomplete"], json!(true));
|
||||
assert!(parsed["next_cursor"].is_null());
|
||||
assert!(
|
||||
parsed["warning"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("node does not support pagination metadata"),
|
||||
"{parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A legacy response with limit > 200 stops after 1 page and does not claim completeness via MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_legacy_limit_above_page_cap() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ids: Vec<String> = (0..200).map(|i| format!("t{i}")).collect();
|
||||
let tasks_json: Vec<Value> = ids.iter().map(|id| json!({ "id": id })).collect();
|
||||
let body = json!({ "tasks": tasks_json, "count": 200 }).to_string();
|
||||
|
||||
let m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/tasks\?limit=200$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool("task_list", json!({"limit": 500}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
m.assert_async().await;
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["count"], 200);
|
||||
assert_eq!(parsed["complete"], json!(false));
|
||||
assert_eq!(parsed["incomplete"], json!(true));
|
||||
assert!(parsed["next_cursor"].is_null());
|
||||
assert!(
|
||||
parsed["warning"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("node does not support pagination metadata"),
|
||||
"{parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor the model passes back must reach the node.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_forwards_cursor_argument() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"cursor=given-cursor".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
call_tool(
|
||||
"task_list",
|
||||
json!({"cursor": "given-cursor"}),
|
||||
&server.url(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_returns_http_errors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"failed"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool("task_list", json!({}), &server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("500"));
|
||||
}
|
||||
|
||||
/// Malformed server responses fail visibly in MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_malformed_response_errors() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":"not-an-array"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool("task_list", json!({}), &server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("malformed") || err.to_string().contains("invalid JSON"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #327 review: a model that sends `limit: 0` used to get an empty list
|
||||
/// marked complete, which reads as "this node has no tasks". The shared
|
||||
/// helper rejects it, so the model sees an invalid argument instead.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_rejects_non_positive_limit() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool("task_list", json!({"limit": 0}), &server.url(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("limit must be a positive"),
|
||||
"{err}"
|
||||
);
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2227,38 +1866,6 @@ mod tests {
|
||||
assert_eq!(parsed["capability"]["can"], "git/push");
|
||||
}
|
||||
|
||||
/// The same refusal the CLI applies. `test_ucan_delegate_via_mcp` uses a named
|
||||
/// resource, so it could never catch the MCP arm issuing a wildcard the CLI
|
||||
/// refuses.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_delegate_via_mcp_refuses_a_push_class_wildcard() {
|
||||
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 audience = gitlawb_core::identity::Keypair::generate();
|
||||
|
||||
for action in ["git/push", "*", "repo/admin"] {
|
||||
let err = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "*",
|
||||
"action": action,
|
||||
"expiry_hours": 24,
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("MCP must refuse a push-class wildcard like the CLI does");
|
||||
assert!(err.to_string().contains("wildcard"), "{action}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ucan_verify_via_mcp() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
@@ -2287,204 +1894,6 @@ mod tests {
|
||||
assert_eq!(parsed["valid"], true);
|
||||
assert_eq!(parsed["signature_valid"], true);
|
||||
assert_eq!(parsed["expired"], false);
|
||||
assert_eq!(parsed["chain_valid"], true);
|
||||
assert_eq!(parsed["root_issuer"], kp.did().to_string());
|
||||
}
|
||||
|
||||
/// The round-ten P2: `ucan_verify` answered from the leaf alone. A leaf with a
|
||||
/// good signature sitting on a proof that never named its issuer reported
|
||||
/// `valid: true` here, and was then refused by import and by the node.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_verify_via_mcp_walks_the_proof_chain() {
|
||||
let alice = gitlawb_core::identity::Keypair::generate();
|
||||
let bob = gitlawb_core::identity::Keypair::generate();
|
||||
let eve = gitlawb_core::identity::Keypair::generate();
|
||||
let node = gitlawb_core::identity::Keypair::generate();
|
||||
let cap = || {
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
"gitlawb://repos/alice/r",
|
||||
"git/push",
|
||||
)]
|
||||
};
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
// Alice grants Bob; Eve presents Alice's grant as if it were hers. Eve's
|
||||
// own signature is fine — the break is in the chain.
|
||||
let root = gitlawb_core::ucan::Ucan::issue(&alice, bob.did(), cap(), Some(hour)).unwrap();
|
||||
let forged =
|
||||
gitlawb_core::ucan::Ucan::delegate(&eve, node.did(), cap(), Some(hour), &root).unwrap();
|
||||
assert!(
|
||||
forged.verify_signature().is_ok(),
|
||||
"fixture: the leaf signature is valid"
|
||||
);
|
||||
|
||||
let result = call_tool(
|
||||
"ucan_verify",
|
||||
json!({"token": forged.encode().unwrap()}),
|
||||
"http://localhost",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["signature_valid"], true);
|
||||
assert_eq!(parsed["expired"], false);
|
||||
assert_eq!(parsed["chain_valid"], false);
|
||||
assert_eq!(
|
||||
parsed["valid"], false,
|
||||
"a broken proof chain must not report valid: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["chain_error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("proof chain broken"),
|
||||
"the reason must be reported: {parsed}"
|
||||
);
|
||||
assert!(parsed["root_issuer"].is_null());
|
||||
}
|
||||
|
||||
/// The workflow an MCP-only agent could not finish: it could be issued a
|
||||
/// delegation and verify it, but not store it where `git-remote-gitlawb`
|
||||
/// looks. `ucan_import` is `gl ucan import` behind a tool — same checks, same
|
||||
/// file — so a token the CLI would store lands in the same place, and one the
|
||||
/// CLI would refuse is refused with the same reason.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_import_via_mcp_stores_where_the_helper_looks() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let me = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
me.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let owner = gitlawb_core::identity::Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let token = gitlawb_core::ucan::Ucan::issue(
|
||||
&owner,
|
||||
me.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/ci-repo"),
|
||||
"git/push",
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
let result = call_tool(
|
||||
"ucan_import",
|
||||
json!({"token": token}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect("a delegation addressed to this identity must import");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["owner"], owner_did);
|
||||
assert_eq!(parsed["repo"], "ci-repo");
|
||||
assert_eq!(parsed["root_issuer"], owner_did);
|
||||
assert_eq!(parsed["expires"], hour.timestamp());
|
||||
|
||||
let stored = crate::ucan_cmd::delegation_path(dir.path(), &owner_did, "ci-repo");
|
||||
assert_eq!(parsed["path"], stored.display().to_string());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&stored).unwrap(),
|
||||
token,
|
||||
"the token must be stored byte-for-byte where the helper reads it"
|
||||
);
|
||||
|
||||
// Same refusals as the CLI: a token addressed to someone else is unusable
|
||||
// here however well-formed it is.
|
||||
let stranger = gitlawb_core::identity::Keypair::generate();
|
||||
let misaddressed = gitlawb_core::ucan::Ucan::issue(
|
||||
&owner,
|
||||
stranger.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/ci-repo"),
|
||||
"git/push",
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
let err = call_tool(
|
||||
"ucan_import",
|
||||
json!({"token": misaddressed}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a token addressed to another identity must be refused");
|
||||
assert!(err.to_string().contains("addressed to"), "{err}");
|
||||
}
|
||||
|
||||
/// The round-ten P2: an omitted `expiry_hours` issued an unbounded token, which
|
||||
/// import and the node both refuse for push. The default is now the CLI's.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_delegate_via_mcp_defaults_to_the_cli_expiry() {
|
||||
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 audience = gitlawb_core::identity::Keypair::generate();
|
||||
|
||||
let before = chrono::Utc::now();
|
||||
let result = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "gitlawb://repos/test/repo",
|
||||
"action": "git/push",
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
let expires = parsed["expires"]
|
||||
.as_i64()
|
||||
.expect("an omitted expiry_hours must still produce an expiring token");
|
||||
let expected = (before
|
||||
+ chrono::Duration::hours(crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64))
|
||||
.timestamp();
|
||||
assert!(
|
||||
(expires - expected).abs() <= 60,
|
||||
"expiry must default to the CLI's {} hours, got {expires} vs {expected}",
|
||||
crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS
|
||||
);
|
||||
let token = gitlawb_core::ucan::Ucan::decode(parsed["token"].as_str().unwrap()).unwrap();
|
||||
assert!(
|
||||
token.chain_lifetime_is_bounded(),
|
||||
"the token must satisfy the rule import and the node apply"
|
||||
);
|
||||
|
||||
// A token that has already expired grants nothing, so the request is a
|
||||
// mistake to report, not a token to mint.
|
||||
for bad in [json!(0), json!(-5)] {
|
||||
let err = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "gitlawb://repos/test/repo",
|
||||
"action": "git/push",
|
||||
"expiry_hours": bad,
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a non-positive expiry must be refused");
|
||||
assert!(err.to_string().contains("expiry_hours"), "{bad}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2799,177 +2208,11 @@ mod tests {
|
||||
assert!(err.to_string().contains("no identity found"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_invalid_limit_rejected() {
|
||||
let 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 err = call_tool(
|
||||
"task_list",
|
||||
json!({"limit": "50"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("string limit must be rejected");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid limit: expected an integer"),
|
||||
"got: {err}"
|
||||
);
|
||||
|
||||
let err_float = call_tool(
|
||||
"task_list",
|
||||
json!({"limit": 50.5}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("float limit must be rejected");
|
||||
assert!(
|
||||
err_float
|
||||
.to_string()
|
||||
.contains("invalid limit: expected an integer"),
|
||||
"got: {err_float}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_invalid_cursor_rejected() {
|
||||
let server = mockito::Server::new_async().await;
|
||||
for invalid in [json!(123), json!({}), json!([]), json!(true)] {
|
||||
let err = call_tool("task_list", json!({"cursor": invalid}), &server.url(), None)
|
||||
.await
|
||||
.expect_err("non-string cursor must be rejected");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid cursor: expected a string"),
|
||||
"for {invalid}: got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_list_null_or_absent_cursor_accepted() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tasks":[],"has_more":false,"incomplete":false,"next_cursor":null}"#)
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
call_tool("task_list", json!({}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
call_tool("task_list", json!({"cursor": null}), &server.url(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_count_matches_the_shipped_list() {
|
||||
fn test_tool_count_is_42() {
|
||||
let tools = tool_definitions();
|
||||
let count = tools.as_array().unwrap().len();
|
||||
// 41 with `ucan_import`. Bump this when a tool is added or removed; the
|
||||
// point is that no tool is added without someone noticing.
|
||||
assert_eq!(count, 41, "expected 41 tools, got {count}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ucan_show_tests {
|
||||
use super::*;
|
||||
|
||||
/// The MCP tool and `gl ucan show` answer the same question and must answer it
|
||||
/// in the same shape — decoded fields, not the file. Returning the file verbatim
|
||||
/// also handed back the bootstrap token, which an MCP response carries much
|
||||
/// further than a terminal does.
|
||||
#[tokio::test]
|
||||
async fn ucan_show_returns_decoded_fields_and_withholds_the_token() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let issuer = gitlawb_core::identity::Keypair::generate();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
let token = gitlawb_core::ucan::Ucan::issue(
|
||||
&issuer,
|
||||
audience.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
"gitlawb://repos/z6MkAbc/myrepo",
|
||||
gitlawb_core::ucan::caps::GIT_PUSH,
|
||||
)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
// The envelope shape every writer produces.
|
||||
std::fs::write(
|
||||
dir.path().join("ucan.json"),
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"ucan": token,
|
||||
"node": "https://node.gitlawb.com",
|
||||
"did": issuer.did().to_string(),
|
||||
"saved_at": "2026-08-18T00:00:00Z",
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
|
||||
.await
|
||||
.expect("ucan_show must read the envelope every writer produces");
|
||||
let v: serde_json::Value = serde_json::from_str(&out).expect("must be JSON");
|
||||
|
||||
assert_eq!(v["issuer"], issuer.did().to_string());
|
||||
assert_eq!(v["audience"], audience.did().to_string());
|
||||
assert_eq!(v["capabilities"][0]["can"], "git/push");
|
||||
assert_eq!(v["expired"], false);
|
||||
assert_eq!(v["signature_valid"], true);
|
||||
|
||||
// NOT `!out.contains(&token)`: the token is itself JSON, so embedding it in
|
||||
// a pretty-printed response escapes every quote and that assertion passes
|
||||
// whether or not the token leaked. The signature is a bare base64 string that
|
||||
// survives escaping unchanged, so it is the substring that actually proves
|
||||
// absence.
|
||||
let sig = serde_json::from_str::<serde_json::Value>(&token).unwrap()["s"]
|
||||
.as_str()
|
||||
.expect("a UCAN carries its signature in `s`")
|
||||
.to_string();
|
||||
assert!(!sig.is_empty());
|
||||
assert!(
|
||||
!out.contains(&sig),
|
||||
"the bootstrap token must not be returned to an MCP caller"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(&out)
|
||||
.unwrap()
|
||||
.get("ucan")
|
||||
.is_none(),
|
||||
"no field may carry the token itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unregistered agent gets a usable message, not a decode error.
|
||||
#[tokio::test]
|
||||
async fn ucan_show_reports_no_saved_ucan() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.contains("No UCAN saved"), "got: {out}");
|
||||
assert_eq!(count, 40, "expected 40 tools, got {count}");
|
||||
}
|
||||
|
||||
// ── Gated read arms surface node denials, not fabricated results (#123) ──
|
||||
|
||||
+3
-56
@@ -41,17 +41,6 @@ pub struct MirrorArgs {
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Argv for the mirror clone, with `--` immediately before the first positional
|
||||
/// so an option-shaped `source` cannot be parsed as a git option (#374).
|
||||
///
|
||||
/// This is a separate function purely so the delimiter's placement can be driven
|
||||
/// through real git by a test. The production call keeps `.status()` rather than
|
||||
/// `.output()`: a mirror clone of a large repo streams git's progress to the
|
||||
/// user's terminal, and capturing it to assert on stderr here would swallow that.
|
||||
fn mirror_clone_args(source: &str) -> [&str; 4] {
|
||||
["clone", "--mirror", "--", source]
|
||||
}
|
||||
|
||||
pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
let source = args.source.trim_end_matches('/').to_string();
|
||||
|
||||
@@ -142,11 +131,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
println!();
|
||||
println!("✓ Mirror complete: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
// Only print View: when the node advertises a web_url — self-hosted nodes
|
||||
// without a web front-end would otherwise produce a 404 link (#370).
|
||||
if let Some(web_url) = crate::repo::fetch_node_web_url(&args.node).await {
|
||||
println!(" View: {web_url}/{owner_short}/{name}");
|
||||
}
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -160,7 +145,7 @@ fn clone_mirror(source: &str, dest: &std::path::Path) -> Result<()> {
|
||||
// bounded-runner rule governs node request handlers. Same spawn as before,
|
||||
// only relocated into this helper.
|
||||
let status = Command::new("git")
|
||||
.args(mirror_clone_args(source))
|
||||
.args(["clone", "--mirror", source])
|
||||
.arg(dest)
|
||||
.status()
|
||||
.context("failed to run git clone — is git installed?")?;
|
||||
@@ -201,43 +186,6 @@ pub fn extract_repo_name(url: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #374: `gl mirror` interpolates the user-supplied source into a
|
||||
/// `git clone --mirror` argv. `--` must sit immediately before the first
|
||||
/// positional so an option-shaped source is read as a repository.
|
||||
///
|
||||
/// This drives the production argv through real git rather than asserting on
|
||||
/// the array's contents, so it fails on a delimiter that is missing *or*
|
||||
/// misplaced. It cannot go through `run()`: that loads an identity keypair and
|
||||
/// contacts a node before reaching the clone, and its bail message interpolates
|
||||
/// `{source}` unconditionally, so an assertion there would pass with `--` gone.
|
||||
///
|
||||
/// Asserting on git's single-quoted `repository '<value>'` is what makes this
|
||||
/// red without the delimiter — undelimited, git consumes the value as an option
|
||||
/// and names the destination as the repository instead.
|
||||
#[test]
|
||||
fn mirror_clone_never_parses_the_source_as_a_git_option() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let out = Command::new("git")
|
||||
.env("LC_ALL", "C")
|
||||
.args(mirror_clone_args(injected))
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an option-shaped source is not a repository and must fail the clone"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("repository '{injected}'")),
|
||||
"git must name the source as the repository, not consume it as an option: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_url() {
|
||||
assert_eq!(
|
||||
@@ -382,8 +330,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// The git subprocess argv is pinned separately by
|
||||
// `mirror_clone_never_parses_the_source_as_a_git_option`; this covers the
|
||||
// We can't easily test the git subprocess calls, but we can test the
|
||||
// API error path by calling the create step directly via NodeClient.
|
||||
let kp2 = gitlawb_core::identity::Keypair::generate();
|
||||
let client = NodeClient::new(server.url(), Some(kp2));
|
||||
|
||||
+10
-2
@@ -168,8 +168,16 @@ pub async fn run(args: NameArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
|
||||
dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
})
|
||||
}
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
@@ -182,7 +190,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
}
|
||||
|
||||
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
@@ -26,7 +26,7 @@ pub enum NodeCmd {
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
@@ -413,7 +413,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let ref_name = ev["ref"].as_str().unwrap_or("?");
|
||||
let ts = ev["timestamp"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.map(|s| &s[..10.min(s.len())])
|
||||
.unwrap_or("?");
|
||||
println!(" {ts} {repo} {ref_name}");
|
||||
}
|
||||
|
||||
@@ -328,10 +328,12 @@ pub async fn cmd_unstake(
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
// The shared resolver, not a local `~/.gitlawb`: `gl identity new` writes the
|
||||
// key wherever `GITLAWB_KEY` points, and the old fallback to `.` on a missing
|
||||
// home made the answer depend on the working directory.
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let base = dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
let path = base.join("identity.pem");
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn cmd_list(node: String) -> Result<()> {
|
||||
let reachable = peer["reachable"].as_bool().unwrap_or(false);
|
||||
let last_seen = peer["last_seen"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.map(|s| &s[..10])
|
||||
.unwrap_or("never");
|
||||
let status = if reachable { "✓" } else { "✗" };
|
||||
println!(" {status} {url}");
|
||||
|
||||
+7
-13
@@ -304,7 +304,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.unwrap_or("?");
|
||||
let status_icon = match status {
|
||||
"open" => "○",
|
||||
@@ -374,7 +374,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let reviewer_short = reviewer
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.unwrap_or("?");
|
||||
let rstatus = r["status"].as_str().unwrap_or("?");
|
||||
let rbody = r["body"].as_str().unwrap_or("");
|
||||
@@ -414,13 +414,10 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.unwrap_or("?");
|
||||
let cbody = c["body"].as_str().unwrap_or("");
|
||||
let created = c["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
println!(" · {author_short} ({created})");
|
||||
println!(" {cbody}");
|
||||
}
|
||||
@@ -467,7 +464,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
|
||||
|
||||
let sha = result["merge_sha"].as_str().unwrap_or("?");
|
||||
println!("✓ Merged PR #{number}");
|
||||
println!(" Merge commit: {}", crate::text::truncate(sha, 12));
|
||||
println!(" Merge commit: {}", &sha[..sha.len().min(12)]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -558,13 +555,10 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.unwrap_or("?");
|
||||
let cbody = c["body"].as_str().unwrap_or("");
|
||||
let created = c["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
println!(" · {author_short} ({created})");
|
||||
println!(" {cbody}");
|
||||
println!();
|
||||
|
||||
+33
-61
@@ -12,7 +12,7 @@ use serde_json::{json, Value};
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
const PUBLIC_NODE: &str = "https://node.twigpine.com";
|
||||
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -39,26 +39,19 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!("and create your first repository.");
|
||||
println!();
|
||||
|
||||
// The wizard generates the identity AND stores the bootstrap UCAN, so it has to
|
||||
// land where every later command reads from — the parent of `GITLAWB_KEY`, not
|
||||
// an unconditional `~/.gitlawb`.
|
||||
let dir = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
let dir = args.dir.clone().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
|
||||
// ── Step 1: Identity ──────────────────────────────────────────────────
|
||||
println!("── Step 1: Identity ─────────────────────────────────────────────────");
|
||||
println!();
|
||||
|
||||
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
|
||||
let pem_path = dir.join("identity.pem");
|
||||
let keypair = if pem_path.exists() {
|
||||
// `args.dir.as_deref()`, not `Some(&dir)`. Passing the DIRECTORY made the
|
||||
// loader re-derive the basename as `identity.pem`, so with
|
||||
// `GITLAWB_KEY=/data/keys/ci-agent.pem` the check above found `ci-agent.pem`
|
||||
// while the load looked for a sibling that need not exist — and the Err arm
|
||||
// below then regenerated ONTO `ci-agent.pem`, destroying a working key and
|
||||
// changing the DID that repository ownership, registrations, and delegations
|
||||
// are all tied to. Regeneration must follow a failure to read the file that
|
||||
// was actually selected, never the absence of a conventional sibling.
|
||||
match load_keypair_from_dir(args.dir.as_deref()) {
|
||||
match load_keypair_from_dir(Some(&dir)) {
|
||||
Ok(kp) => {
|
||||
let did = kp.did();
|
||||
println!(" ✓ Identity already exists");
|
||||
@@ -66,15 +59,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!();
|
||||
kp
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Identity at {} is unreadable: {e}", pem_path.display());
|
||||
println!(" Regenerating — the previous key cannot be recovered.");
|
||||
generate_identity(&dir, &pem_path)?
|
||||
Err(_) => {
|
||||
println!(" Identity file exists but is unreadable. Regenerating...");
|
||||
generate_identity(&dir)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" No identity found. Generating a new Ed25519 keypair...");
|
||||
generate_identity(&dir, &pem_path)?
|
||||
generate_identity(&dir)?
|
||||
};
|
||||
|
||||
let did = keypair.did().to_string();
|
||||
@@ -107,50 +99,27 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let ucan = payload["ucan"].as_str().unwrap_or("");
|
||||
// The messaging below derives from what was actually persisted, not
|
||||
// from the 2xx: a success response carrying no `ucan` skips the write,
|
||||
// and saying "UCAN saved" anyway sends the operator looking for a file
|
||||
// that was never created - then resurfaces later as a push rejection
|
||||
// with nothing pointing back here.
|
||||
let saved = if ucan.is_empty() {
|
||||
false
|
||||
} else {
|
||||
crate::secret_file::create_dir(&dir)?;
|
||||
if !ucan.is_empty() {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did,
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
crate::secret_file::write(
|
||||
&ucan_path,
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
true
|
||||
};
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
}
|
||||
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
|
||||
println!(" ✓ Registered successfully");
|
||||
println!(" Trust score: {trust:.2}");
|
||||
if saved {
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
} else {
|
||||
// A 2xx without a usable ucan is not a registration; the
|
||||
// response may not even be JSON. Saying success here would
|
||||
// also leave a stale ucan.json from another node in place.
|
||||
println!(
|
||||
" \u{00d7} Registration returned no UCAN (unexpected response body)"
|
||||
);
|
||||
}
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
println!();
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body["message"].as_str().unwrap_or("unknown");
|
||||
println!(
|
||||
" ✗ Registration failed ({status}): {}",
|
||||
sanitize_node_msg(msg)
|
||||
);
|
||||
println!(" ✗ Registration failed ({status}): {msg}");
|
||||
println!(" You can retry with: gl register --node {}", args.node);
|
||||
println!();
|
||||
// Non-fatal — continue to repo creation
|
||||
@@ -236,10 +205,7 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
let status = resp.status();
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let msg = payload["message"].as_str().unwrap_or("unknown");
|
||||
println!(
|
||||
" ✗ Repo creation failed ({status}): {}",
|
||||
sanitize_node_msg(msg)
|
||||
);
|
||||
println!(" ✗ Repo creation failed ({status}): {msg}");
|
||||
println!(
|
||||
" Try manually: gl repo create {repo_name} --node {}",
|
||||
args.node
|
||||
@@ -260,17 +226,23 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_identity(
|
||||
dir: &std::path::Path,
|
||||
path: &std::path::Path,
|
||||
) -> Result<gitlawb_core::identity::Keypair> {
|
||||
crate::secret_file::create_dir(dir)
|
||||
.with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = dir.join("identity.pem");
|
||||
|
||||
crate::secret_file::write(path, pem.as_bytes())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
}
|
||||
|
||||
let did = keypair.did();
|
||||
println!(" ✓ Generated new identity");
|
||||
|
||||
+20
-80
@@ -1,15 +1,14 @@
|
||||
//! `gl register` — register this agent identity with a Twigpine node.
|
||||
//!
|
||||
//! Sends a signed POST /api/register request and saves the returned bootstrap
|
||||
//! UCAN token as `ucan.json` beside the identity key, where the other commands
|
||||
//! look for it.
|
||||
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -34,7 +33,7 @@ pub struct RegisterArgs {
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -65,20 +64,17 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
// Save bootstrap UCAN
|
||||
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let saved_to = if ucan.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let path = ucan_path(args.dir.as_deref())?;
|
||||
if !ucan.is_empty() {
|
||||
let ucan_path = ucan_path(args.dir.as_deref())?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
crate::secret_file::write(&path, serde_json::to_string_pretty(&record)?.as_bytes())?;
|
||||
tracing::debug!("saved UCAN to {}", path.display());
|
||||
Some(path)
|
||||
};
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
tracing::debug!("saved UCAN to {}", ucan_path.display());
|
||||
}
|
||||
|
||||
let trust = payload
|
||||
.get("trust_score")
|
||||
@@ -94,39 +90,25 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
.unwrap_or("");
|
||||
|
||||
println!();
|
||||
println!(" {}", sanitize_node_msg(message));
|
||||
println!(" {message}");
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN expires: {}", sanitize_node_msg(expires));
|
||||
println!(" UCAN expires: {expires}");
|
||||
println!();
|
||||
// The real path, not the default one: `GITLAWB_KEY` moves it, and an operator
|
||||
// told to look in `~/.gitlawb` would find nothing there.
|
||||
// Registration without a stored token means the registration-gated
|
||||
// capabilities never arrived, so the closing line must not claim they did.
|
||||
match &saved_to {
|
||||
Some(path) => {
|
||||
println!(" Bootstrap UCAN saved to {}", path.display());
|
||||
println!(" You are now a verified agent on the gitlawb network.");
|
||||
}
|
||||
None => {
|
||||
println!(" The node returned no bootstrap UCAN, so this identity is not");
|
||||
println!(" a verified agent yet. Re-run `gl register` once the node issues one.");
|
||||
}
|
||||
}
|
||||
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
|
||||
println!(" You are now a verified agent on the Twigpine network.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the bootstrap UCAN is stored: beside the identity key, always.
|
||||
///
|
||||
/// Routed through `gitlawb_dir` rather than resolving `~/.gitlawb` locally. The
|
||||
/// key is READ from the parent of `GITLAWB_KEY`, so writing the token anywhere
|
||||
/// else splits the two: registration succeeds, and `gl doctor`, `gl ucan show`,
|
||||
/// `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the key's
|
||||
/// directory and report an unregistered identity.
|
||||
fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||
let base = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?;
|
||||
crate::secret_file::create_dir(&base)
|
||||
.with_context(|| format!("failed to create {}", base.display()))?;
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
std::fs::create_dir_all(&base)?;
|
||||
Ok(base.join("ucan.json"))
|
||||
}
|
||||
|
||||
@@ -173,48 +155,6 @@ mod tests {
|
||||
assert_eq!(content["node"].as_str().unwrap(), server.url());
|
||||
}
|
||||
|
||||
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
|
||||
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
|
||||
/// silent split-brain: registration prints success, and every command that later
|
||||
/// reads `ucan.json` — `gl doctor`, `gl ucan show`, `gl init`, `gl mcp` — looks
|
||||
/// beside the key, finds nothing, and reports an unregistered identity.
|
||||
#[tokio::test]
|
||||
async fn register_saves_the_bootstrap_ucan_beside_the_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
// No --dir: the destination has to come from GITLAWB_KEY alone.
|
||||
let _env = crate::identity::test_env::set_key(Some(dir.path().join("identity.pem")));
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
run(RegisterArgs {
|
||||
node: server.url(),
|
||||
capabilities: vec!["git:push".to_string()],
|
||||
model: None,
|
||||
dir: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let beside_the_key = dir.path().join("ucan.json");
|
||||
assert!(
|
||||
beside_the_key.exists(),
|
||||
"the bootstrap UCAN must land beside GITLAWB_KEY, not in ~/.gitlawb"
|
||||
);
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(beside_the_key).unwrap()).unwrap();
|
||||
assert_eq!(content["ucan"].as_str().unwrap(), "eyJhbGci.test.token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
+6
-412
@@ -5,7 +5,7 @@ use clap::{Args, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -274,126 +274,6 @@ async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result
|
||||
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
|
||||
}
|
||||
|
||||
/// Fetch `GET /` from the node and return the `web_url` if the node advertises one.
|
||||
///
|
||||
/// The View: link is a nice-to-have, so every failure mode degrades to `None`
|
||||
/// rather than failing the enclosing command — but failures are surfaced as
|
||||
/// stderr warnings (request error, non-success HTTP status with the status,
|
||||
/// malformed advertised value), since they all mean the node itself is
|
||||
/// misbehaving or misconfigured, which the user would otherwise never learn.
|
||||
/// A successful response that lacks a usable `web_url` (field missing or
|
||||
/// blank) omits the link silently: self-hosted nodes without a web front-end
|
||||
/// are expected to not advertise one (#370).
|
||||
///
|
||||
/// The body is treated like every other caller-chosen node reply (INV-6): the
|
||||
/// read is capped and an accepted `web_url` must be free of control/bidi bytes
|
||||
/// — it reaches the terminal verbatim through the View: line.
|
||||
pub(crate) async fn fetch_node_web_url(node: &str) -> Option<String> {
|
||||
let info_client = NodeClient::new(node, None);
|
||||
let info_resp = match info_client.get("/").await {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
eprintln!("warning: node info request failed ({err}); skipping View link");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let status = info_resp.status();
|
||||
if !status.is_success() {
|
||||
eprintln!("warning: node info request returned {status}; skipping View link");
|
||||
return None;
|
||||
}
|
||||
// An info reply is a DID, a few URLs and counts — 8 KiB is well past what
|
||||
// the shape needs; anything longer is hostile or broken (peer.rs precedent).
|
||||
let raw_body = crate::http::read_body_capped(info_resp, 8 * 1024).await;
|
||||
match web_url_from_info_body(&raw_body) {
|
||||
Ok(url) => url,
|
||||
Err(reason) => {
|
||||
eprintln!("warning: {reason}; skipping View link");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a capped `GET /` body for the `View:` advertisement. Split out of
|
||||
/// `fetch_node_web_url` so tests can pin the classification contract directly:
|
||||
/// a truncated or half-read body is indistinguishable from a node that
|
||||
/// advertised nothing, so it must warn rather than silently omit the link.
|
||||
fn web_url_from_info_body(
|
||||
raw_body: &crate::http::CappedBody,
|
||||
) -> std::result::Result<Option<String>, String> {
|
||||
if raw_body.truncated || raw_body.read_failed {
|
||||
return Err("node info body was oversized or could not be read in full".to_string());
|
||||
}
|
||||
let info: Value = match serde_json::from_str(&raw_body.text) {
|
||||
Ok(json) => json,
|
||||
Err(_) => return Err("node info body was not valid JSON".to_string()),
|
||||
};
|
||||
// Missing field / non-string degrade to None without warning — same contract
|
||||
// as before; only transport- and advertisement-level failures warn there.
|
||||
let raw = match info["web_url"].as_str() {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// A present-but-malformed value means the node is misconfigured; say so
|
||||
// instead of quietly dropping the link. Mirrors the node-side boot
|
||||
// validation: must be an absolute http(s) URL with no query or fragment —
|
||||
// the link is built by string-appending `/{owner}/{repo}` to it. Control
|
||||
// and bidi-format bytes are rejected outright (not stripped): they have no
|
||||
// legitimate place in a base URL and would reach the terminal through the
|
||||
// View: line — including when they arrive percent-encoded (INV-6).
|
||||
let malformed_reason: Option<String> = if trimmed
|
||||
.chars()
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
|
||||
{
|
||||
Some("contains control or bidi characters".to_string())
|
||||
} else {
|
||||
match trimmed.parse::<url::Url>() {
|
||||
Err(_) => Some("not an absolute URL".to_string()),
|
||||
Ok(parsed) if !matches!(parsed.scheme(), "http" | "https") => {
|
||||
Some("not http(s)".to_string())
|
||||
}
|
||||
Ok(parsed) if parsed.query().is_some() || parsed.fragment().is_some() => {
|
||||
Some("contains a query or fragment".to_string())
|
||||
}
|
||||
// userinfo is rejected because the `View:` line prints the
|
||||
// value verbatim; a remote advertisement carrying
|
||||
// `user:pass@host` would land that credential in terminal
|
||||
// scrollback and CI logs.
|
||||
Ok(parsed) if !parsed.username().is_empty() || parsed.password().is_some() => {
|
||||
Some("contains a username or password".to_string())
|
||||
}
|
||||
Ok(parsed)
|
||||
if parsed
|
||||
.host_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.chain(
|
||||
percent_encoding::percent_decode_str(parsed.path())
|
||||
.decode_utf8_lossy()
|
||||
.chars(),
|
||||
)
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c)) =>
|
||||
{
|
||||
Some("contains control or bidi characters, even percent-encoded".to_string())
|
||||
}
|
||||
Ok(_) => None,
|
||||
}
|
||||
};
|
||||
match malformed_reason {
|
||||
// The reason string is ours; the advertised value is not — defang it
|
||||
// exactly as it would have been defanged had it been accepted.
|
||||
Some(reason) => Err(format!(
|
||||
"node advertised a malformed web_url ({:?}, {reason})",
|
||||
sanitize_node_msg(trimmed)
|
||||
)),
|
||||
None => Ok(Some(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_create(
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
@@ -431,11 +311,7 @@ async fn cmd_create(
|
||||
println!("✓ Created repository: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
println!(" HTTP: {clone_url}");
|
||||
// Only print View: when the node advertises a web_url — self-hosted nodes
|
||||
// without a web front-end would otherwise produce a 404 link (#370).
|
||||
if let Some(web_url) = fetch_node_web_url(&node).await {
|
||||
println!(" View: {web_url}/{owner_short}/{name}");
|
||||
}
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
if let Some(desc) = payload["description"].as_str().filter(|s| !s.is_empty()) {
|
||||
println!(" Desc: {desc}");
|
||||
}
|
||||
@@ -461,10 +337,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let name = r["name"].as_str().unwrap_or("?");
|
||||
let desc = r["description"].as_str().unwrap_or("");
|
||||
let public = r["is_public"].as_bool().unwrap_or(true);
|
||||
let updated = r["updated_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
let updated = r["updated_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
let vis = if public { "public" } else { "private" };
|
||||
println!(" {name:<24} {vis:<8} {updated} {desc}");
|
||||
}
|
||||
@@ -641,7 +514,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Resul
|
||||
let url = r["replica_url"].as_str().unwrap_or("?");
|
||||
let registered = r["registered_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.map(|s| &s[..10.min(s.len())])
|
||||
.unwrap_or("?");
|
||||
println!(" {registered} {did} → {url}");
|
||||
}
|
||||
@@ -683,7 +556,7 @@ pub(crate) async fn cmd_commits(
|
||||
.or_else(|| c["sha"].as_str())
|
||||
.or_else(|| c["oid"].as_str())
|
||||
.unwrap_or("?");
|
||||
let short_sha = crate::text::truncate(sha, 10);
|
||||
let short_sha = &sha[..sha.len().min(10)];
|
||||
let msg = c["message"].as_str().unwrap_or("(no message)");
|
||||
let first_line = msg.lines().next().unwrap_or(msg);
|
||||
let author = c["author_name"]
|
||||
@@ -693,7 +566,7 @@ pub(crate) async fn cmd_commits(
|
||||
let date = c["date"]
|
||||
.as_str()
|
||||
.or_else(|| c["committer_date"].as_str())
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.map(|s| &s[..10.min(s.len())])
|
||||
.unwrap_or("?");
|
||||
println!(" {short_sha} {date} {first_line} ({author})");
|
||||
}
|
||||
@@ -972,285 +845,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_with_web_url() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://example.com","did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_trims_trailing_slash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://example.com/"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_without_web_url() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_empty_string() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":""}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_whitespace_only() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":" "}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_server_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(500)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
/// A node advertising a present-but-malformed web_url (not an absolute
|
||||
/// http(s) URL) must not yield a View link — and must warn, since a
|
||||
/// malformed advertisement means the node is misconfigured (#370).
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_malformed_value_is_rejected() {
|
||||
for bad in [
|
||||
"gitlawb.com",
|
||||
"not a url",
|
||||
"ftp://files.example.com",
|
||||
// Query/fragment corrupt the appended /{owner}/{repo} path.
|
||||
"https://example.com?a=1",
|
||||
"https://example.com/#faq",
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(r#"{{"web_url":"{bad}"}}"#))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"malformed web_url {bad:?} must not render a View link"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport-level failure (connection refused) degrades to None but is
|
||||
/// no longer silent: the request error is surfaced on stderr so an
|
||||
/// unreachable node isn't indistinguishable from one without a web_url.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_connection_refused_warns() {
|
||||
// Port 1 on localhost is reserved (tcpmux) and refuses connections.
|
||||
let web_url = fetch_node_web_url("http://127.0.0.1:1").await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
/// A hostile node can smuggle ANSI/bell/bidi controls inside a web_url that
|
||||
/// still passes http(s) URL parsing — those bytes would reach the terminal
|
||||
/// verbatim through the View: line. The advertised value must be rejected
|
||||
/// outright: no control byte may survive into the returned string.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_rejects_control_bytes() {
|
||||
for bad in [
|
||||
"https://example.com/\x1b[31mred", // ANSI CSI escape in path
|
||||
"https://example.com\x07", // bell
|
||||
"https://ex\u{202e}ample.com", // bidi override (RLO)
|
||||
"https://example.com/\u{200f}", // RLM format char
|
||||
"\x1b]0;title\x07https://evil.example", // OSC title-set prefix
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
// serde_json escapes the control bytes so the body is VALID
|
||||
// JSON — a raw literal would make parse fail and the test
|
||||
// would pass without reaching the control check at all.
|
||||
.with_body(serde_json::json!({"web_url": bad}).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"web_url with control bytes {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An oversized or half-read `GET /` body must warn, not silently
|
||||
/// classify as "no web front-end": a cut body fails JSON parse, which is
|
||||
/// indistinguishable from a node that advertised nothing without the
|
||||
/// truncated flag. Removing the flag check makes this test pass
|
||||
/// vacuously through the parse-fail arm.
|
||||
#[test]
|
||||
fn web_url_from_info_body_warns_on_truncated_reads() {
|
||||
for (body, label) in [
|
||||
(
|
||||
crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://x"#.repeat(2000),
|
||||
truncated: true,
|
||||
read_failed: false,
|
||||
},
|
||||
"truncated",
|
||||
),
|
||||
(
|
||||
crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://x"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: true,
|
||||
},
|
||||
"read_failed",
|
||||
),
|
||||
] {
|
||||
let err = web_url_from_info_body(&body)
|
||||
.expect_err(&format!("{label} body must warn, not silently classify"));
|
||||
assert!(
|
||||
err.contains("oversized or could not be read"),
|
||||
"{label}: got {err}"
|
||||
);
|
||||
}
|
||||
// A clean body missing web_url still classifies as a silent None.
|
||||
let clean = crate::http::CappedBody {
|
||||
text: r#"{"did":"did:key:x"}"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: false,
|
||||
};
|
||||
assert_eq!(web_url_from_info_body(&clean).unwrap(), None);
|
||||
}
|
||||
|
||||
/// Percent-encoded bidi must not slip past the literal scan: a node
|
||||
/// advertising `https://example.com/%E2%80%AE` serves wire bytes with no
|
||||
/// Cf code point, but a browser decodes it into a reordering character
|
||||
/// on the rendered View: link (INV-6).
|
||||
#[test]
|
||||
fn web_url_from_info_body_rejects_percent_encoded_bidi() {
|
||||
let body = crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://example.com/%E2%80%AE"}"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: false,
|
||||
};
|
||||
let err = web_url_from_info_body(&body).expect_err("percent-encoded bidi must be rejected");
|
||||
assert!(err.contains("bidi"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_validates_after_trimming() {
|
||||
// Trailing slash is trimmed before validation; result stays usable.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"http://localhost:8080/ui/"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("http://localhost:8080/ui"));
|
||||
}
|
||||
|
||||
/// A remote node that advertises `web_url` with userinfo (`user@`,
|
||||
/// `user:pass@`) must be dropped: the value is reprinted on the `View:`
|
||||
/// line, and a credential embedded in the URL would land in terminal
|
||||
/// scrollback and CI logs. Mirrors the node-side boot validation.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_rejects_userinfo() {
|
||||
for bad in [
|
||||
"https://[email protected]",
|
||||
"https://user:[email protected]",
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(r#"{{"web_url":"{}"}}"#, bad))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"web_url with userinfo {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-success status must still return None (View link is cosmetic) but
|
||||
/// the status surfaces as a user-visible stderr warning instead of being
|
||||
/// swallowed silently (#370 review).
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_non_success_warns_with_status() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(503)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
// The warning itself goes to stderr; mockito can't capture it here, so
|
||||
// this asserts only the None contract. Manual check: run against a
|
||||
// 503-ing node and observe "node info request returned 503" on stderr.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_create_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//! Writes and directories that hold key material or bearer tokens.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// Write `contents` to `path` with owner-only permissions.
|
||||
///
|
||||
/// On unix the mode is pinned at creation, so the file never exists with a
|
||||
/// permissive mode between open and chmod. `O_NOFOLLOW` refuses to write
|
||||
/// through a pre-planted symlink. `mode()` applies only when the file is
|
||||
/// created, so the mode is pinned on the descriptor before the file is
|
||||
/// truncated: a pre-existing loose file is tightened before, not after, the
|
||||
/// new contents land in it.
|
||||
pub(crate) fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)?;
|
||||
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
f.set_len(0)?;
|
||||
f.write_all(contents)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create `path` (and missing parents) as an owner-only directory.
|
||||
///
|
||||
/// An existing directory is also re-pinned: a `~/.gitlawb` created before
|
||||
/// this helper stays group- and world-listable otherwise. A symlinked
|
||||
/// `path` is left alone rather than chmodded through, which would strip
|
||||
/// group and world access from whatever the link points at.
|
||||
pub(crate) fn create_dir(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(path)?;
|
||||
// Re-pin through the opened directory, not the path: O_NOFOLLOW
|
||||
// refuses a swapped-in symlink and fchmod acts on the checked inode,
|
||||
// so a final-component swap between check and chmod cannot retarget
|
||||
// the mode change at whatever the link points at.
|
||||
match std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)
|
||||
{
|
||||
Ok(dir) => {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = dir.as_raw_fd();
|
||||
// Re-pin only a leaf this process owns. A --dir the caller
|
||||
// can write but cannot chmod (foreign-owned 0775 leaf, ACL
|
||||
// grant) is adopted unchanged: create_dir_all allowed the
|
||||
// write before, and aborting turns a usable directory into
|
||||
// a command failure. An owned directory that still refuses
|
||||
// chmod (EPERM) is adopted the same way.
|
||||
let owned = unsafe {
|
||||
let mut st: libc::stat = std::mem::zeroed();
|
||||
libc::fstat(fd, &mut st) == 0 && st.st_uid == libc::geteuid()
|
||||
};
|
||||
if owned && unsafe { libc::fchmod(fd, 0o700) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.raw_os_error() != Some(libc::EPERM) {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ELOOP: the path is a symlink and is left alone per contract.
|
||||
Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::create_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn create_dir_adopts_writable_leaf_it_cannot_chmod() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// /tmp is root-owned 1777: writable by this euid, not chmod-able.
|
||||
// The pre-helper create_dir_all allowed the write; the re-pin must
|
||||
// adopt the leaf unchanged rather than abort on EPERM.
|
||||
let before = std::fs::metadata("/tmp").unwrap().permissions().mode();
|
||||
super::create_dir(std::path::Path::new("/tmp")).unwrap();
|
||||
let after = std::fs::metadata("/tmp").unwrap().permissions().mode();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn write_creates_file_with_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("key.pem");
|
||||
super::write(&path, b"secret").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn write_tightens_preexisting_loose_file() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("key.pem");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
super::write(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn write_refuses_symlink() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("target.pem");
|
||||
std::fs::write(&target, b"planted").unwrap();
|
||||
let link = dir.path().join("link.pem");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
assert!(super::write(&link, b"secret").is_err());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"planted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn create_dir_modes_0700_and_tightens_existing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a").join("b");
|
||||
super::create_dir(&path).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
super::create_dir(&path).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn create_dir_does_not_chmod_through_symlink() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("shared");
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
super::create_dir(&link).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
|
||||
0o755
|
||||
);
|
||||
}
|
||||
}
|
||||
+34
-1364
File diff suppressed because it is too large.
Load diff
@@ -1,63 +0,0 @@
|
||||
//! Display helpers for strings that come off the wire unchecked.
|
||||
|
||||
/// Truncate `s` to at most `max` bytes, cutting at a char boundary.
|
||||
///
|
||||
/// `&s[..max]` panics when `s` is shorter than `max` bytes and when `max`
|
||||
/// lands inside a multi-byte char; `s.len().min(max)` guards only the first
|
||||
/// case. Node-supplied timestamps and ids hit both.
|
||||
pub(crate) fn truncate(s: &str, max: usize) -> &str {
|
||||
let mut end = max.min(s.len());
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
/// Keep at most the last `max` bytes of `s`, cutting at a char boundary.
|
||||
///
|
||||
/// `&s[s.len().saturating_sub(max)..]` panics when the start index lands
|
||||
/// inside a multi-byte char; node-supplied ids hit that case.
|
||||
pub(crate) fn truncate_suffix(s: &str, max: usize) -> &str {
|
||||
let mut start = s.len().saturating_sub(max);
|
||||
while !s.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
&s[start..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{truncate, truncate_suffix};
|
||||
|
||||
#[test]
|
||||
fn cuts_ascii_at_max() {
|
||||
assert_eq!(truncate("2026-08-15T12:34:56Z", 10), "2026-08-15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_and_empty_inputs_return_whole() {
|
||||
assert_eq!(truncate("2026", 10), "2026");
|
||||
assert_eq!(truncate("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_cut_does_not_panic() {
|
||||
// Byte index 10 lands inside 'é'; the cut must land on the boundary
|
||||
// before it (9 bytes) rather than panic or exceed the byte limit.
|
||||
assert_eq!(truncate("2026-08-1\u{e9}5T12:34:56Z", 10), "2026-08-1");
|
||||
// A string that is entirely multi-byte and shorter than max.
|
||||
assert_eq!(truncate("\u{e9}\u{e9}", 10), "\u{e9}\u{e9}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_cut_does_not_panic() {
|
||||
// 'é' is two bytes; a start index of len-8 lands inside it.
|
||||
assert_eq!(truncate_suffix("did:key:\u{e9}1234567", 8), "1234567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_short_input_returns_whole() {
|
||||
assert_eq!(truncate_suffix("short", 8), "short");
|
||||
assert_eq!(truncate_suffix("", 8), "");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user