mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 18:08:02 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1c96f6f1 |
No files matched your search
@@ -326,19 +326,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
|
||||
@@ -347,14 +334,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
|
||||
@@ -380,21 +359,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.8.0"
|
||||
".": "0.7.1"
|
||||
}
|
||||
@@ -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.
|
||||
-177
@@ -1,182 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/Twigpine/node/compare/v0.7.1...v0.8.0) (2026-11-10)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **node:** pending_ref_transitions.first_ref_name is dropped. Callers that read or write that column must move to receive_pack_requests.accepted_ordinal.
|
||||
* **node:** gate agent-task reads behind visibility rules ([#553](https://github.com/Twigpine/node/issues/553))
|
||||
* **node:** the GraphQL `repos` field now returns an error instead of the full list when more than MAX_VISIBLE_REPO_PAGE_SIZE (200) repositories are visible; larger lists must use `reposPage` with `limit`/`after`. Repository names are additionally limited to 100 bytes at creation, fork, and mirror-registration time.
|
||||
* **node:** gl cert show <id> --verify now requires --expect-node <did>; the node's self-reported DID is no longer trusted as a cert anchor.
|
||||
* **node:** `git-receive-pack` now rejects a push whose authenticated DID is not the repo owner, returning 403 before any ref update is applied. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so an agent pushing under its own DID cannot push while this is on. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` during a rolling upgrade, or have automation push as the repo owner, until scoped collaborator / UCAN-delegated push rights land.
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** carry the resumed candidate's identity in the scan token ([2656718](https://github.com/Twigpine/node/commit/2656718916ab9c68dd7fccb8f6fba8bba6b43e82))
|
||||
* **node,git:** cap concurrent served git ops with a 503 load-shed ([#62](https://github.com/Twigpine/node/issues/62)) ([5981257](https://github.com/Twigpine/node/commit/5981257be582fe1d39980bb2b1b3ff5c98448e84))
|
||||
* **node:** add scheduled inbound mirror worker ([#555](https://github.com/Twigpine/node/issues/555)) ([209618a](https://github.com/Twigpine/node/commit/209618a2feb1157a5bd685282de3b68a64e3882b))
|
||||
* **node:** persist upstream mirror configuration and transition state ([#538](https://github.com/Twigpine/node/issues/538)) ([0e5a160](https://github.com/Twigpine/node/commit/0e5a1608e21f4859b2919307287747263f9fa8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api,sync:** Delimit positional arguments in git clone mirror invocations ([#539](https://github.com/Twigpine/node/issues/539)) ([0cdb4bc](https://github.com/Twigpine/node/commit/0cdb4bcc7c4f7b0eb997fc5ea26981b1e0e26e3c))
|
||||
* **attest:** bound did:key method-id length before base58 decode ([#360](https://github.com/Twigpine/node/issues/360)) ([#535](https://github.com/Twigpine/node/issues/535)) ([9b915b0](https://github.com/Twigpine/node/commit/9b915b09a7c727af15c5c520107403b9dc93d35a))
|
||||
* **attest:** make verify_all lenient under RequireAll ([#359](https://github.com/Twigpine/node/issues/359)) ([#532](https://github.com/Twigpine/node/issues/532)) ([db98b39](https://github.com/Twigpine/node/commit/db98b3957a15eff9e9f286f8a2d084d8e731fcdb))
|
||||
* **attest:** use strict Ed25519 verification with regression test ([1fcbbbd](https://github.com/Twigpine/node/commit/1fcbbbd89c027ae7d1d903f705631d94f8dfbadc))
|
||||
* **ci:** add tested release tag resolver ([1b65f70](https://github.com/Twigpine/node/commit/1b65f70403ab47ced8c2828745c1c79c89a185e4))
|
||||
* **ci:** recognize namespaced test attributes in pr-triage's needs-tests check ([1989fcc](https://github.com/Twigpine/node/commit/1989fcca112d0efb56978efe09d9a836afa7cb59))
|
||||
* **ci:** reject unsafe release dispatch tags ([7b365b0](https://github.com/Twigpine/node/commit/7b365b0dc3ef3b5e3d14fd62bccc434e57200612))
|
||||
* **ci:** reject unsafe release dispatch tags ([0b990d6](https://github.com/Twigpine/node/commit/0b990d687f0eb379b62eb600ede2a6233017e419))
|
||||
* **core:** cap the blinded recipient scan in open_blob ([#419](https://github.com/Twigpine/node/issues/419)) ([#440](https://github.com/Twigpine/node/issues/440)) ([2450bbd](https://github.com/Twigpine/node/commit/2450bbd9e962f47b374b07932a1ff3fc969b9427))
|
||||
* **core:** compute created-skew in checked arithmetic ([#358](https://github.com/Twigpine/node/issues/358)) ([#545](https://github.com/Twigpine/node/issues/545)) ([61d8a4f](https://github.com/Twigpine/node/commit/61d8a4f8f1503ca0b088a039802a8999d305642d))
|
||||
* **core:** count distinct signer DIDs in certificate threshold check ([3993fd1](https://github.com/Twigpine/node/commit/3993fd1f5daacbebb8f8e2c62af8856c493ec87a))
|
||||
* **core:** Count distinct signer DIDs in certificate threshold check ([50d3cbb](https://github.com/Twigpine/node/commit/50d3cbbe97f77b3cab9fab220add9f3a0d0dbc2f))
|
||||
* **core:** enforce strict RFC 8032 Ed25519 signature verification ([425ebf4](https://github.com/Twigpine/node/commit/425ebf40e7737f44c5fe4e9dc8995c8aeb074ce1))
|
||||
* **core:** ignore invalid signatures in satisfies_threshold ([#349](https://github.com/Twigpine/node/issues/349)) ([#554](https://github.com/Twigpine/node/issues/554)) ([0919cf7](https://github.com/Twigpine/node/commit/0919cf7c621e360fbdacf6b9162d46620edc0f86))
|
||||
* **core:** put the redirect predicate behind a feature so core stays lean ([c82a70a](https://github.com/Twigpine/node/commit/c82a70ab21a21192b2579693b7d7c13aeb8a4d20))
|
||||
* **core:** refuse a redirect that rewrites the signed request-target ([5c0d6f3](https://github.com/Twigpine/node/commit/5c0d6f32522adea8f167f8e445a26377b6fe3963))
|
||||
* **core:** reject small-order Ed25519 keys at DID resolution and before X25519 ([#314](https://github.com/Twigpine/node/issues/314)) ([86c544a](https://github.com/Twigpine/node/commit/86c544a577f28efb2341a53d4faa6b54d1c8b919))
|
||||
* **core:** reject zero threshold, canonicalize did:key, document replay ([#509](https://github.com/Twigpine/node/issues/509)) ([#540](https://github.com/Twigpine/node/issues/540)) ([b4a3122](https://github.com/Twigpine/node/commit/b4a3122500e87d0b84aa3b116171de070188a6bd))
|
||||
* **core:** share the origin-scoped redirect rule with the git helper ([ba44a01](https://github.com/Twigpine/node/commit/ba44a018ec8945b447977d89fc6a10d98fc1bb7b))
|
||||
* **core:** use strict Ed25519 signature verification ([a6c13be](https://github.com/Twigpine/node/commit/a6c13be15b1421f3791e49a8219ea739f1e3ec6e))
|
||||
* **deps:** bump aws-smithy-json to 0.62.7 (GHSA-8ffr-xgwf-xj56) ([#516](https://github.com/Twigpine/node/issues/516)) ([151d688](https://github.com/Twigpine/node/commit/151d68838743a96198879b3154cfbaedca04c3cd))
|
||||
* **deps:** bump follow-redirects and override ws in scripts/ ([#525](https://github.com/Twigpine/node/issues/525)) ([a23c81d](https://github.com/Twigpine/node/commit/a23c81d75ae0a7567905ca9ad093bc9ad7c5adcf))
|
||||
* **deps:** bump libp2p to pick up hickory 0.26 and serde_with 3.22 ([#518](https://github.com/Twigpine/node/issues/518)) ([623b600](https://github.com/Twigpine/node/commit/623b600835d717fcfdab36af116dd4b9a92a92cd)), closes [#76](https://github.com/Twigpine/node/issues/76)
|
||||
* **deps:** resolve cargo audit failures on main ([#514](https://github.com/Twigpine/node/issues/514)) ([4d024e0](https://github.com/Twigpine/node/commit/4d024e01bcf25c93a3b878ac50e6435575dcc5f4))
|
||||
* **git-remote:** abort fetch on EOF without POSTing; harden the real-git test harness ([#192](https://github.com/Twigpine/node/issues/192)) ([1cb930c](https://github.com/Twigpine/node/commit/1cb930cbd57ea5d232edddc8e50dc94fab4362a5))
|
||||
* **git-remote:** drive multi-round fetch as a v0 stateless-RPC client loop ([#117](https://github.com/Twigpine/node/issues/117)) ([5fdf155](https://github.com/Twigpine/node/commit/5fdf155c7fd7398f61c200c256cb7327729f378a))
|
||||
* **git-remote:** drive multi-round fetch as a v0 stateless-RPC client loop ([#117](https://github.com/Twigpine/node/issues/117)) ([a64b141](https://github.com/Twigpine/node/commit/a64b14150dfb7e369c16e5fab98375ba0ce38404))
|
||||
* **git-remote:** normalize the shim's accepted socket to blocking ([10e0840](https://github.com/Twigpine/node/commit/10e0840dd95204d2b8faa1c594808e2ad57bc9c4))
|
||||
* **git-remote:** normalize the shim's accepted socket to blocking ([#192](https://github.com/Twigpine/node/issues/192)) ([8d7f95f](https://github.com/Twigpine/node/commit/8d7f95fd63769ef30540f216190e1bb52ccd4f69))
|
||||
* **git-remote:** reject malformed pkt-line length headers instead of flushing ([8c04043](https://github.com/Twigpine/node/commit/8c04043fb56b5030a0aa6b328c91f20173352aa6))
|
||||
* **git-remote:** skip content-free flush rounds in the fetch loop ([#117](https://github.com/Twigpine/node/issues/117)) ([68ba492](https://github.com/Twigpine/node/commit/68ba49267c8d65169ab9adf0552761f87baba4c1))
|
||||
* **git-remote:** stop waiting on EOF for a delete-only push ([#442](https://github.com/Twigpine/node/issues/442)) ([c2e175d](https://github.com/Twigpine/node/commit/c2e175d2222975eccb4776447268e8678fd48ba4))
|
||||
* **gl:** accept the resume token the incomplete-scan message tells you to pass ([181f617](https://github.com/Twigpine/node/commit/181f6178a0e0f5b9506093fa7579ad6ef8dd2ff5))
|
||||
* **gl:** assert 404 verdict and harden transport error handling in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([a329174](https://github.com/Twigpine/node/commit/a3291741757038307e61d3952e6c20aa4901c8a3))
|
||||
* **gl:** assert whoami 404 verdict and preserve transport error cause ([#220](https://github.com/Twigpine/node/issues/220)) ([b4007a5](https://github.com/Twigpine/node/commit/b4007a5e005c63aa0c083b969cd8b5f65b918adb))
|
||||
* **gl:** close two completeness-gate escapes; surface the node's error key ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** derive View: URL from node instead of hardcoding gitlawb.com ([#370](https://github.com/Twigpine/node/issues/370)) ([#549](https://github.com/Twigpine/node/issues/549)) ([d84c445](https://github.com/Twigpine/node/commit/d84c445f94c05e43994c8199bb8e4cd432554fbc))
|
||||
* **gl:** follow scan continuations in gl ipfs get, bounded three ways ([9a8d190](https://github.com/Twigpine/node/commit/9a8d190492315cf767807dd1a98470debff4bd25))
|
||||
* **gl:** make doctor exit non-zero on Fail-class checks ([#357](https://github.com/Twigpine/node/issues/357)) ([#547](https://github.com/Twigpine/node/issues/547)) ([42cc893](https://github.com/Twigpine/node/commit/42cc893fb3af17468767cd8c6722894cd4ef7e44))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([21c74c6](https://github.com/Twigpine/node/commit/21c74c65d985200221489fa342d7419e8626362f))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([fd78cfa](https://github.com/Twigpine/node/commit/fd78cfa3ad0e7997b284371a55b7fb52753236d1))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3cc15cf](https://github.com/Twigpine/node/commit/3cc15cf7247396ab1efcd279d3dd51dd8deaf014))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([16cc3b3](https://github.com/Twigpine/node/commit/16cc3b37e8268d048f3145ab16978dae297df5d4))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3f41df5](https://github.com/Twigpine/node/commit/3f41df5868838544876473c2560c3d6be20e679c))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([b0e5cc0](https://github.com/Twigpine/node/commit/b0e5cc0ee0e91ebe35689bb1e073fa7e48ab37ee))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([12bc759](https://github.com/Twigpine/node/commit/12bc759ea6b20ab97c9dfeb4e00d7c6a6ca4fcfe))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([10e3492](https://github.com/Twigpine/node/commit/10e34928254205fafa8e96513a3df7899bfb10b1))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([c10f2ca](https://github.com/Twigpine/node/commit/c10f2ca255d63331f6acf3a29629e2a1c11ff355))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([3add535](https://github.com/Twigpine/node/commit/3add5356e36f0c2f89a0617fcb8018209bf722ff))
|
||||
* **gl:** only report registered: false on 404 in whoami ([#220](https://github.com/Twigpine/node/issues/220)) ([fdd1103](https://github.com/Twigpine/node/commit/fdd11030dc55a68142441ecf05f0bf33a8d38695))
|
||||
* **gl:** pass the mirror temp path to git as an OS string ([#434](https://github.com/Twigpine/node/issues/434)) ([df87b15](https://github.com/Twigpine/node/commit/df87b150fdb00e4b70e359996a41b2eebf7bb426))
|
||||
* **gl:** pin secret-file modes at creation instead of chmod after ([#354](https://github.com/Twigpine/node/issues/354)) ([#557](https://github.com/Twigpine/node/issues/557)) ([a00d7d5](https://github.com/Twigpine/node/commit/a00d7d5c482f013ff103487793b445f636d3a1da))
|
||||
* **gl:** read whoami error bodies through CappedBody.text ([bfc44f9](https://github.com/Twigpine/node/commit/bfc44f926d08c0bf774e2c05dd76b245871294f1))
|
||||
* **gl:** read whoami error bodies through CappedBody.text ([4725146](https://github.com/Twigpine/node/commit/4725146db2c13cd3b8f998260d9df3a4b5f70c9e))
|
||||
* **gl:** report quickstart registration failure when the node returns no UCAN ([#355](https://github.com/Twigpine/node/issues/355)) ([#556](https://github.com/Twigpine/node/issues/556)) ([10d5b98](https://github.com/Twigpine/node/commit/10d5b98b2bb2ac221b5407321290e4e681b206f1))
|
||||
* **gl:** report why the node-DID comparison was skipped in cert show ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** require non-empty error message and use ephemeral port in whoami tests ([#220](https://github.com/Twigpine/node/issues/220)) ([a27a267](https://github.com/Twigpine/node/commit/a27a2678712cc512b316f143d8c053dbdf981a4b))
|
||||
* **gl:** sanitize full transport error chain in whoami, remove raw error attachment ([#220](https://github.com/Twigpine/node/issues/220)) ([39ce655](https://github.com/Twigpine/node/commit/39ce655904f516094cbbef6f2ee585906ef77e05))
|
||||
* **gl:** status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result ([#123](https://github.com/Twigpine/node/issues/123)) ([#186](https://github.com/Twigpine/node/issues/186)) ([6b2c53b](https://github.com/Twigpine/node/commit/6b2c53bbb86bcb35a54ef2edd2078e8f208263a6))
|
||||
* **gl:** stop carrying request signatures to a host the node redirected us to ([de3f230](https://github.com/Twigpine/node/commit/de3f230940f21d88da48a972bddeb877420d2165))
|
||||
* **icaptcha-client:** parse GITLAWB_ICAPTCHA_INSECURE as truthy only ([#227](https://github.com/Twigpine/node/issues/227)) ([#533](https://github.com/Twigpine/node/issues/533)) ([77ac103](https://github.com/Twigpine/node/commit/77ac1037cbf893bf0e2e861753cc4bc78cc9053a))
|
||||
* **icaptcha:** return unsolved instead of blocking or overflowing ([#444](https://github.com/Twigpine/node/issues/444)) ([5479fd5](https://github.com/Twigpine/node/commit/5479fd59b848f13e7c0f0f1054b0d41cf50cbb73))
|
||||
* **infra:** serialize the upgrade and write compose through a unique temp path ([888fab6](https://github.com/Twigpine/node/commit/888fab602828dc8b514cfa5fbb4d027a3a2db019))
|
||||
* **ipfs:** page the legacy CID scan instead of materializing every repo ([4985e38](https://github.com/Twigpine/node/commit/4985e38af2bd629fa81ec70aec63937ed9894b73))
|
||||
* **node,infra,macos:** make the owner-push opt-out reach every launch path ([1f0d9ff](https://github.com/Twigpine/node/commit/1f0d9ff368c0169048aeec219de4114c2630dff6))
|
||||
* **node:** ANS-104 codec and recovery policy types ([#26](https://github.com/Twigpine/node/issues/26) split 2/4) ([#524](https://github.com/Twigpine/node/issues/524)) ([2c341f7](https://github.com/Twigpine/node/commit/2c341f71ac66c0011a09ff5864e8734fd0348c09))
|
||||
* **node:** authenticate gossip ref-update events before writing them ([#325](https://github.com/Twigpine/node/issues/325)) ([9bda2e1](https://github.com/Twigpine/node/commit/9bda2e103c9ae25a5a798343164b4fa02219735b))
|
||||
* **node:** bind a peer row to its DID so only its keyholder can repoint it ([#273](https://github.com/Twigpine/node/issues/273)) ([8d38043](https://github.com/Twigpine/node/commit/8d38043a4ca7d0df3c9a4c4bdbb171d7adcdec4f))
|
||||
* **node:** bound federated repository aggregation ([#546](https://github.com/Twigpine/node/issues/546)) ([0189990](https://github.com/Twigpine/node/commit/0189990ac9e72bed3c42bc589347eacec04be740))
|
||||
* **node:** bound GraphQL query cost ([#552](https://github.com/Twigpine/node/issues/552)) ([c805e35](https://github.com/Twigpine/node/commit/c805e35f40873a120c81bf00d9ddb50da956fd97))
|
||||
* **node:** bound Identify-derived Kademlia addresses ([#544](https://github.com/Twigpine/node/issues/544)) ([b33562f](https://github.com/Twigpine/node/commit/b33562f1a913cbc1b294f00577f4ab1f7ad67984))
|
||||
* **node:** bound legacy readiness fallback ([5af14c2](https://github.com/Twigpine/node/commit/5af14c203d07fdd2d0b5689fcb12e189237dbbb2))
|
||||
* **node:** bound peer failure tracking ([74f7554](https://github.com/Twigpine/node/commit/74f75548941712573d344aa058d162f40189b4df))
|
||||
* **node:** bound replay of authenticated gossip ref-update events ([#334](https://github.com/Twigpine/node/issues/334)) ([206e082](https://github.com/Twigpine/node/commit/206e0827ad6beb051cbb0fb5ef459a63a102c454))
|
||||
* **node:** bound the fruitless reads one sweep run will spend ([e4aaae8](https://github.com/Twigpine/node/commit/e4aaae8c5229355010ca710abf22c181210201c3))
|
||||
* **node:** bound the legacy CID scan and let the caller carry its position ([22f4db9](https://github.com/Twigpine/node/commit/22f4db94b2fb3b3c7c43c5bd34608ea77ce5d197))
|
||||
* **node:** bound the legacy scan's query by the remaining row budget ([21e6873](https://github.com/Twigpine/node/commit/21e687368336fd219d82308ee20b3d6bb018a3dc))
|
||||
* **node:** bound the legacy-repair read by the pin batch deadline ([ea2219b](https://github.com/Twigpine/node/commit/ea2219b2cb635da61f120f65434db4c427494f6c))
|
||||
* **node:** bound the pin loops' DB work by the batch deadline, both lanes ([9984e08](https://github.com/Twigpine/node/commit/9984e082121f3e8d25750dd54c0d60c85cef0ac7))
|
||||
* **node:** bound the pre-walk CID resolve on its own short budget ([0d7711c](https://github.com/Twigpine/node/commit/0d7711c08917a70f982391249439485ddce382b0))
|
||||
* **node:** bound the request body the signature middleware buffers before it verifies ([#262](https://github.com/Twigpine/node/issues/262)) ([288ad92](https://github.com/Twigpine/node/commit/288ad92fe83526a717fa1a6ac37b1f3cf1f97961))
|
||||
* **node:** bring the Pinata pin path back into lockstep with the Kubo one ([4681f54](https://github.com/Twigpine/node/commit/4681f54327b58b6ebdd569a1cec17a3c2643ad13))
|
||||
* **node:** budget provenance and fallback walks separately ([ae96ffa](https://github.com/Twigpine/node/commit/ae96ffa46706a6d088a412aa02dbf2f715ceaa37))
|
||||
* **node:** canonicalize and 404-on-no-match in remove_label ([#344](https://github.com/Twigpine/node/issues/344)) ([#530](https://github.com/Twigpine/node/issues/530)) ([8c9e4ad](https://github.com/Twigpine/node/commit/8c9e4adc2d5ae3560c1bd3ba76c1d0ddb4de2af6))
|
||||
* **node:** carry discovery onto the per-(oid, repo) incompleteness marker ([bd0bef1](https://github.com/Twigpine/node/commit/bd0bef1476f90e99d9a697e2520c32b954f3ba6f))
|
||||
* **node:** charge discovery's probes against the per-run read budget ([bc20236](https://github.com/Twigpine/node/commit/bc202368e13202504fe77534273b508f7559a84a))
|
||||
* **node:** clamp every admission-held DB await in the CID resolver ([bad82b3](https://github.com/Twigpine/node/commit/bad82b3cd9fa970ebf67817395bfbd98591879b1))
|
||||
* **node:** close review findings on the owner-push default ([2f0174c](https://github.com/Twigpine/node/commit/2f0174cc041fb6e9eb3c904172b37b5b9a6c094b))
|
||||
* **node:** close three host-class gaps in the public-URL gate ([#346](https://github.com/Twigpine/node/issues/346)) ([76ca04f](https://github.com/Twigpine/node/commit/76ca04f3552aa8c17305fca73fa1c3469a29903c))
|
||||
* **node:** count a completed fetch once per fetch, not per stateless-RPC POST ([1c690c1](https://github.com/Twigpine/node/commit/1c690c125c16ad2ed38a5885d0e16b96789b715e))
|
||||
* **node:** count fetch completion from the response outcome, not the request ([5a5d4e1](https://github.com/Twigpine/node/commit/5a5d4e1b43a5b37fcc57731ec504b221a58931f1))
|
||||
* **node:** drop withheld-subtree trees from the replication pin set ([#382](https://github.com/Twigpine/node/issues/382)) ([19a7ce2](https://github.com/Twigpine/node/commit/19a7ce2bbdad9dd96bdc35ed6230d14f772a5969))
|
||||
* **node:** durable post-receive outbox for receive-pack ([#26](https://github.com/Twigpine/node/issues/26) split 1/4) ([#558](https://github.com/Twigpine/node/issues/558)) ([f68d0dd](https://github.com/Twigpine/node/commit/f68d0dd3ffac587561499f63102cfd08732b72c2))
|
||||
* **node:** enforce owner-only push by default ([cdbda19](https://github.com/Twigpine/node/commit/cdbda19c29db576d2ea37ae74578c899b4eb3bb9))
|
||||
* **node:** Escape LIKE metacharacters in certificate prefix search ([eeafa1f](https://github.com/Twigpine/node/commit/eeafa1fa5350fb2d15ba30ac6cd6ad8247b4b7ec))
|
||||
* **node:** Escape LIKE wildcards in certificate prefix search ([96d8123](https://github.com/Twigpine/node/commit/96d8123e0f84e85a7b0234ad84f5e603a5a303aa))
|
||||
* **node:** fail pin_git_object when the add response carries no Hash ([#528](https://github.com/Twigpine/node/issues/528)) ([47c6e6c](https://github.com/Twigpine/node/commit/47c6e6ce2200dd196cb531bc6a6ed946cf587409))
|
||||
* **node:** fence a repo publish on the write attempt that owns it ([#285](https://github.com/Twigpine/node/issues/285)) ([ed76adf](https://github.com/Twigpine/node/commit/ed76adfe29d793229fbc63eeb86d24cac5d23d13))
|
||||
* **node:** gate /ipfs/pins and /arweave/anchors behind authentication ([#121](https://github.com/Twigpine/node/issues/121)) ([#543](https://github.com/Twigpine/node/issues/543)) ([d3845ce](https://github.com/Twigpine/node/commit/d3845ced69597b3e73e3c8cb5f452f6367897e5b))
|
||||
* **node:** gate agent-task reads behind visibility rules ([#553](https://github.com/Twigpine/node/issues/553)) ([4cd0a6a](https://github.com/Twigpine/node/commit/4cd0a6a306b8e131a87b5629d7a986d0e84b873f))
|
||||
* **node:** gate bounty mutations on repo read before status checks ([#529](https://github.com/Twigpine/node/issues/529)) ([8eedb93](https://github.com/Twigpine/node/commit/8eedb935c3525a91ac311e273c3f22d3b4eb4615))
|
||||
* **node:** gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak ([#135](https://github.com/Twigpine/node/issues/135)) ([21f8cb6](https://github.com/Twigpine/node/commit/21f8cb61350b2355edd2b2ed527d9fb9c4305bd9))
|
||||
* **node:** gate replica register/unregister on repo read visibility ([#445](https://github.com/Twigpine/node/issues/445)) ([0303222](https://github.com/Twigpine/node/commit/0303222eae486c6d519be1441d4b3b11115a05ab))
|
||||
* **node:** gate two Unix-only tests so the crate compiles on Windows ([5a7a77b](https://github.com/Twigpine/node/commit/5a7a77b8dc1723cde44b0439327e517e23a048ab)), closes [#228](https://github.com/Twigpine/node/issues/228)
|
||||
* **node:** give each discovery row its own slice of the pass budget ([22560b0](https://github.com/Twigpine/node/commit/22560b0872bd16dd66100681b1050faa677fc568))
|
||||
* **node:** harden peer readiness state ([6bd8f41](https://github.com/Twigpine/node/commit/6bd8f4121235ec6c4b7062aadcf3a631f174510c))
|
||||
* **node:** hold fork to the same repo-name rules as every other creation route ([#412](https://github.com/Twigpine/node/issues/412)) ([fc87586](https://github.com/Twigpine/node/commit/fc8758641147ca5f38561fc71b13b1e41a78a5ed))
|
||||
* **node:** honor pre-assigned task assignee on claim ([#550](https://github.com/Twigpine/node/issues/550)) ([a4026e8](https://github.com/Twigpine/node/commit/a4026e84ffe82f48b41817c8ba687fbc1aac8f9b))
|
||||
* **node:** implement reconciliation sweep as durability backstop ([#218](https://github.com/Twigpine/node/issues/218)) ([#551](https://github.com/Twigpine/node/issues/551)) ([a2262d9](https://github.com/Twigpine/node/commit/a2262d92b2029d7228e3bf8528aad4d7ed1865db))
|
||||
* **node:** make creation-route rate limit configurable; fix(gl): surface real errors on pr create failure ([#560](https://github.com/Twigpine/node/issues/560)) ([3c1ee27](https://github.com/Twigpine/node/commit/3c1ee27b60f7a9247f04c3144e8491a7c1f65f20))
|
||||
* **node:** make metrics::init race-safe with Once ([c45ec96](https://github.com/Twigpine/node/commit/c45ec9605d55171f7b8f1b0b542478097ddf43a3))
|
||||
* **node:** make the pin-source incompleteness marker per (object, repo) ([5575da4](https://github.com/Twigpine/node/commit/5575da4b65ff19da78a6b218da07f854706a811c))
|
||||
* **node:** map ipfs/arweave DB outages to 503 via bare ? ([#251](https://github.com/Twigpine/node/issues/251)) ([0e2328b](https://github.com/Twigpine/node/commit/0e2328bd8044ce6e8923cc6bd42219068d3f6b9d))
|
||||
* **node:** map ipfs/arweave DB outages to 503 via bare ? ([#251](https://github.com/Twigpine/node/issues/251)) ([c29a91a](https://github.com/Twigpine/node/commit/c29a91af6b7eb444e202f4724e3f677d2ef25466))
|
||||
* **node:** mint a continuation when a ceiling stops the /ipfs scan mid-page ([69cfbd4](https://github.com/Twigpine/node/commit/69cfbd4b3ccde243ff669392820f0fce86381663))
|
||||
* **node:** never hand back a continuation that made no progress ([76f6300](https://github.com/Twigpine/node/commit/76f630008f0088c952755b643126affdf962d12b))
|
||||
* **node:** opaque AppError::Internal and AppError::Db HTTP bodies ([#226](https://github.com/Twigpine/node/issues/226)) ([241b366](https://github.com/Twigpine/node/commit/241b36633bca7e0ead33bf670b7c1531c3bd9ab5))
|
||||
* **node:** opaque AppError::Internal and AppError::Db HTTP bodies ([#226](https://github.com/Twigpine/node/issues/226)) ([d192028](https://github.com/Twigpine/node/commit/d192028ee1487a3638c545d6a39e58a4575df1f1))
|
||||
* **node:** opaque GraphQL DB error messages ([#250](https://github.com/Twigpine/node/issues/250)) ([c10ccf1](https://github.com/Twigpine/node/commit/c10ccf1f059eae18fcb0b7058a35bb134838b790))
|
||||
* **node:** order the CID candidate list so it cannot depend on heap order ([366b599](https://github.com/Twigpine/node/commit/366b59933112b345113501a65f0f30e76518ff61))
|
||||
* **node:** owner-gate create_task repo_id against hosted repos ([#541](https://github.com/Twigpine/node/issues/541)) ([bac49f1](https://github.com/Twigpine/node/commit/bac49f150a2b99d7b14fc9d53e231b7e743a753c))
|
||||
* **node:** persist the libp2p identity instead of deriving it from the node DID ([#324](https://github.com/Twigpine/node/issues/324)) ([c301a68](https://github.com/Twigpine/node/commit/c301a681619e890a081c385d7cbab5b8a8d4efbb))
|
||||
* **node:** port discovery's candidate load onto the paged repo query ([4c665fb](https://github.com/Twigpine/node/commit/4c665fbca8641f7dfe6770d1ee5384a0af3d215c))
|
||||
* **node:** preserve legacy peer compatibility ([547f9ae](https://github.com/Twigpine/node/commit/547f9aed10706bc617d0e1e2e5346cf52d19a901))
|
||||
* **node:** reconcile public-read messaging with per-repo enforcement ([#439](https://github.com/Twigpine/node/issues/439)) ([0366f18](https://github.com/Twigpine/node/commit/0366f18458dd996ea18057ce60abf88673e16ed4))
|
||||
* **node:** record a discovered source and its marker in one commit ([9babb5e](https://github.com/Twigpine/node/commit/9babb5ece053608c522f49b4528de23d5b61b749))
|
||||
* **node:** reject revision syntax in issue ids and drop service passthrough ([#521](https://github.com/Twigpine/node/issues/521)) ([421f03a](https://github.com/Twigpine/node/commit/421f03ac46592c57bc0df5a8fa79061c74d32fe6))
|
||||
* **node:** repair source-less legacy pins by bounded additive discovery ([d88c0c7](https://github.com/Twigpine/node/commit/d88c0c726c8630e104e0161366d1815d69e96428))
|
||||
* **node:** require Content-Digest on a signed request ([#306](https://github.com/Twigpine/node/issues/306)) ([189aad9](https://github.com/Twigpine/node/commit/189aad96e5c831f94cef654048d2d9797a5f574d))
|
||||
* **node:** resolve issue ids from git refnames, not request input ([#526](https://github.com/Twigpine/node/issues/526)) ([3063719](https://github.com/Twigpine/node/commit/30637193a626da800984363b6192fc50131b9741))
|
||||
* **node:** resume the /ipfs scan per candidate so a starved one is not skipped ([d0635d9](https://github.com/Twigpine/node/commit/d0635d95b7d601490474419c6bdb99b0ce402e71))
|
||||
* **node:** rewind the pin repair sweep cursor on every completed walk ([1b44b12](https://github.com/Twigpine/node/commit/1b44b12b468c265f946390a57cd815d67d205166))
|
||||
* **node:** rotate discovery candidates across traversals ([4fdfa1c](https://github.com/Twigpine/node/commit/4fdfa1cedb3dcb6d7d6cc4004e7277a9fa95de94))
|
||||
* **node:** scope bounty aggregates to anonymously-listable repos ([#477](https://github.com/Twigpine/node/issues/477)) ([#527](https://github.com/Twigpine/node/issues/527)) ([317a155](https://github.com/Twigpine/node/commit/317a1550c2810932118429b9987ea437a1465cf5))
|
||||
* **node:** scope the refs listing by the canonical repo slug ([#388](https://github.com/Twigpine/node/issues/388)) ([131bb95](https://github.com/Twigpine/node/commit/131bb95c686698d0f6a90dabd3976509df86b595))
|
||||
* **node:** size the ipfs work floor for provenance walks plus one full search ([dc5daa1](https://github.com/Twigpine/node/commit/dc5daa14b4d0816d8a14fabdf057259c1d177a46))
|
||||
* **node:** stop reporting a Kubo pin whose DB record never landed ([f839fb9](https://github.com/Twigpine/node/commit/f839fb924653f1b012c2b34d51260ea3faba7f62))
|
||||
* **node:** stop the /ipfs size probe reporting a fault as a missing object ([ed6355e](https://github.com/Twigpine/node/commit/ed6355ed2c42bed09dec35d46904805e97de806c))
|
||||
* **node:** use readiness for peer liveness ([225644a](https://github.com/Twigpine/node/commit/225644a3ea0302ae91959016928df1040ddd9cb5))
|
||||
* **node:** use readiness for peer liveness ([1a841a9](https://github.com/Twigpine/node/commit/1a841a99e2d40129a33f1898d3ce28d05eb04632))
|
||||
* **node:** validate git branch refs to close option injection ([#379](https://github.com/Twigpine/node/issues/379)) ([6233bd9](https://github.com/Twigpine/node/commit/6233bd9d850b60fdd2fb05afaa3d996d9f37f59d))
|
||||
* **node:** validate ref-update SHAs are hex, not just length 40 ([#433](https://github.com/Twigpine/node/issues/433)) ([911be4d](https://github.com/Twigpine/node/commit/911be4d9fef309e56117440d21e5dad7718be666))
|
||||
* **node:** version ref_certificates for forward compat ([#26](https://github.com/Twigpine/node/issues/26) split 3/4) ([#548](https://github.com/Twigpine/node/issues/548)) ([38bab41](https://github.com/Twigpine/node/commit/38bab4191baa5e8778f3412dc1f013d1f654bb15))
|
||||
* **release:** bump icaptcha-client with the rest of the workspace ([6668525](https://github.com/Twigpine/node/commit/6668525f41936ed2e1f8a1334b0dd17eaea866f0))
|
||||
* **repo_store:** use SHA-256 for stable advisory-lock key ([#210](https://github.com/Twigpine/node/issues/210)) ([c380f9c](https://github.com/Twigpine/node/commit/c380f9cee5139a2cab6fc52000d43844b86bb143))
|
||||
* **repo_store:** use SHA-256 for stable advisory-lock key ([#210](https://github.com/Twigpine/node/issues/210)) ([89e10d0](https://github.com/Twigpine/node/commit/89e10d0ff12dcccae3bd4490548d906f77d76636))
|
||||
* **review:** assert the read-timeout postcondition and drop the socket clone ([4d9bd5a](https://github.com/Twigpine/node/commit/4d9bd5a18a0692baa67f94aac8e8663d8e69bdac))
|
||||
* **review:** bound the probe's channel waits and stop overstating the handshake ([d4e5ecf](https://github.com/Twigpine/node/commit/d4e5ecf299afaabf05eee22db462b9314b5a6998))
|
||||
* **review:** bound token key, rule bytes, and the discovery load ([e6cfb58](https://github.com/Twigpine/node/commit/e6cfb588c2a1600d59f2af88471d248a99532f03))
|
||||
* **review:** keep the resume token on every terminal that still holds one ([8c39cfd](https://github.com/Twigpine/node/commit/8c39cfdcb347ca824cbec7d6b56720df9517a472))
|
||||
* **review:** make the coupled sites findable and pin two more properties ([a7e8e35](https://github.com/Twigpine/node/commit/a7e8e35030cae4e1126cda6c161bc516e3b8ffff))
|
||||
* **review:** make the LIKE escape independent of the SQL parser mode ([1d3987b](https://github.com/Twigpine/node/commit/1d3987bba6619a64a2cef15023224cbaa6cc8d1b)), closes [#319](https://github.com/Twigpine/node/issues/319)
|
||||
* **review:** mint a continuation at every ceiling and widen the token ([342dd48](https://github.com/Twigpine/node/commit/342dd48d2426a8870423cd537781b52d03e8f175))
|
||||
* **test:** match SHA-256 advisory-lock key in receive-pack deadline test ([c68e1b0](https://github.com/Twigpine/node/commit/c68e1b07f8db752cff786bdc9c226b49889a40b1))
|
||||
* **whoami:** update registered status handling on 404 response ([75b0df8](https://github.com/Twigpine/node/commit/75b0df8f1c7bda6a02444ce17e11088b839a51b6))
|
||||
|
||||
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
|
||||
|
||||
|
||||
|
||||
Generated
+9
-10
@@ -3085,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3470,7 +3470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.8.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
@@ -3485,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.8.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek 2.2.0",
|
||||
@@ -3502,7 +3502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.8.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
@@ -3530,7 +3530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.8.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3590,7 +3590,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.8.0"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3601,7 +3601,6 @@ dependencies = [
|
||||
"ed25519-dalek 2.2.0",
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"libc",
|
||||
"mockito",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
@@ -4058,7 +4057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icaptcha-client"
|
||||
version = "0.8.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
@@ -6229,7 +6228,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7163,7 +7162,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -70,7 +70,7 @@ Known limitations:
|
||||
- Repository write authorization is secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` only for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint a `did:key` and sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included.
|
||||
- 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.
|
||||
@@ -422,7 +422,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. |
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
---
|
||||
@@ -55,7 +55,7 @@ These are documented limitations of the current live release. They should be pri
|
||||
|
||||
### 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.
|
||||
- **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 — 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.
|
||||
|
||||
### UCAN delegation and revocation
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for Twigpine ref-update certs"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
+11
-228
@@ -41,30 +41,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 {
|
||||
@@ -162,15 +138,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 +169,21 @@ 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).
|
||||
/// signature from the same maintainer counts once.
|
||||
///
|
||||
/// 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.
|
||||
/// `threshold` must be at least 1. Zero is rejected instead of being
|
||||
/// treated as "no signatures required": a caller that derives the
|
||||
/// threshold from configuration and gets 0 would otherwise accept any
|
||||
/// certificate, whether its signatures verify or not.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
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,149 +416,6 @@ 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() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = 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;
|
||||
|
||||
@@ -84,7 +84,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,116 +104,6 @@ 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
|
||||
@@ -247,9 +137,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
|
||||
@@ -675,8 +616,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");
|
||||
}
|
||||
|
||||
+170
-2705
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}")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-2767
File diff suppressed because it is too large.
Load diff
@@ -634,12 +634,10 @@ mod tests {
|
||||
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
|
||||
.expect("lazy pool creation should not fail");
|
||||
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
|
||||
let task_cursor_key = crate::api::task_cursor::TaskCursorKey::derive(&keypair.to_seed());
|
||||
let schema = Arc::new(graphql::build_schema(
|
||||
db.clone(),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
task_cursor_key.clone(),
|
||||
));
|
||||
crate::state::AppState {
|
||||
config: Arc::new(Config::parse_from(["gitlawb-node"])),
|
||||
@@ -651,7 +649,6 @@ mod tests {
|
||||
ref_update_tx: ref_tx,
|
||||
task_event_tx: task_tx,
|
||||
graphql_schema: schema,
|
||||
task_cursor_key,
|
||||
machine_id: None,
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
@@ -687,7 +684,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ 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
|
||||
@@ -83,36 +81,8 @@ pub(crate) fn v2_signing_payload(
|
||||
|
||||
/// 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,92 +90,9 @@ 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
|
||||
@@ -217,9 +104,8 @@ async fn build_ref_certificate(
|
||||
|
||||
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(),
|
||||
@@ -490,16 +376,14 @@ mod v1_payload_tests {
|
||||
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");
|
||||
let cert =
|
||||
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
|
||||
.await
|
||||
.expect("issue_ref_certificate must succeed");
|
||||
|
||||
// The cert returned by the issuer is the source of truth
|
||||
// for both the version claim and the signed bytes.
|
||||
|
||||
@@ -152,46 +152,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.
|
||||
@@ -284,29 +244,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 +773,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 {
|
||||
@@ -960,13 +843,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!(
|
||||
@@ -1166,51 +1042,6 @@ pub(crate) fn validate_web_url(raw: &str) -> Result<(), String> {
|
||||
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 +1270,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);
|
||||
|
||||
+174
-4895
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -43,53 +43,6 @@ const WALK_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
#[cfg(unix)]
|
||||
const WATCHDOG_TERM_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Build a Git child command, optionally with an isolated environment for an
|
||||
/// owner-selected HTTPS destination. The isolated form intentionally drops
|
||||
/// ambient proxy, credential-helper, askpass, and Git config environment that
|
||||
/// could otherwise forward node-operator secrets to the selected upstream.
|
||||
fn git_child_command(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
isolated_https: bool,
|
||||
isolated_config: &[(&str, &str)],
|
||||
) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(git_bin);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if isolated_https {
|
||||
let path = std::env::var_os("PATH");
|
||||
command.env_clear();
|
||||
if let Some(path) = path {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
command
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env(
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
if cfg!(windows) { "NUL" } else { "/dev/null" },
|
||||
)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_ASKPASS", "false")
|
||||
.env("SSH_ASKPASS", "false")
|
||||
.env("LANG", "C")
|
||||
.env("LC_ALL", "C");
|
||||
if !isolated_config.is_empty() {
|
||||
command.env("GIT_CONFIG_COUNT", isolated_config.len().to_string());
|
||||
for (index, (key, value)) in isolated_config.iter().enumerate() {
|
||||
command
|
||||
.env(format!("GIT_CONFIG_KEY_{index}"), key)
|
||||
.env(format!("GIT_CONFIG_VALUE_{index}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
/// Run one git child under a shared `deadline` with process-group teardown,
|
||||
/// BLOCKING, and return its stdout. The child runs in its own process group; a
|
||||
/// watchdog thread SIGTERMs (lets git clean up its `*.lock` files), then SIGKILLs,
|
||||
@@ -128,22 +81,24 @@ fn child_terminated_without_reaping(pid: i32) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_bounded_git_raw_impl(
|
||||
pub(crate) fn run_bounded_git_raw(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
isolated_https: bool,
|
||||
isolated_config: &[(&str, &str)],
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
|
||||
let label = args.first().copied().unwrap_or("git");
|
||||
let mut command = git_child_command(git_bin, args, repo_path, isolated_https, isolated_config);
|
||||
let mut child = command
|
||||
let mut child = std::process::Command::new(git_bin)
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {label}"))?;
|
||||
@@ -253,51 +208,6 @@ fn run_bounded_git_raw_impl(
|
||||
Ok((status, out, err))
|
||||
}
|
||||
|
||||
pub(crate) fn run_bounded_git_raw(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
run_bounded_git_raw_impl(git_bin, args, repo_path, stdin_bytes, deadline, false, &[])
|
||||
}
|
||||
|
||||
/// Run a bounded Git child without inheriting ambient Git/proxy/credential
|
||||
/// configuration. Reserved for owner-selected external HTTPS destinations;
|
||||
/// ordinary local Git operations retain their existing environment.
|
||||
pub(crate) fn run_bounded_git_raw_isolated_https(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
run_bounded_git_raw_impl(git_bin, args, repo_path, stdin_bytes, deadline, true, &[])
|
||||
}
|
||||
|
||||
/// Isolated HTTPS runner with command-scope Git configuration supplied as
|
||||
/// separate key/value environment entries. Unlike `git -c key=value`, this
|
||||
/// preserves valid URL subsection keys whose path contains `=`.
|
||||
pub(crate) fn run_bounded_git_raw_isolated_https_with_config(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
config: &[(&str, &str)],
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
run_bounded_git_raw_impl(
|
||||
git_bin,
|
||||
args,
|
||||
repo_path,
|
||||
stdin_bytes,
|
||||
deadline,
|
||||
true,
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
/// Bounded git returning only stdout, `bail!`ing on any nonzero exit. The thin
|
||||
/// wrapper the walk callers use. Probes that must distinguish exit classes —
|
||||
/// `git cat-file` absence vs an object-store access failure — call
|
||||
@@ -331,20 +241,23 @@ pub(crate) fn run_bounded_git(
|
||||
/// the Unix version's signature and result semantics so every caller compiles on all
|
||||
/// targets (#174).
|
||||
#[cfg(not(unix))]
|
||||
fn run_bounded_git_raw_impl(
|
||||
pub(crate) fn run_bounded_git_raw(
|
||||
git_bin: &str,
|
||||
args: &[&str],
|
||||
repo_path: &Path,
|
||||
stdin_bytes: &[u8],
|
||||
deadline: Instant,
|
||||
isolated_https: bool,
|
||||
isolated_config: &[(&str, &str)],
|
||||
) -> Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
|
||||
let label = args.first().copied().unwrap_or("git");
|
||||
let mut child = git_child_command(git_bin, args, repo_path, isolated_https, isolated_config)
|
||||
let mut child = std::process::Command::new(git_bin)
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {label}"))?;
|
||||
|
||||
@@ -421,111 +334,6 @@ pub(crate) fn run_bounded_git(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fail closed unless every ref ultimately resolves to a commit (a ref pointing
|
||||
/// directly at a blob or tree, or an annotated tag — even a nested one — of such
|
||||
/// an object is refused). `git rev-list --all` silently *skips* such refs, but
|
||||
/// `git upload-pack` (serve) and the whole-repo pin fallback
|
||||
/// (`git cat-file --batch-all-objects`) still expose their target object, so a
|
||||
/// tolerant walk would under-withhold. Refuse rather than leak.
|
||||
///
|
||||
/// Each ref is peeled fully with `<ref>^{}` through `git cat-file --batch-check`.
|
||||
/// Full peeling is why this is not `for-each-ref %(*objecttype)`, which
|
||||
/// dereferences only one tag level and so misclassifies a tag-of-a-tag-of-a-
|
||||
/// commit as a non-commit.
|
||||
///
|
||||
/// Internal node-managed refs are exempt from this check since they point
|
||||
/// to blobs by design, not repository content: request markers
|
||||
/// (`refs/gitlawb/requests/*`, per-push marker blobs) and issue records
|
||||
/// (`refs/gitlawb/issues/*`, JSON blobs). The exemption lists exactly
|
||||
/// those two namespaces: any other `refs/gitlawb/*` ref or non-commit
|
||||
/// content ref still fails closed. Pushes into `refs/gitlawb/*` are
|
||||
/// refused at the push edge, so no new bypass ref can appear; the
|
||||
/// visibility logic only cares about content refs, not these two
|
||||
/// internal metadata namespaces whose binding and advertisement hiding
|
||||
/// are verified on the push path.
|
||||
fn assert_all_refs_are_commits(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<()> {
|
||||
let refs_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["for-each-ref", "--format=%(refname)"],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let refs_stdout = String::from_utf8_lossy(&refs_out);
|
||||
let refnames: Vec<&str> = refs_stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
if refnames.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Filter out only the two node-managed blob namespaces before checking
|
||||
// object types. Scoped to `refs/gitlawb/requests/` and
|
||||
// `refs/gitlawb/issues/` — not all of `refs/gitlawb/*` — so any
|
||||
// other internal or content ref pointing at a non-commit still
|
||||
// fails closed.
|
||||
let content_refs: Vec<&str> = refnames
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
!r.starts_with("refs/gitlawb/requests/") && !r.starts_with("refs/gitlawb/issues/")
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if content_refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Peel every content ref in one `git cat-file --batch-check` pass: one `<refname>^{}`
|
||||
// query per line, one output line per input line, in order. cat-file echoes the
|
||||
// full query on a `<query> missing` line, so output scales with refname length;
|
||||
// run_bounded_git drains stdout concurrently with the stdin write, so the pipe
|
||||
// cannot deadlock, and the whole peel is bounded by the shared walk deadline.
|
||||
let queries = content_refs
|
||||
.iter()
|
||||
.map(|r| format!("{r}^{{}}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let peel_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "--batch-check=%(objecttype)"],
|
||||
repo_path,
|
||||
queries.as_bytes(),
|
||||
deadline,
|
||||
)?;
|
||||
|
||||
let peel_stdout = String::from_utf8_lossy(&peel_out);
|
||||
let types: Vec<&str> = peel_stdout.lines().map(str::trim).collect();
|
||||
// A short read means at least one ref went unclassified — fail closed.
|
||||
if types.len() != content_refs.len() {
|
||||
anyhow::bail!(
|
||||
"git cat-file returned {} lines for {} refs; \
|
||||
refusing to produce a partial (under-withheld) set",
|
||||
types.len(),
|
||||
content_refs.len()
|
||||
);
|
||||
}
|
||||
for (refname, kind) in content_refs.iter().zip(types.iter()) {
|
||||
// git emits `<query> missing` (not the objecttype) when the peel target
|
||||
// is absent; the status word is the last token.
|
||||
if kind.split_ascii_whitespace().last() == Some("missing") {
|
||||
anyhow::bail!(
|
||||
"ref {refname} does not resolve to an object; \
|
||||
refusing to produce a partial (under-withheld) set"
|
||||
);
|
||||
}
|
||||
if *kind != "commit" {
|
||||
anyhow::bail!(
|
||||
"ref {refname} resolves to a {kind}, not a commit; \
|
||||
refusing to produce a partial (under-withheld) set"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List every (blob_oid, "/repo/relative/path") pair reachable from any commit in
|
||||
/// `repo_path` — every ref *and* every historical commit those refs reach, not just
|
||||
/// the ref tips. `git upload-pack` (serve) and the whole-repo pin fallback
|
||||
@@ -1616,17 +1424,84 @@ fn pair_decision(
|
||||
}
|
||||
}
|
||||
|
||||
/// Root tree oid of every reachable commit, at "/". `ls-tree` never emits a commit's
|
||||
/// own root tree (it lists entries *under* a tree), so it is added explicitly here.
|
||||
/// Resolved in ONE bounded `git log --no-walk --format=%T --stdin` pass over the
|
||||
/// shared commit set — not a per-commit `rev-parse` — so a tree-set walk costs the
|
||||
/// same subprocess order as the blob walk. The commit oids go on STDIN, not argv: a
|
||||
/// long history has tens of thousands of reachable commits, and passing them all as
|
||||
/// arguments overflows ARG_MAX so `git log` fails to spawn — which the caller treats
|
||||
/// as a walk error and fail-closed 404s an authorized reader of a reachable/root
|
||||
/// tree (#173 P2). `run_bounded_git` drains stdout concurrently with the stdin
|
||||
/// write, so a large history cannot deadlock the pipes. A commit whose root tree git
|
||||
/// cannot resolve fails the pass (bail), failing closed.
|
||||
/// Reachable blob AND withheld-subtree tree OIDs the anonymous (or `caller`)
|
||||
/// replication audience must not receive. The single source for
|
||||
/// `replication_withheld_set`: a blob-only set handed to [`replicable_objects`]
|
||||
/// reintroduces #172 (withheld trees pin to public IPFS).
|
||||
///
|
||||
/// Strict where [`tree_paths`] is lenient. This feeds a withheld filter, so
|
||||
/// absence means replicate; a missed reachable tree would leak. The walk
|
||||
/// therefore matches [`blob_paths`]: [`assert_all_refs_are_commits`] first, then
|
||||
/// a failing `rev-list --all [HEAD]` is an error, then [`object_paths`] (already
|
||||
/// fail-closed on ls-tree / non-UTF-8) plus [`root_tree_pairs`]. [`tree_paths`]
|
||||
/// stays the lenient allow-list walk for GET /ipfs/{cid} and the full-scan
|
||||
/// allowed-tree set, where absence withholds.
|
||||
fn assert_all_refs_are_commits(repo_path: &Path, git_bin: &str, deadline: Instant) -> Result<()> {
|
||||
let refs_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["for-each-ref", "--format=%(refname)"],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
let refs_stdout = String::from_utf8_lossy(&refs_out);
|
||||
let refnames: Vec<&str> = refs_stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
if refnames.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Peel every ref in one `git cat-file --batch-check` pass: one `<refname>^{}`
|
||||
// query per line, one output line per input line, in order. cat-file echoes the
|
||||
// full query on a `<query> missing` line, so output scales with refname length;
|
||||
// run_bounded_git drains stdout concurrently with the stdin write, so the pipe
|
||||
// cannot deadlock, and the whole peel is bounded by the shared walk deadline.
|
||||
let queries = refnames
|
||||
.iter()
|
||||
.map(|r| format!("{r}^{{}}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let peel_out = run_bounded_git(
|
||||
git_bin,
|
||||
&["cat-file", "--batch-check=%(objecttype)"],
|
||||
repo_path,
|
||||
queries.as_bytes(),
|
||||
deadline,
|
||||
)?;
|
||||
|
||||
let peel_stdout = String::from_utf8_lossy(&peel_out);
|
||||
let types: Vec<&str> = peel_stdout.lines().map(str::trim).collect();
|
||||
// A short read means at least one ref went unclassified — fail closed.
|
||||
if types.len() != refnames.len() {
|
||||
anyhow::bail!(
|
||||
"git cat-file returned {} lines for {} refs; \
|
||||
refusing to produce a partial (under-withheld) set",
|
||||
types.len(),
|
||||
refnames.len()
|
||||
);
|
||||
}
|
||||
for (refname, kind) in refnames.iter().zip(types.iter()) {
|
||||
// git emits `<query> missing` (not the objecttype) when the peel target
|
||||
// is absent; the status word is the last token.
|
||||
if kind.split_ascii_whitespace().last() == Some("missing") {
|
||||
anyhow::bail!(
|
||||
"ref {refname} does not resolve to an object; \
|
||||
refusing to produce a partial (under-withheld) set"
|
||||
);
|
||||
}
|
||||
if *kind != "commit" {
|
||||
anyhow::bail!(
|
||||
"ref {refname} resolves to a {kind}, not a commit; \
|
||||
refusing to produce a partial (under-withheld) set"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn root_tree_pairs(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
@@ -1658,18 +1533,6 @@ fn root_tree_pairs(
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Reachable blob AND withheld-subtree tree OIDs the anonymous (or `caller`)
|
||||
/// replication audience must not receive. The single source for
|
||||
/// `replication_withheld_set`: a blob-only set handed to [`replicable_objects`]
|
||||
/// reintroduces #172 (withheld trees pin to public IPFS).
|
||||
///
|
||||
/// Strict where [`tree_paths`] is lenient. This feeds a withheld filter, so
|
||||
/// absence means replicate; a missed reachable tree would leak. The walk
|
||||
/// therefore matches [`blob_paths`]: [`assert_all_refs_are_commits`] first, then
|
||||
/// a failing `rev-list --all [HEAD]` is an error, then [`object_paths`] (already
|
||||
/// fail-closed on ls-tree / non-UTF-8) plus [`root_tree_pairs`]. [`tree_paths`]
|
||||
/// stays the lenient allow-list walk for GET /ipfs/{cid} and the full-scan
|
||||
/// allowed-tree set, where absence withholds.
|
||||
pub fn withheld_object_oids_bounded(
|
||||
repo_path: &Path,
|
||||
git_bin: &str,
|
||||
@@ -6100,77 +5963,6 @@ esac\n";
|
||||
);
|
||||
}
|
||||
|
||||
/// A planted non-exempt `refs/gitlawb/*` blob ref fails the commit walk
|
||||
/// closed, while the two exempt namespaces (`requests/`, `issues/`) pass.
|
||||
/// The exemption narrowing removed a serving path, so this test pins the
|
||||
/// denial: widening the exemption back to all of `refs/gitlawb/*` turns
|
||||
/// the expect_err red. Real git, scratch bare repo.
|
||||
#[test]
|
||||
fn planted_non_exempt_gitlawb_blob_ref_fails_closed() {
|
||||
use std::time::{Duration, Instant};
|
||||
let td = TempDir::new().unwrap();
|
||||
let bare = td.path().join("repo.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");
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
run(&["init", "-q", "--bare", "."]);
|
||||
// A blob object to point refs at (node markers and issues are blobs).
|
||||
std::fs::write(bare.join("marker-body"), b"marker\n").unwrap();
|
||||
let blob = run_out(&["hash-object", "-w", "marker-body"]);
|
||||
let check = |want_ok: bool, why: &str| {
|
||||
let result =
|
||||
assert_all_refs_are_commits(&bare, "git", Instant::now() + Duration::from_secs(30));
|
||||
if want_ok {
|
||||
result.expect(why);
|
||||
} else {
|
||||
let err = result.expect_err(why);
|
||||
assert!(
|
||||
err.to_string().contains("refs/gitlawb/evil"),
|
||||
"the denial must name the offending ref, got: {err:#}"
|
||||
);
|
||||
}
|
||||
};
|
||||
// The bare `refs/gitlawb` name fails closed too (checked first: git
|
||||
// forbids a bare ref coexisting with the subtree below, D/F conflict).
|
||||
run(&["update-ref", "refs/gitlawb", &blob]);
|
||||
let result =
|
||||
assert_all_refs_are_commits(&bare, "git", Instant::now() + Duration::from_secs(30));
|
||||
let err = result.expect_err("bare refs/gitlawb blob ref must fail closed");
|
||||
assert!(
|
||||
err.to_string().contains("refs/gitlawb"),
|
||||
"the denial must name the offending ref, got: {err:#}"
|
||||
);
|
||||
run(&["update-ref", "-d", "refs/gitlawb", "--no-deref"]);
|
||||
// Exempt namespaces with blob targets pass (node-managed by design).
|
||||
run(&["update-ref", "refs/gitlawb/requests/r1", &blob]);
|
||||
run(&["update-ref", "refs/gitlawb/issues/i1", &blob]);
|
||||
check(true, "requests//issues/ blob refs are exempt and must pass");
|
||||
// A non-exempt blob ref fails closed.
|
||||
run(&["update-ref", "refs/gitlawb/evil", &blob]);
|
||||
check(false, "a non-exempt refs/gitlawb blob ref must fail closed");
|
||||
// Cleanup proves the denial is about that ref, not residue.
|
||||
run(&["update-ref", "-d", "refs/gitlawb/evil", "--no-deref"]);
|
||||
check(true, "removing the evil ref must restore the walk");
|
||||
}
|
||||
|
||||
/// #218 review round 9 (guidance #1 — single fail-closed
|
||||
/// classification contract): for every non-commit ref shape the
|
||||
/// parser can produce, the FOUR consumers of `(oid, path)` —
|
||||
|
||||
@@ -76,7 +76,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,81 +92,15 @@ pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a failed `claim_task` for GraphQL exactly as REST's claim handler
|
||||
/// classifies it: a lost race is a client-safe conflict, a real sqlx fault
|
||||
/// stays opaque. Lives here rather than at the three call sites so the
|
||||
/// classification cannot drift between transports (#327 review), and so
|
||||
/// `every_graphql_map_err_uses_opaque_helpers` can keep whitelisting by name.
|
||||
pub(crate) fn graphql_claim_conflict(e: anyhow::Error) -> async_graphql::Error {
|
||||
graphql_app_err(crate::api::tasks::task_write_conflict(
|
||||
e,
|
||||
"task not claimable: not found or already claimed",
|
||||
))
|
||||
}
|
||||
|
||||
/// The `finish_task` half of [`graphql_claim_conflict`], covering both
|
||||
/// `completeTask` and `failTask`.
|
||||
pub(crate) fn graphql_finish_conflict(e: anyhow::Error) -> async_graphql::Error {
|
||||
graphql_app_err(crate::api::tasks::task_write_conflict(
|
||||
e,
|
||||
"task not found or not in claimed state",
|
||||
))
|
||||
}
|
||||
|
||||
pub struct TaskReadBrakeExtension;
|
||||
|
||||
impl async_graphql::extensions::ExtensionFactory for TaskReadBrakeExtension {
|
||||
fn create(&self) -> Arc<dyn async_graphql::extensions::Extension> {
|
||||
Arc::new(TaskReadBrakeExtensionImpl)
|
||||
}
|
||||
}
|
||||
|
||||
use async_graphql::async_trait::async_trait;
|
||||
|
||||
struct TaskReadBrakeExtensionImpl;
|
||||
|
||||
#[async_trait]
|
||||
impl async_graphql::extensions::Extension for TaskReadBrakeExtensionImpl {
|
||||
async fn prepare_request(
|
||||
&self,
|
||||
ctx: &async_graphql::extensions::ExtensionContext<'_>,
|
||||
mut request: async_graphql::Request,
|
||||
next: async_graphql::extensions::NextPrepareRequest<'_>,
|
||||
) -> async_graphql::ServerResult<async_graphql::Request> {
|
||||
if let Some(session_brake) = ctx
|
||||
.session_data
|
||||
.get(&std::any::TypeId::of::<crate::rate_limit::TaskReadBrake>())
|
||||
.and_then(|d| d.downcast_ref::<crate::rate_limit::TaskReadBrake>())
|
||||
{
|
||||
request = request.data(crate::rate_limit::TaskReadBrake {
|
||||
limiter: session_brake.limiter.clone(),
|
||||
key: session_brake.key.clone(),
|
||||
request_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
});
|
||||
}
|
||||
if let Some(did) = ctx
|
||||
.session_data
|
||||
.get(&std::any::TypeId::of::<crate::auth::AuthenticatedDid>())
|
||||
.and_then(|d| d.downcast_ref::<crate::auth::AuthenticatedDid>())
|
||||
{
|
||||
request = request.data(did.clone());
|
||||
}
|
||||
next.run(ctx, request).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_schema(
|
||||
db: Arc<Db>,
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
task_cursor_key: crate::api::task_cursor::TaskCursorKey,
|
||||
) -> GitlawbSchema {
|
||||
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
|
||||
.data(db)
|
||||
.data(ref_update_tx)
|
||||
.data(task_event_tx)
|
||||
.data(task_cursor_key)
|
||||
.extension(TaskReadBrakeExtension)
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -259,7 +192,6 @@ mod tests {
|
||||
db,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,7 +217,6 @@ mod tests {
|
||||
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)
|
||||
@@ -382,10 +313,7 @@ mod tests {
|
||||
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(),
|
||||
db.list_tasks(Some("pending"), None, 8).await.unwrap().len(),
|
||||
8
|
||||
);
|
||||
continue;
|
||||
@@ -402,7 +330,7 @@ mod tests {
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_tasks_keyset(Some("claimed"), Some(caller), 8, None)
|
||||
db.list_tasks(Some("claimed"), Some(caller), 8)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
@@ -424,7 +352,7 @@ mod tests {
|
||||
// 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),
|
||||
("tasks", "id", MAX_VISIBLE_TASK_PAGE_SIZE),
|
||||
(
|
||||
"reposPage",
|
||||
"nodes { name }",
|
||||
@@ -667,12 +595,7 @@ mod tests {
|
||||
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]),
|
||||
);
|
||||
let schema = build_schema(Arc::new(Db::for_testing(pool)), ref_tx, task_tx);
|
||||
|
||||
// refUpdates(limit: MAX_VISIBLE_REF_UPDATES):
|
||||
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
|
||||
@@ -726,12 +649,12 @@ mod tests {
|
||||
);
|
||||
|
||||
// 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.
|
||||
// Single-field selection has child_complexity 1, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 1 <= 400.
|
||||
// It passes validation and reaches the resolver.
|
||||
let single_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ items {{ id }} }} }}"
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
@@ -751,14 +674,14 @@ mod tests {
|
||||
single_task.errors
|
||||
);
|
||||
|
||||
// Selection items { id status } has child_complexity 3, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 3 <= 400.
|
||||
// Two-field selection has child_complexity 2, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 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 }} }} }}"
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id status }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
@@ -920,10 +843,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through one of the curated helpers in this module, or discard the error
|
||||
/// (`|_|`). Same source-scrape pattern as `api::authz_guard` (#255
|
||||
/// review). The list is deliberately a whitelist of names rather than a
|
||||
/// prefix match, so adding a new mapper is a decision a reviewer sees.
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
#[test]
|
||||
fn every_graphql_map_err_uses_opaque_helpers() {
|
||||
for (file, src) in [
|
||||
@@ -938,8 +859,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!(
|
||||
|
||||
@@ -106,37 +106,25 @@ 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)?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
// Report the stored assignee form like REST, so subscribers see the
|
||||
// same identity whichever API surface ran the transition.
|
||||
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;
|
||||
let _ = tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
@@ -157,12 +145,12 @@ 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,
|
||||
@@ -175,25 +163,20 @@ impl MutationRoot {
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref(), caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_finish_conflict)?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
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;
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -214,12 +197,11 @@ 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,
|
||||
@@ -233,25 +215,20 @@ impl MutationRoot {
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason), caller)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_finish_conflict)?;
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
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;
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -349,7 +326,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 +370,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 +392,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 +409,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)
|
||||
);
|
||||
|
||||
@@ -764,7 +482,7 @@ mod tests {
|
||||
// repo id afterwards.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks_keyset(None, None, 50, None)
|
||||
.list_tasks(None, None, 50)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
@@ -926,11 +644,9 @@ mod tests {
|
||||
.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}"
|
||||
errs.contains("reserved"),
|
||||
"stranger claim must surface reserved deny distinctly: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("gql-ucan-secret"),
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{Db, RepoRecord, MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
|
||||
use super::types::{AgentTaskReadType, RefUpdateType, RepoPageType, RepoType, TaskPageType};
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoPageType, RepoType};
|
||||
|
||||
fn repo_type(repo: RepoRecord) -> RepoType {
|
||||
RepoType {
|
||||
@@ -45,23 +45,6 @@ fn parse_repo_cursor(cursor: &str) -> Result<(String, String)> {
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
/// Debit the per-IP task-read brake for one task field, mirroring the
|
||||
/// `rate_limit_by_ip` layer on `task_read_routes`. Both surfaces run the same
|
||||
/// #268 visibility gate and pay the same queries before answering, so an
|
||||
/// anonymous prober must not get an unbraked lane by asking over GraphQL
|
||||
/// (#327 review).
|
||||
///
|
||||
/// A schema built without the brake (unit tests, subscriptions) is not braked,
|
||||
/// exactly as `rate_limit_by_ip` is a no-op without its extension.
|
||||
async fn task_read_brake(ctx: &Context<'_>, field: &str) -> Result<()> {
|
||||
match ctx.data::<crate::rate_limit::TaskReadBrake>() {
|
||||
Ok(brake) if !brake.check(field).await => Err(async_graphql::Error::new(
|
||||
crate::rate_limit::RATE_LIMIT_MESSAGE,
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
// DB-backed roots carry a base cost so aliases consume the request budget
|
||||
@@ -234,74 +217,30 @@ impl QueryRoot {
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0. Use `nextCursor` to read past the clamp."
|
||||
desc = "Requests above the documented maximum are clamped to it (no error). Negative values clamp to 0."
|
||||
)]
|
||||
limit: i64,
|
||||
#[graphql(
|
||||
desc = "Opaque continuation token from a previous page's `nextCursor`. Must be presented with the same `status`/`assigneeDid` filter that issued it."
|
||||
)]
|
||||
cursor: Option<String>,
|
||||
) -> Result<TaskPageType> {
|
||||
use crate::api::task_cursor::{self, TaskCursorKey, TaskFilter};
|
||||
|
||||
task_read_brake(ctx, "tasks").await?;
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// #268: gate rows via the same collector the REST list route uses (like
|
||||
// `ref_updates` shares `collect_visible_ref_updates` with its REST feed),
|
||||
// so the two surfaces cannot drift. The collector clamps `limit` itself,
|
||||
// including the negative-LIMIT case #250 called out for this resolver.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let key = ctx.data_unchecked::<TaskCursorKey>();
|
||||
let filter = TaskFilter {
|
||||
status: status.as_deref(),
|
||||
assignee_did: assignee_did.as_deref(),
|
||||
};
|
||||
let resume = cursor
|
||||
.as_deref()
|
||||
.map(|token| task_cursor::decode(key, filter, caller, token))
|
||||
.transpose()
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
let result = crate::api::tasks::collect_visible_tasks(
|
||||
db,
|
||||
status.as_deref(),
|
||||
assignee_did.as_deref(),
|
||||
limit,
|
||||
resume.as_ref(),
|
||||
caller,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
Ok(TaskPageType {
|
||||
next_cursor: result
|
||||
.next_position
|
||||
.as_ref()
|
||||
.map(|pos| task_cursor::encode(key, filter, caller, pos)),
|
||||
items: result
|
||||
.tasks
|
||||
.into_iter()
|
||||
.map(AgentTaskReadType::from)
|
||||
.collect(),
|
||||
has_more: result.has_more,
|
||||
incomplete: result.incomplete,
|
||||
})
|
||||
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
|
||||
// rejects with 2201W, which would otherwise trip the opaque DB path
|
||||
// and write an error-level log on every probe (#250 review).
|
||||
let limit = limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskReadType>> {
|
||||
task_read_brake(ctx, "task").await?;
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// #268: same gate as the REST get route, via the shared helper.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let t = crate::api::tasks::get_visible_task(db, &id, caller)
|
||||
let t = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
Ok(t.map(AgentTaskReadType::from))
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
Ok(t.map(AgentTaskType::from))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,14 +889,10 @@ mod tests {
|
||||
Arc::new(db)
|
||||
}
|
||||
|
||||
fn cursor_key() -> crate::api::task_cursor::TaskCursorKey {
|
||||
crate::api::task_cursor::TaskCursorKey::derive(&[42u8; 32])
|
||||
}
|
||||
|
||||
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
super::super::build_schema(db, ref_tx, task_tx, cursor_key())
|
||||
super::super::build_schema(db, ref_tx, task_tx)
|
||||
}
|
||||
|
||||
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
|
||||
@@ -1267,7 +1202,7 @@ mod tests {
|
||||
async fn tasks_negative_limit_clamped(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: -1) { items { id } } }").await;
|
||||
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
@@ -1302,12 +1237,12 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
// Queried as the delegator, not anonymously: since #268 the task read
|
||||
// surface is visibility-gated, and an anonymous caller sees none of
|
||||
// these repo-less tasks at all. The clamp is what this test pins, so it
|
||||
// needs a caller who can legitimately see all 201 rows.
|
||||
let resp = authed(&schema, "{ tasks(limit: 5000) { items { id } } }", OWNER).await;
|
||||
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
|
||||
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
|
||||
assert_eq!(
|
||||
count_tasks(&resp),
|
||||
crate::db::MAX_VISIBLE_TASK_PAGE_SIZE as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_TASK_PAGE_SIZE"
|
||||
);
|
||||
}
|
||||
|
||||
/// A limit above MAX_VISIBLE_REF_UPDATES must reach the resolver clamped,
|
||||
@@ -1348,401 +1283,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed one repo-less task carrying a `ucan_token`, so a leak on any read
|
||||
/// surface is visible in the response body.
|
||||
async fn seed_task(db: &Db, id: &str, delegator: &str) {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: id.into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: delegator.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: Some("SECRET-UCAN-TOKEN".into()),
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// #268: the `tasks` resolver must delegate to the gated collector, not
|
||||
/// query the DB directly. A repo-less task belonging to someone else is
|
||||
/// invisible to an anonymous caller. `tasks_negative_limit_clamped` cannot
|
||||
/// catch a resolver that stops calling `collect_visible_tasks` because it
|
||||
/// seeds no rows — this seeds one, so the gate is load-bearing here.
|
||||
#[sqlx::test]
|
||||
async fn tasks_repo_less_task_hidden_from_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_task(&db, "t1", OWNER).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks { items { id } } }").await;
|
||||
assert_eq!(
|
||||
count_tasks(&resp),
|
||||
0,
|
||||
"anon must not enumerate another party's repo-less task"
|
||||
);
|
||||
assert!(
|
||||
!format!("{:?}", resp.data).contains("SECRET-UCAN-TOKEN"),
|
||||
"no ucan token may reach an anonymous caller"
|
||||
);
|
||||
}
|
||||
|
||||
/// #268 sibling for the single-task resolver: an invisible task reads as
|
||||
/// `null`, indistinguishable from one that does not exist.
|
||||
#[sqlx::test]
|
||||
async fn task_by_id_is_null_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_task(&db, "t1", OWNER).await;
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, r#"{ task(id: "t1") { id } }"#).await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
assert_eq!(
|
||||
obj.get("task"),
|
||||
Some(&async_graphql::Value::Null),
|
||||
"an invisible task must read as null, got {:?}",
|
||||
obj.get("task")
|
||||
);
|
||||
}
|
||||
|
||||
/// #268: `ucanToken` is absent from the read type's schema entirely, so the
|
||||
/// delegator cannot request it either. Asking for it is a validation error,
|
||||
/// which pins the redaction at the schema level rather than per-resolver.
|
||||
#[sqlx::test]
|
||||
async fn task_read_schema_has_no_ucan_token_field(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_task(&db, "t1", OWNER).await;
|
||||
let schema = schema(db);
|
||||
let resp = authed(&schema, r#"{ task(id: "t1") { id ucanToken } }"#, OWNER).await;
|
||||
assert!(
|
||||
!resp.errors.is_empty(),
|
||||
"ucanToken must not exist on the task read type"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn tasks_find_older_visible_row_behind_denied_window(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("public-repo", OWNER, "public", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let visible = crate::db::AgentTask {
|
||||
id: "visible".into(),
|
||||
repo_id: Some("public-repo".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: OWNER.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&visible).await.unwrap();
|
||||
for i in 0..200 {
|
||||
let mut hidden = visible.clone();
|
||||
hidden.id = format!("hidden-{i:03}");
|
||||
hidden.repo_id = None;
|
||||
hidden.created_at = "2026-01-02T00:00:00Z".into();
|
||||
hidden.updated_at = hidden.created_at.clone();
|
||||
db.create_task(&hidden).await.unwrap();
|
||||
}
|
||||
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 1) { items { id } incomplete } }").await;
|
||||
assert_eq!(count_tasks(&resp), 1);
|
||||
assert!(!task_incomplete(&resp));
|
||||
assert!(format!("{:?}", resp.data).contains("visible"));
|
||||
}
|
||||
|
||||
/// The GraphQL surface must page past a denied window on server-issued
|
||||
/// cursors alone, exactly as REST does (#327 review). Before this, the
|
||||
/// only cursor a caller could hold named the last row they saw, so the
|
||||
/// query stalled at `incomplete: true` forever and never reached
|
||||
/// `past-ceiling`.
|
||||
#[sqlx::test]
|
||||
async fn tasks_page_past_candidate_ceiling_on_server_cursors(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("public-repo", OWNER, "public", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let visible_newer = crate::db::AgentTask {
|
||||
id: "newer-visible".into(),
|
||||
repo_id: Some("public-repo".into()),
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: OWNER.into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: "2026-01-03T00:00:00Z".into(),
|
||||
updated_at: "2026-01-03T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&visible_newer).await.unwrap();
|
||||
for i in 0..1500 {
|
||||
let mut hidden = visible_newer.clone();
|
||||
hidden.id = format!("hidden-{i:04}");
|
||||
hidden.repo_id = None;
|
||||
hidden.created_at = "2026-01-02T00:00:00Z".into();
|
||||
hidden.updated_at = hidden.created_at.clone();
|
||||
db.create_task(&hidden).await.unwrap();
|
||||
}
|
||||
|
||||
let mut visible_older = visible_newer.clone();
|
||||
visible_older.id = "past-ceiling".into();
|
||||
visible_older.created_at = "2026-01-01T00:00:00Z".into();
|
||||
visible_older.updated_at = visible_older.created_at.clone();
|
||||
db.create_task(&visible_older).await.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let mut cursor: Option<String> = None;
|
||||
for request in 0..10 {
|
||||
let query = match &cursor {
|
||||
Some(c) => format!(
|
||||
r#"{{ tasks(limit: 1, cursor: "{c}") {{ items {{ id }} hasMore incomplete nextCursor }} }}"#
|
||||
),
|
||||
None => {
|
||||
"{ tasks(limit: 1) { items { id } hasMore incomplete nextCursor } }".to_string()
|
||||
}
|
||||
};
|
||||
let resp = anon(&schema, &query).await;
|
||||
let rendered = format!("{:?}", resp.data);
|
||||
assert!(
|
||||
!rendered.contains("hidden-"),
|
||||
"request {request} disclosed a denied row: {rendered}"
|
||||
);
|
||||
for item in task_items(&resp) {
|
||||
let async_graphql::Value::Object(row) = item else {
|
||||
panic!("task item not an object");
|
||||
};
|
||||
seen.push(row.get("id").unwrap().to_string().replace('"', ""));
|
||||
}
|
||||
// Short page with more behind it is the scan wall, and says so.
|
||||
if task_page_bool(&resp, "hasMore") && task_items(&resp).is_empty() {
|
||||
assert!(
|
||||
task_incomplete(&resp),
|
||||
"request {request}: an empty page with rows behind it is a paused scan"
|
||||
);
|
||||
}
|
||||
match task_page_cursor(&resp) {
|
||||
Some(c) => cursor = Some(c),
|
||||
None => {
|
||||
assert!(!task_page_bool(&resp, "hasMore"));
|
||||
assert!(!task_incomplete(&resp));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec!["newer-visible".to_string(), "past-ceiling".to_string()],
|
||||
"both visible rows must be reachable using only server-issued cursors"
|
||||
);
|
||||
}
|
||||
|
||||
/// REST and GraphQL mint the same tokens from the same node key, so a
|
||||
/// cursor must not be usable against a filter it was not issued for, and
|
||||
/// must render the same single rejection every other bad cursor renders.
|
||||
#[sqlx::test]
|
||||
async fn tasks_rejects_unusable_cursor(pool: PgPool) {
|
||||
use crate::api::task_cursor::{self, TaskFilter, TaskPosition};
|
||||
|
||||
let db = db(pool).await;
|
||||
let schema = schema(db);
|
||||
|
||||
let wrong_filter = task_cursor::encode(
|
||||
&cursor_key(),
|
||||
TaskFilter {
|
||||
status: Some("pending"),
|
||||
assignee_did: None,
|
||||
},
|
||||
None,
|
||||
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
|
||||
);
|
||||
let foreign = task_cursor::encode(
|
||||
&crate::api::task_cursor::TaskCursorKey::derive(&[9u8; 32]),
|
||||
TaskFilter {
|
||||
status: None,
|
||||
assignee_did: None,
|
||||
},
|
||||
None,
|
||||
&TaskPosition::new("2026-01-01T00:00:00Z", "t1"),
|
||||
);
|
||||
|
||||
for (label, query) in [
|
||||
(
|
||||
"garbage",
|
||||
r#"{ tasks(cursor: "not-a-cursor") { items { id } } }"#.to_string(),
|
||||
),
|
||||
(
|
||||
"another node's key",
|
||||
format!(r#"{{ tasks(cursor: "{foreign}") {{ items {{ id }} }} }}"#),
|
||||
),
|
||||
(
|
||||
"different filter",
|
||||
format!(r#"{{ tasks(cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#),
|
||||
),
|
||||
] {
|
||||
let resp = anon(&schema, &query).await;
|
||||
assert_eq!(
|
||||
resp.errors.len(),
|
||||
1,
|
||||
"{label}: must be rejected, not treated as page one"
|
||||
);
|
||||
assert!(
|
||||
resp.errors[0].message.contains("invalid or expired cursor"),
|
||||
"{label}: unexpected message {:?}",
|
||||
resp.errors[0].message
|
||||
);
|
||||
}
|
||||
|
||||
let resp = anon(
|
||||
&schema,
|
||||
&format!(
|
||||
r#"{{ tasks(status: "pending", cursor: "{wrong_filter}") {{ items {{ id }} }} }}"#
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn tasks_anonymous_denial_hides_repoless_task_and_leaks_no_token(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let task = crate::db::AgentTask {
|
||||
id: "t1".into(),
|
||||
repo_id: None,
|
||||
kind: "code-review".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:z6MkDelegator".into(),
|
||||
assignee_did: None,
|
||||
capability: "agent:task".into(),
|
||||
ucan_token: Some("secret-ucan-token".into()),
|
||||
payload: Some("payload".into()),
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&task).await.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let query = "{ tasks { items { id } } }";
|
||||
let resp = anon(&schema, query).await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
let rendered = format!("{:?}", resp.data);
|
||||
assert!(!rendered.contains("secret-ucan-token"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn task_anonymous_denial_returns_null_and_leaks_no_token(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let task = crate::db::AgentTask {
|
||||
id: "t1".into(),
|
||||
repo_id: None,
|
||||
kind: "code-review".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:z6MkDelegator".into(),
|
||||
assignee_did: None,
|
||||
capability: "agent:task".into(),
|
||||
ucan_token: Some("secret-ucan-token".into()),
|
||||
payload: Some("payload".into()),
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
db.create_task(&task).await.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let query = r#"{ task(id: "t1") { id } }"#;
|
||||
let resp = anon(&schema, query).await;
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
assert_eq!(obj.get("task"), Some(&async_graphql::Value::Null));
|
||||
let rendered = format!("{:?}", resp.data);
|
||||
assert!(!rendered.contains("secret-ucan-token"));
|
||||
}
|
||||
|
||||
fn task_page_bool(resp: &async_graphql::Response, field: &str) -> bool {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
let async_graphql::Value::Boolean(value) = page.get(field).expect("field present") else {
|
||||
panic!("{field} not a bool");
|
||||
};
|
||||
*value
|
||||
}
|
||||
|
||||
fn task_page_cursor(resp: &async_graphql::Response) -> Option<String> {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
match page.get("nextCursor").expect("nextCursor key") {
|
||||
async_graphql::Value::Null => None,
|
||||
async_graphql::Value::String(c) => Some(c.clone()),
|
||||
other => panic!("nextCursor not a string: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_items(resp: &async_graphql::Response) -> &Vec<async_graphql::Value> {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
};
|
||||
let async_graphql::Value::List(rows) = page.get("items").expect("items key") else {
|
||||
panic!("items not a list");
|
||||
};
|
||||
rows
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
task_items(resp).len()
|
||||
}
|
||||
|
||||
fn task_incomplete(resp: &async_graphql::Response) -> bool {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(page) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not an object");
|
||||
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
|
||||
panic!("tasks not a list");
|
||||
};
|
||||
let async_graphql::Value::Boolean(incomplete) =
|
||||
page.get("incomplete").expect("incomplete key")
|
||||
else {
|
||||
panic!("incomplete not a bool");
|
||||
};
|
||||
*incomplete
|
||||
rows.len()
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ pub struct SubscriptionRoot;
|
||||
|
||||
#[Subscription]
|
||||
impl SubscriptionRoot {
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted under the
|
||||
/// `optional_signature` layer and carries optional caller identity, but this
|
||||
/// resolver does not inspect it — it relays whatever enters the broadcast
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted outside the
|
||||
/// `optional_signature` layer, so this resolver has NO caller identity and
|
||||
/// cannot gate per-subscriber — it relays whatever enters the broadcast
|
||||
/// channel to any anonymous client. Its visibility safety therefore rests
|
||||
/// entirely on the WRITE side: the push handler only sends a
|
||||
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
|
||||
|
||||
@@ -56,65 +56,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,
|
||||
|
||||
+52
-286
@@ -7,7 +7,6 @@ mod bootstrap;
|
||||
mod cert;
|
||||
mod config;
|
||||
mod db;
|
||||
mod durable_outbox;
|
||||
mod encrypted_pin;
|
||||
mod error;
|
||||
mod git;
|
||||
@@ -25,7 +24,6 @@ mod state;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod upstream_mirror;
|
||||
mod visibility;
|
||||
mod webhooks;
|
||||
|
||||
@@ -309,15 +307,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 +351,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
|
||||
@@ -463,7 +451,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,
|
||||
@@ -562,19 +549,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 +610,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,22 +667,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.
|
||||
{
|
||||
@@ -769,63 +725,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 +757,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)]
|
||||
@@ -1465,8 +1212,6 @@ mod rate_limiter_sweep_tests {
|
||||
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| {
|
||||
[
|
||||
@@ -1478,8 +1223,6 @@ mod rate_limiter_sweep_tests {
|
||||
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 +1240,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 +1465,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::{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use async_graphql::http::ALL_WEBSOCKET_PROTOCOLS;
|
||||
use async_graphql_axum::{GraphQLProtocol, GraphQLRequest, GraphQLResponse, GraphQLWebSocket};
|
||||
use axum::extract::{DefaultBodyLimit, WebSocketUpgrade};
|
||||
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::{
|
||||
extract::State,
|
||||
middleware,
|
||||
@@ -24,8 +23,6 @@ use crate::state::AppState;
|
||||
async fn graphql_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<axum::Extension<crate::auth::AuthenticatedDid>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
rate_limit::PeerAddr(peer): rate_limit::PeerAddr,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
// `optional_signature` attaches the verified DID when a signature is present.
|
||||
@@ -35,49 +32,9 @@ async fn graphql_handler(
|
||||
if let Some(axum::Extension(did)) = auth {
|
||||
inner = inner.data(did);
|
||||
}
|
||||
// The anonymous `tasks`/`task` resolvers run the same #268 visibility gate
|
||||
// as the REST read routes and cost the node the same queries, so they carry
|
||||
// the same per-IP brake. It rides as request data rather than a router layer
|
||||
// because /graphql is one endpoint for every operation — see `TaskReadBrake`
|
||||
// (#327 review). It debits before the gate runs, but unlike the REST layer
|
||||
// it sits inside `optional_signature`, so it brakes the gate's query cost
|
||||
// and not signature verification.
|
||||
inner = inner.data(rate_limit::TaskReadBrake {
|
||||
limiter: state.task_read_rate_limiter.clone(),
|
||||
key: rate_limit::client_key(&headers, peer, state.push_limiter_trust),
|
||||
request_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
});
|
||||
state.graphql_schema.execute(inner).await.into()
|
||||
}
|
||||
|
||||
async fn graphql_ws_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
rate_limit::PeerAddr(peer): rate_limit::PeerAddr,
|
||||
auth: Option<axum::Extension<auth::AuthenticatedDid>>,
|
||||
protocol: GraphQLProtocol,
|
||||
upgrade: WebSocketUpgrade,
|
||||
) -> axum::response::Response {
|
||||
let mut data = async_graphql::Data::default();
|
||||
if let Some(axum::Extension(did)) = auth {
|
||||
data.insert(did);
|
||||
}
|
||||
data.insert(rate_limit::TaskReadBrake {
|
||||
limiter: state.task_read_rate_limiter.clone(),
|
||||
key: rate_limit::client_key(&headers, peer, state.push_limiter_trust),
|
||||
request_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
});
|
||||
let schema = state.graphql_schema.as_ref().clone();
|
||||
upgrade
|
||||
.protocols(ALL_WEBSOCKET_PROTOCOLS)
|
||||
.on_upgrade(move |stream| {
|
||||
GraphQLWebSocket::new(stream, schema, protocol)
|
||||
.with_data(data)
|
||||
.serve()
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn graphql_playground() -> impl IntoResponse {
|
||||
axum::response::Html(async_graphql::http::playground_source(
|
||||
async_graphql::http::GraphQLPlaygroundConfig::new("/graphql")
|
||||
@@ -120,14 +77,17 @@ fn add_auth_layers(
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
// ── GraphQL routes ─────────────────────────────────────────────────────
|
||||
let schema = state.graphql_schema.as_ref().clone();
|
||||
let graphql_routes = Router::new()
|
||||
.route("/graphql", get(graphql_playground).post(graphql_handler))
|
||||
.route("/graphql/ws", get(graphql_ws_handler))
|
||||
// Attach the verified DID to /graphql and /graphql/ws when a signature is present.
|
||||
// Attach the verified DID to /graphql when a signature is present. The
|
||||
// layer covers only routes added before it, so /graphql/ws (added after,
|
||||
// read-only subscriptions) stays open.
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
))
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
let task_write_routes = add_auth_layers(
|
||||
@@ -140,31 +100,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
SIGNED_BODY_LIMIT,
|
||||
);
|
||||
|
||||
// ── Task routes (read — open, but scoped) ──────────────────────────────
|
||||
// `optional_signature` attaches the verified DID when a signature is present
|
||||
// so the handlers can identify the caller; the routes stay anonymous-reachable,
|
||||
// but each task/row is gated to its delegator, its assignee, or (for a
|
||||
// repo-scoped task) whoever can read that repo (#268 — these routes previously
|
||||
// carried no gate and no identity at all).
|
||||
// Both routes also carry a per-IP flood brake, mirroring `/ipfs/{cid}`: they are
|
||||
// anon-reachable and the gate above costs a task lookup plus deduped-repo and
|
||||
// visibility-rule queries *before* the opaque 404, so a prober pays nothing and
|
||||
// the node pays per request. The limiter is the outermost layer so a flood is
|
||||
// rejected before signature verification and the visibility queries run. The
|
||||
// extension MUST be attached or `rate_limit_by_ip` is a silent no-op.
|
||||
let task_read_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.task_read_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
// ── Task routes (read — open) ──────────────────────────────────────────
|
||||
let task_read_routes = Router::new()
|
||||
.route("/api/v1/tasks", get(tasks::list_tasks))
|
||||
.route("/api/v1/tasks/{id}", get(tasks::get_task))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(task_read_limiter));
|
||||
.route("/api/v1/tasks/{id}", get(tasks::get_task));
|
||||
|
||||
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
|
||||
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
|
||||
@@ -776,351 +715,13 @@ async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::test_state;
|
||||
use sqlx::PgPool;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn ws_send_text(stream: &mut tokio::net::TcpStream, text: &str) {
|
||||
let payload = text.as_bytes();
|
||||
let len = payload.len();
|
||||
let mut frame = Vec::new();
|
||||
frame.push(0x81);
|
||||
let mask = [0x12, 0x34, 0x56, 0x78];
|
||||
if len <= 125 {
|
||||
frame.push(0x80 | (len as u8));
|
||||
} else if len <= 65535 {
|
||||
frame.push(0x80 | 126);
|
||||
frame.extend_from_slice(&(len as u16).to_be_bytes());
|
||||
} else {
|
||||
frame.push(0x80 | 127);
|
||||
frame.extend_from_slice(&(len as u64).to_be_bytes());
|
||||
}
|
||||
frame.extend_from_slice(&mask);
|
||||
for (i, b) in payload.iter().enumerate() {
|
||||
frame.push(b ^ mask[i % 4]);
|
||||
}
|
||||
stream.write_all(&frame).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
}
|
||||
|
||||
async fn ws_recv_text(stream: &mut tokio::net::TcpStream) -> String {
|
||||
let mut header = [0u8; 2];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
let b1 = header[1];
|
||||
let masked = (b1 & 0x80) != 0;
|
||||
let mut len = (b1 & 0x7f) as usize;
|
||||
if len == 126 {
|
||||
let mut ext = [0u8; 2];
|
||||
stream.read_exact(&mut ext).await.unwrap();
|
||||
len = u16::from_be_bytes(ext) as usize;
|
||||
} else if len == 127 {
|
||||
let mut ext = [0u8; 8];
|
||||
stream.read_exact(&mut ext).await.unwrap();
|
||||
len = u64::from_be_bytes(ext) as usize;
|
||||
}
|
||||
let mask = if masked {
|
||||
let mut m = [0u8; 4];
|
||||
stream.read_exact(&mut m).await.unwrap();
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.read_exact(&mut payload).await.unwrap();
|
||||
if let Some(m) = mask {
|
||||
for (i, b) in payload.iter_mut().enumerate() {
|
||||
*b ^= m[i % 4];
|
||||
}
|
||||
}
|
||||
String::from_utf8(payload).unwrap()
|
||||
}
|
||||
|
||||
async fn ws_recv_op_frames(stream: &mut tokio::net::TcpStream, op_id: &str) -> Vec<String> {
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let text = ws_recv_text(stream).await;
|
||||
frames.push(text.clone());
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if v.get("id").and_then(|id| id.as_str()) == Some(op_id) {
|
||||
let msg_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if msg_type == "complete" || msg_type == "error" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
async fn connect_ws(addr: std::net::SocketAddr) -> tokio::net::TcpStream {
|
||||
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let req = format!(
|
||||
"GET /graphql/ws HTTP/1.1\r\n\
|
||||
Host: {}\r\n\
|
||||
Upgrade: websocket\r\n\
|
||||
Connection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
|
||||
Sec-WebSocket-Version: 13\r\n\
|
||||
Sec-WebSocket-Protocol: graphql-transport-ws\r\n\r\n",
|
||||
addr
|
||||
);
|
||||
stream.write_all(req.as_bytes()).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(resp.starts_with("HTTP/1.1 101 Switching Protocols"));
|
||||
|
||||
// Init connection
|
||||
ws_send_text(&mut stream, r#"{"type":"connection_init"}"#).await;
|
||||
let ack = ws_recv_text(&mut stream).await;
|
||||
assert!(ack.contains("connection_ack"));
|
||||
|
||||
stream
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn graphql_ws_task_query_enforces_per_request_field_limit(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = build_router(state);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut stream = connect_ws(addr).await;
|
||||
|
||||
// Query with 6 aliased task fields (exceeding MAX_GRAPHQL_TASK_READS_PER_REQUEST = 5).
|
||||
// limit: 1 keeps the document under GRAPHQL_MAX_COMPLEXITY so the 6th
|
||||
// field reaches the brake instead of failing complexity first.
|
||||
let query = r#"{"id":"1","type":"subscribe","payload":{"query":"query { f1: tasks(limit: 1) { items { id } } f2: tasks(limit: 1) { items { id } } f3: tasks(limit: 1) { items { id } } f4: tasks(limit: 1) { items { id } } f5: tasks(limit: 1) { items { id } } f6: tasks(limit: 1) { items { id } } }"}}"#;
|
||||
ws_send_text(&mut stream, query).await;
|
||||
let frames = ws_recv_op_frames(&mut stream, "1").await;
|
||||
let resp = frames.join("\n");
|
||||
assert!(
|
||||
resp.contains("rate limit exceeded"),
|
||||
"6th task field over WS must be braked: {resp}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn graphql_ws_task_query_enforces_per_ip_rate_limit(pool: PgPool) {
|
||||
let mut state = test_state(pool).await;
|
||||
// Restrict task read rate limiter to 1 request per 60s
|
||||
state.task_read_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(1, std::time::Duration::from_secs(60));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = build_router(state);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut stream = connect_ws(addr).await;
|
||||
|
||||
// First task query succeeds (or returns valid data)
|
||||
let query1 = r#"{"id":"1","type":"subscribe","payload":{"query":"query { tasks { items { id } } }"}}"#;
|
||||
ws_send_text(&mut stream, query1).await;
|
||||
let frames1 = ws_recv_op_frames(&mut stream, "1").await;
|
||||
let resp1 = frames1.join("\n");
|
||||
assert!(!resp1.contains("rate limit exceeded"));
|
||||
|
||||
// Second task query on the same connection hits per-IP limiter
|
||||
let query2 = r#"{"id":"2","type":"subscribe","payload":{"query":"query { tasks { items { id } } }"}}"#;
|
||||
ws_send_text(&mut stream, query2).await;
|
||||
let frames2 = ws_recv_op_frames(&mut stream, "2").await;
|
||||
let resp2 = frames2.join("\n");
|
||||
assert!(
|
||||
resp2.contains("rate limit exceeded"),
|
||||
"exceeded per-IP limiter over WS must return rate limit message: {resp2}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn graphql_ws_task_query_resets_field_budget_per_operation(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = build_router(state);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut stream = connect_ws(addr).await;
|
||||
|
||||
// Execute 6 sequential 1-field queries on the same connection.
|
||||
// Each operation must receive its own 5-field budget, so none are braked by the per-request limit.
|
||||
for i in 1..=6 {
|
||||
let id = i.to_string();
|
||||
let query = format!(
|
||||
r#"{{"id":"{id}","type":"subscribe","payload":{{"query":"query {{ tasks {{ items {{ id }} }} }}"}}}}"#
|
||||
);
|
||||
ws_send_text(&mut stream, &query).await;
|
||||
let frames = ws_recv_op_frames(&mut stream, &id).await;
|
||||
let resp = frames.join("\n");
|
||||
assert!(
|
||||
!resp.contains("rate limit exceeded"),
|
||||
"operation {id} on the same WS connection must have a fresh field budget: {resp}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_ws_signed(
|
||||
addr: std::net::SocketAddr,
|
||||
keypair: &gitlawb_core::identity::Keypair,
|
||||
) -> tokio::net::TcpStream {
|
||||
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let signed = gitlawb_core::http_sig::sign_request(keypair, "GET", "/graphql/ws", b"");
|
||||
let req = format!(
|
||||
"GET /graphql/ws HTTP/1.1\r\n\
|
||||
Host: {}\r\n\
|
||||
Upgrade: websocket\r\n\
|
||||
Connection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
|
||||
Sec-WebSocket-Version: 13\r\n\
|
||||
Sec-WebSocket-Protocol: graphql-transport-ws\r\n\
|
||||
content-digest: {}\r\n\
|
||||
signature-input: {}\r\n\
|
||||
signature: {}\r\n\r\n",
|
||||
addr, signed.content_digest, signed.signature_input, signed.signature
|
||||
);
|
||||
stream.write_all(req.as_bytes()).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
let resp = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(resp.starts_with("HTTP/1.1 101 Switching Protocols"));
|
||||
|
||||
// Init connection
|
||||
ws_send_text(&mut stream, r#"{"type":"connection_init"}"#).await;
|
||||
let ack = ws_recv_text(&mut stream).await;
|
||||
assert!(ack.contains("connection_ack"));
|
||||
|
||||
stream
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn graphql_ws_authenticated_query_accesses_private_task(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = build_router(state.clone());
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let delegator_did = keypair.did().to_string();
|
||||
|
||||
let task = crate::db::AgentTask {
|
||||
id: "ws-auth-task-01".into(),
|
||||
repo_id: None,
|
||||
delegator_did: delegator_did.clone(),
|
||||
kind: "test".into(),
|
||||
capability: "read".into(),
|
||||
status: "pending".into(),
|
||||
assignee_did: Some(delegator_did.clone()),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00Z".into(),
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.unwrap();
|
||||
|
||||
// 1. Unauthenticated WS connection cannot see the private task
|
||||
let mut unauth_stream = connect_ws(addr).await;
|
||||
let query = r#"{"id":"1","type":"subscribe","payload":{"query":"query { tasks { items { id } } }"}}"#;
|
||||
ws_send_text(&mut unauth_stream, query).await;
|
||||
let unauth_frames = ws_recv_op_frames(&mut unauth_stream, "1").await;
|
||||
let unauth_resp = unauth_frames.join("\n");
|
||||
assert!(
|
||||
!unauth_resp.contains("ws-auth-task-01"),
|
||||
"unauthenticated WS must not disclose private task: {unauth_resp}"
|
||||
);
|
||||
|
||||
// 2. Forged signature claiming the delegator DID must be rejected at handshake
|
||||
let attacker_keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let attacker_signed =
|
||||
gitlawb_core::http_sig::sign_request(&attacker_keypair, "GET", "/graphql/ws", b"");
|
||||
let forged_sig_input = attacker_signed
|
||||
.signature_input
|
||||
.replace(&attacker_keypair.did().to_string(), &delegator_did);
|
||||
let mut forged_stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let forged_req = format!(
|
||||
"GET /graphql/ws HTTP/1.1\r\n\
|
||||
Host: {}\r\n\
|
||||
Upgrade: websocket\r\n\
|
||||
Connection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
|
||||
Sec-WebSocket-Version: 13\r\n\
|
||||
Sec-WebSocket-Protocol: graphql-transport-ws\r\n\
|
||||
content-digest: {}\r\n\
|
||||
signature-input: {}\r\n\
|
||||
signature: {}\r\n\r\n",
|
||||
addr, attacker_signed.content_digest, forged_sig_input, attacker_signed.signature
|
||||
);
|
||||
forged_stream
|
||||
.write_all(forged_req.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
forged_stream.flush().await.unwrap();
|
||||
let mut forged_bytes = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !forged_bytes.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let n = forged_stream.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
forged_bytes.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
let forged_resp = String::from_utf8_lossy(&forged_bytes);
|
||||
assert!(
|
||||
forged_resp.starts_with("HTTP/1.1 401 Unauthorized"),
|
||||
"forged WS signature must be rejected with 401: {forged_resp}"
|
||||
);
|
||||
|
||||
// 3. Signed WS connection authenticates caller and retrieves the private task
|
||||
let mut auth_stream = connect_ws_signed(addr, &keypair).await;
|
||||
ws_send_text(&mut auth_stream, query).await;
|
||||
let auth_frames = ws_recv_op_frames(&mut auth_stream, "1").await;
|
||||
let auth_resp = auth_frames.join("\n");
|
||||
assert!(
|
||||
auth_resp.contains("ws-auth-task-01"),
|
||||
"signed WS must authenticate delegator and return private task: {auth_resp}"
|
||||
);
|
||||
}
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::db::RepoRecord;
|
||||
use crate::test_support::test_state;
|
||||
|
||||
/// Regression: anonymous callers must not see the pin/anchor index (#121, #134).
|
||||
#[sqlx::test]
|
||||
|
||||
@@ -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
|
||||
@@ -330,16 +326,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,7 +354,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -995,13 +715,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(())
|
||||
}
|
||||
|
||||
@@ -1022,11 +735,7 @@ async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> an
|
||||
])
|
||||
.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 +775,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)]
|
||||
@@ -2405,463 +2108,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,12 +86,10 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
let node_did = keypair.did();
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
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(),
|
||||
));
|
||||
AppState {
|
||||
config: Arc::new(Config::parse_from(["gitlawb-node"])),
|
||||
@@ -103,7 +101,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
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)),
|
||||
@@ -142,7 +139,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -1266,7 +1262,7 @@ mod tests {
|
||||
// repo id afterwards — a 403 that still writes is the injection.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks_keyset(None, None, 50, None)
|
||||
.list_tasks(None, None, 50)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
@@ -1625,11 +1621,11 @@ mod tests {
|
||||
let assignee = "did:key:zTASKASSIGNEEBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let stranger = "did:key:zTASKSTRANGERCCCCCCCCCCCCCCCCCCCCCCCCCCC";
|
||||
let state = test_state(pool).await;
|
||||
let repo = seed_repo(delegator, "task-pub-repo");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let mut t1 = seed_task("task-1", delegator);
|
||||
t1.repo_id = Some(repo.id);
|
||||
state.db.create_task(&t1).await.expect("seed task");
|
||||
state
|
||||
.db
|
||||
.create_task(&seed_task("task-1", delegator))
|
||||
.await
|
||||
.expect("seed task");
|
||||
// Assignee claims it: pending -> claimed, assignee_did = assignee.
|
||||
state
|
||||
.db
|
||||
@@ -1648,25 +1644,8 @@ mod tests {
|
||||
let uri = "/api/v1/tasks/task-1/complete";
|
||||
let body = || Body::from("{}");
|
||||
|
||||
// Stranger on an invisible (repo-less) task receives opaque 404 (no existence leak).
|
||||
let inv_task = seed_task("task-inv", delegator);
|
||||
state.db.create_task(&inv_task).await.unwrap();
|
||||
let inv_resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
stranger,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/task-inv/complete",
|
||||
body(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
inv_resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"an invisible task must 404 so existence is not leaked"
|
||||
);
|
||||
|
||||
// Stranger on a visible task is rejected by the authorization gate with exact 403.
|
||||
// Stranger (not the assignee) is rejected by the authorization gate, even
|
||||
// with the empty body that previously bypassed the binding. Exact 403.
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(stranger, Method::POST, uri, body()))
|
||||
.await
|
||||
@@ -1735,11 +1714,9 @@ mod tests {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// The claim gate hides existence: an ineligible claimer gets the same
|
||||
// 404 as a missing task, not a 403 that would reveal the reservation.
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::FORBIDDEN,
|
||||
"stranger must not steal a pre-assigned task, got {}",
|
||||
resp.status()
|
||||
);
|
||||
@@ -1905,7 +1882,7 @@ mod tests {
|
||||
);
|
||||
let listed_bare = state
|
||||
.db
|
||||
.list_tasks_keyset(Some("claimed"), Some(bare), 10, None)
|
||||
.list_tasks(Some("claimed"), Some(bare), 10)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -1914,14 +1891,12 @@ mod tests {
|
||||
);
|
||||
let listed_full = state
|
||||
.db
|
||||
.list_tasks_keyset(Some("claimed"), Some(&full), 10, None)
|
||||
.list_tasks(Some("claimed"), Some(&full), 10)
|
||||
.await
|
||||
.unwrap();
|
||||
// The keyset filter normalizes to the did:key short form, so the
|
||||
// claimer's full DID now matches the bare stored form too.
|
||||
assert!(
|
||||
listed_full.iter().any(|t| t.id == "task-form"),
|
||||
"normalized list by claimer's full DID must match the bare stored form"
|
||||
!listed_full.iter().any(|t| t.id == "task-form"),
|
||||
"exact list by claimer's full DID must miss the bare stored form"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1958,7 +1933,7 @@ mod tests {
|
||||
);
|
||||
let listed = state
|
||||
.db
|
||||
.list_tasks_keyset(Some("claimed"), Some(&padded), 10, None)
|
||||
.list_tasks(Some("claimed"), Some(&padded), 10)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -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,29 +76,8 @@ 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
|
||||
@@ -238,7 +92,6 @@ async fn fire_event_async_occurrence(
|
||||
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 +113,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,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The Twigpine CLI — identity management, node control, MCP server"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -29,7 +29,6 @@ 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",
|
||||
|
||||
+33
-26
@@ -99,27 +99,6 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
|
||||
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
|
||||
}
|
||||
|
||||
/// Load keypair from an optional directory override, distinguishing missing default
|
||||
/// identity (clean anonymous) from an explicit directory error or corrupt PEM.
|
||||
pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Keypair>> {
|
||||
if let Some(d) = dir {
|
||||
return load_keypair_from_dir(Some(d)).map(Some);
|
||||
}
|
||||
let base = match dirs::home_dir() {
|
||||
Some(h) => h.join(".gitlawb"),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let path = key_path(&base);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(pem) => Keypair::from_pem(&pem)
|
||||
.context("failed to load keypair from PEM")
|
||||
.map(Some),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(anyhow::Error::from(e)
|
||||
.context(format!("failed to read identity from {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_new(dir: Option<PathBuf>, force: bool) -> Result<()> {
|
||||
cmd_new_with_reader(dir, force, &mut std::io::stdin().lock()).await
|
||||
}
|
||||
@@ -152,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");
|
||||
@@ -213,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());
|
||||
@@ -264,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());
|
||||
|
||||
+15
-6
@@ -109,16 +109,16 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
|
||||
crate::secret_file::create_dir(&ucan_dir)?;
|
||||
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)?,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -232,13 +232,22 @@ fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::iden
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ mod protect;
|
||||
mod quickstart;
|
||||
mod register;
|
||||
mod repo;
|
||||
mod secret_file;
|
||||
mod star;
|
||||
mod status;
|
||||
mod sync;
|
||||
|
||||
+13
-384
@@ -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 {
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -640,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 {
|
||||
@@ -1121,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" => {
|
||||
@@ -1748,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;
|
||||
|
||||
@@ -1762,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]
|
||||
@@ -2499,86 +2208,6 @@ 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_is_42() {
|
||||
let tools = tool_definitions();
|
||||
|
||||
@@ -100,17 +100,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let ucan = payload["ucan"].as_str().unwrap_or("");
|
||||
if !ucan.is_empty() {
|
||||
crate::secret_file::create_dir(&dir)?;
|
||||
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(),
|
||||
)?;
|
||||
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}");
|
||||
@@ -236,15 +233,23 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_identity(dir: &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");
|
||||
|
||||
@@ -72,10 +72,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
crate::secret_file::write(
|
||||
&ucan_path,
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
tracing::debug!("saved UCAN to {}", ucan_path.display());
|
||||
}
|
||||
|
||||
@@ -111,7 +108,7 @@ fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
std::fs::create_dir_all(&base)?;
|
||||
Ok(base.join("ucan.json"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -90,7 +90,7 @@ async fn cmd_delegate(
|
||||
let encoded = ucan.encode()?;
|
||||
|
||||
if let Some(path) = out {
|
||||
crate::secret_file::write(&path, encoded.as_bytes())?;
|
||||
std::fs::write(&path, &encoded)?;
|
||||
println!("UCAN saved to {}", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "icaptcha-client"
|
||||
version = "0.8.0" # x-release-please-version
|
||||
version = "0.4.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Map a pull request's changed paths to the CI surfaces they touch, so each
|
||||
# check in pr-checks.yml and codeql.yml runs only when its inputs changed and
|
||||
# Rust jobs test only the affected crates.
|
||||
#
|
||||
# Usage:
|
||||
# git diff -z --name-only --no-renames BASE HEAD | scripts/ci-surfaces.sh
|
||||
# scripts/ci-surfaces.sh --all # every surface, whole workspace
|
||||
#
|
||||
# Input is NUL-terminated paths (`git diff -z`). Without -z, git C-quotes any
|
||||
# path with non-ASCII, tab, quote or newline characters ("crates/gl/\303\251.rs"),
|
||||
# which would match no surface and skip every job; newline-delimited input
|
||||
# also cannot carry a path that contains a newline. Input that does not end in
|
||||
# NUL (e.g. a plain `git diff --name-only`) is rejected rather than misread.
|
||||
# scripts/test-ci-surfaces.sh pins the mapping.
|
||||
#
|
||||
# Prints GITHUB_OUTPUT lines (key=value). Each surface runs only for the paths
|
||||
# listed for it below, so a path no surface lists (docs, README, infra/, any
|
||||
# other workflow) runs no PR Checks job. Within the Rust surface, a change this
|
||||
# script cannot attribute to specific crates widens rather than narrows: a
|
||||
# global Rust input or an unknown crate directory runs the Rust jobs on the
|
||||
# whole workspace. The CI wiring itself (pr-checks.yml, codeql.yml, this
|
||||
# script) runs every surface.
|
||||
# Non-PR events (push to main, merge_group, workflow_dispatch, schedule) are
|
||||
# expected to pass --all, so main and the merge queue keep full coverage.
|
||||
#
|
||||
# Runnable from anywhere in the repo.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
all=false
|
||||
if [ "${1:-}" = "--all" ]; then
|
||||
all=true
|
||||
elif [ "$#" -ne 0 ]; then
|
||||
echo "usage: $0 [--all] < NUL-terminated-changed-paths" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
changed=()
|
||||
if [ "$all" = false ]; then
|
||||
path=""
|
||||
while IFS= read -r -d '' path; do
|
||||
[ -n "$path" ] && changed+=("$path")
|
||||
done
|
||||
# read leaves any unterminated tail in $path.
|
||||
if [ -n "$path" ]; then
|
||||
echo "ci-surfaces.sh: input is not NUL-terminated; pipe \`git diff -z --name-only\`" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Workspace crates, derived from crates/*/Cargo.toml so a new crate is picked
|
||||
# up without editing this script. dir -> package name, and dir -> the crate
|
||||
# dirs it depends on through `path = "../<dir>"` (normal, dev, or build deps).
|
||||
declare -A pkg_of=() deps_of=()
|
||||
for manifest in "$ROOT"/crates/*/Cargo.toml; do
|
||||
dir="$(basename "$(dirname "$manifest")")"
|
||||
pkg_of[$dir]="$(sed -n 's/^name *= *"\([^"]*\)".*/\1/p' "$manifest" | head -n1)"
|
||||
deps_of[$dir]="$(sed -n 's#.*path *= *"\.\./\([^"/]*\)".*#\1#p' "$manifest" | tr '\n' ' ')"
|
||||
done
|
||||
|
||||
# Files outside crates/ that a crate compiles in (include_str!/include_bytes!),
|
||||
# path -> crate dir. A change to one is a change to that crate, so its tests
|
||||
# run. Candidates (keep those that resolve outside crates/<dir>/):
|
||||
# git grep -nE 'include_(str|bytes)!\("(\.\./){2,}' crates
|
||||
declare -A embedded_in=(
|
||||
[bootstrap-peers.json]=gitlawb-node
|
||||
)
|
||||
|
||||
# Mark a crate dir as touched; a dir this checkout does not know (removed or
|
||||
# renamed crate) widens the Rust jobs to the whole workspace.
|
||||
touch_crate() {
|
||||
if [ -n "${pkg_of[$1]:-}" ]; then
|
||||
touched[$1]=1
|
||||
else
|
||||
full_rust=true
|
||||
fi
|
||||
}
|
||||
|
||||
full_rust=false
|
||||
rust=false
|
||||
docker=false
|
||||
audit=false
|
||||
core_deps=false
|
||||
release_versioning=false
|
||||
release_tag=false
|
||||
cq_actions=false
|
||||
cq_js=false
|
||||
cq_rust=false
|
||||
cq_swift=false
|
||||
declare -A touched=()
|
||||
|
||||
for path in "${changed[@]}"; do
|
||||
case "$path" in
|
||||
# The CI wiring itself: validate everything it gates.
|
||||
.github/workflows/pr-checks.yml | .github/workflows/codeql.yml | scripts/ci-surfaces.sh)
|
||||
all=true ;;
|
||||
# Workspace-wide Rust inputs.
|
||||
Cargo.toml | Cargo.lock | .cargo/* | rust-toolchain | rust-toolchain.toml \
|
||||
| rustfmt.toml | .rustfmt.toml | clippy.toml | .clippy.toml)
|
||||
full_rust=true ;;
|
||||
crates/*/*)
|
||||
dir="${path#crates/}"
|
||||
touch_crate "${dir%%/*}" ;;
|
||||
*)
|
||||
if [ -n "${embedded_in[$path]:-}" ]; then
|
||||
touch_crate "${embedded_in[$path]}"
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
Dockerfile | .dockerignore | bootstrap-peers.json | Cargo.toml | Cargo.lock \
|
||||
| .cargo/* | rust-toolchain | rust-toolchain.toml | crates/*/Cargo.toml)
|
||||
docker=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
Cargo.lock | .cargo/audit.toml) audit=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
Cargo.toml | Cargo.lock | crates/gitlawb-core/Cargo.toml \
|
||||
| ci/gitlawb-core-allowed-deps.txt | scripts/check-gitlawb-core-deps.sh)
|
||||
core_deps=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
Cargo.toml | crates/*/Cargo.toml | release-please-config.json \
|
||||
| .release-please-manifest.json | scripts/check-release-versioning.sh)
|
||||
release_versioning=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
scripts/resolve-release-tag.sh | scripts/test-resolve-release-tag.sh)
|
||||
release_tag=true ;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
.github/workflows/* | .github/actions/*) cq_actions=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
npm/* | *.js | *.mjs | *.cjs | *.jsx | *.ts | *.mts | *.cts | *.tsx) cq_js=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
macos-app/* | *.swift) cq_swift=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$all" = true ]; then
|
||||
full_rust=true
|
||||
docker=true
|
||||
audit=true
|
||||
core_deps=true
|
||||
release_versioning=true
|
||||
release_tag=true
|
||||
cq_actions=true
|
||||
cq_js=true
|
||||
cq_swift=true
|
||||
fi
|
||||
|
||||
# Affected crates: the touched ones plus everything that depends on them,
|
||||
# transitively, so a gitlawb-core change still tests every consumer.
|
||||
declare -A affected=()
|
||||
for dir in "${!touched[@]}"; do affected[$dir]=1; done
|
||||
grew=true
|
||||
while [ "$grew" = true ]; do
|
||||
grew=false
|
||||
for dir in "${!deps_of[@]}"; do
|
||||
[ -n "${affected[$dir]:-}" ] && continue
|
||||
for dep in ${deps_of[$dir]}; do
|
||||
if [ -n "${affected[$dep]:-}" ]; then
|
||||
affected[$dir]=1
|
||||
grew=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$full_rust" = true ] || [ "${#affected[@]}" -gt 0 ]; then
|
||||
rust=true
|
||||
cq_rust=true
|
||||
fi
|
||||
|
||||
rust_packages=""
|
||||
windows_packages=""
|
||||
if [ "$full_rust" = true ]; then
|
||||
rust_packages="--workspace"
|
||||
windows_packages="-p gl -p git-remote-gitlawb"
|
||||
elif [ "$rust" = true ]; then
|
||||
for dir in $(printf '%s\n' "${!affected[@]}" | sort); do
|
||||
rust_packages+="${rust_packages:+ }-p ${pkg_of[$dir]}"
|
||||
case "${pkg_of[$dir]}" in
|
||||
gl | git-remote-gitlawb) windows_packages+="${windows_packages:+ }-p ${pkg_of[$dir]}" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
windows=false
|
||||
[ -n "$windows_packages" ] && windows=true
|
||||
|
||||
languages=()
|
||||
[ "$cq_actions" = true ] && languages+=('{"language":"actions","build-mode":"none","runner":"ubuntu-latest"}')
|
||||
[ "$cq_js" = true ] && languages+=('{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"}')
|
||||
[ "$cq_rust" = true ] && languages+=('{"language":"rust","build-mode":"none","runner":"ubuntu-latest"}')
|
||||
[ "$cq_swift" = true ] && languages+=('{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}')
|
||||
codeql_matrix="{\"include\":[$(IFS=,; echo "${languages[*]}")]}"
|
||||
codeql=false
|
||||
[ "${#languages[@]}" -gt 0 ] && codeql=true
|
||||
|
||||
cat <<EOF
|
||||
rust=$rust
|
||||
rust_packages=$rust_packages
|
||||
windows=$windows
|
||||
windows_packages=$windows_packages
|
||||
docker=$docker
|
||||
audit=$audit
|
||||
core_deps=$core_deps
|
||||
release_versioning=$release_versioning
|
||||
release_tag=$release_tag
|
||||
codeql=$codeql
|
||||
codeql_matrix=$codeql_matrix
|
||||
EOF
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fixture test for scripts/ci-surfaces.sh, which alone decides which PR Checks
|
||||
# jobs and CodeQL languages run. Pins at least one must-not-skip case per
|
||||
# surface row, the crate fan-out and widening rules, and paths that git would
|
||||
# C-quote without -z. Run from pr-checks.yml before the mapper is used.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
mapper="$repo_root/scripts/ci-surfaces.sh"
|
||||
failures=0
|
||||
|
||||
# map PATH... : run the mapper on the paths as `git diff -z` would emit them.
|
||||
map() {
|
||||
if [ "$#" -gt 0 ]; then printf '%s\0' "$@"; fi | "$mapper"
|
||||
}
|
||||
|
||||
# check CASE OUTPUT KEY=VALUE... : each KEY must have exactly VALUE.
|
||||
check() {
|
||||
local name="$1" out="$2" want key
|
||||
shift 2
|
||||
for want in "$@"; do
|
||||
key="${want%%=*}"
|
||||
if ! grep -qxF -- "$want" <<<"$out"; then
|
||||
printf 'FAIL %s: want %s, got %s\n' "$name" "$want" \
|
||||
"$(grep -E "^${key}=" <<<"$out" || echo '<missing>')" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# has_packages CASE OUTPUT PKG... : rust_packages must include each -p PKG.
|
||||
has_packages() {
|
||||
local name="$1" out="$2" pkgs pkg
|
||||
shift 2
|
||||
pkgs=" $(sed -n 's/^rust_packages=//p' <<<"$out") "
|
||||
for pkg in "$@"; do
|
||||
if [[ "$pkgs" != *" -p $pkg "* ]]; then
|
||||
printf 'FAIL %s: rust_packages lacks -p %s (got%s)\n' "$name" "$pkg" "$pkgs" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
nothing=(rust=false rust_packages= windows=false windows_packages= docker=false
|
||||
audit=false core_deps=false release_versioning=false release_tag=false
|
||||
codeql=false 'codeql_matrix={"include":[]}')
|
||||
everything=(rust=true rust_packages=--workspace windows=true
|
||||
'windows_packages=-p gl -p git-remote-gitlawb' docker=true audit=true
|
||||
core_deps=true release_versioning=true release_tag=true codeql=true)
|
||||
all_languages='codeql_matrix={"include":[{"language":"actions","build-mode":"none","runner":"ubuntu-latest"},{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"},{"language":"rust","build-mode":"none","runner":"ubuntu-latest"},{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
|
||||
rust_only='codeql_matrix={"include":[{"language":"rust","build-mode":"none","runner":"ubuntu-latest"}]}'
|
||||
|
||||
# Docs-only and empty input run nothing.
|
||||
check "docs only" "$(map README.md docs/x.md infra/x.tf)" "${nothing[@]}"
|
||||
check "empty input" "$(map)" "${nothing[@]}"
|
||||
|
||||
# A crate change tests that crate and its reverse deps, nothing else.
|
||||
check "gl source" "$(map crates/gl/src/main.rs)" \
|
||||
rust=true 'rust_packages=-p gl' windows=true 'windows_packages=-p gl' \
|
||||
docker=false audit=false "$rust_only"
|
||||
out="$(map crates/gitlawb-core/src/lib.rs)"
|
||||
check "gitlawb-core source" "$out" rust=true windows=true \
|
||||
'windows_packages=-p git-remote-gitlawb -p gl' "$rust_only"
|
||||
has_packages "gitlawb-core fan-out" "$out" gitlawb-core icaptcha-client \
|
||||
gitlawb-attest git-remote-gitlawb gl gitlawb-node
|
||||
check "icaptcha-client source" "$(map crates/icaptcha-client/src/lib.rs)" \
|
||||
'rust_packages=-p gl -p icaptcha-client' 'windows_packages=-p gl'
|
||||
|
||||
# Anything the Rust surface cannot attribute to known crates widens.
|
||||
check "unknown crate dir" "$(map crates/no-such-crate/src/lib.rs)" \
|
||||
rust=true rust_packages=--workspace docker=false
|
||||
check "root Cargo.lock" "$(map Cargo.lock)" \
|
||||
rust=true rust_packages=--workspace docker=true audit=true core_deps=true \
|
||||
release_versioning=false
|
||||
check "toolchain file" "$(map rust-toolchain.toml)" rust=true rust_packages=--workspace
|
||||
|
||||
# Files compiled into a crate from outside crates/.
|
||||
check "bootstrap-peers.json" "$(map bootstrap-peers.json)" \
|
||||
rust=true 'rust_packages=-p gitlawb-node' windows=false docker=true
|
||||
|
||||
# One must-not-skip trigger per non-Rust row.
|
||||
check "Dockerfile" "$(map Dockerfile)" docker=true rust=false
|
||||
check "crate manifest" "$(map crates/gl/Cargo.toml)" \
|
||||
docker=true release_versioning=true 'rust_packages=-p gl'
|
||||
check "audit config" "$(map .cargo/audit.toml)" audit=true rust_packages=--workspace
|
||||
check "core allowlist" "$(map ci/gitlawb-core-allowed-deps.txt)" core_deps=true rust=false
|
||||
check "release-please config" "$(map release-please-config.json)" \
|
||||
release_versioning=true rust=false
|
||||
check "release tag resolver" "$(map scripts/resolve-release-tag.sh)" release_tag=true rust=false
|
||||
check "other workflow" "$(map .github/workflows/release.yml)" "${nothing[@]:0:9}" \
|
||||
codeql=true 'codeql_matrix={"include":[{"language":"actions","build-mode":"none","runner":"ubuntu-latest"}]}'
|
||||
check "npm" "$(map npm/package.json)" codeql=true \
|
||||
'codeql_matrix={"include":[{"language":"javascript-typescript","build-mode":"none","runner":"ubuntu-latest"}]}'
|
||||
check "swift" "$(map macos-app/Sources/App.swift)" rust=false codeql=true \
|
||||
'codeql_matrix={"include":[{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
|
||||
|
||||
# Editing the CI wiring runs every surface, exactly like --all.
|
||||
for wiring in .github/workflows/pr-checks.yml .github/workflows/codeql.yml scripts/ci-surfaces.sh; do
|
||||
check "wiring $wiring" "$(map "$wiring")" "${everything[@]}" "$all_languages"
|
||||
done
|
||||
check "--all" "$("$mapper" --all </dev/null)" "${everything[@]}" "$all_languages"
|
||||
|
||||
# Names git C-quotes without -z (non-ASCII, tab, quote, newline) still map.
|
||||
for name in $'\xc3\xa9.rs' $'a\tb.rs' 'a"b.rs' $'a\nb.rs'; do
|
||||
check "quoted name $(printf %q "$name")" "$(map "crates/gl/src/$name")" \
|
||||
rust=true 'rust_packages=-p gl'
|
||||
done
|
||||
check "non-ASCII swift" "$(map $'macos-app/Sources/Caf\xc3\xa9.swift')" codeql=true \
|
||||
'codeql_matrix={"include":[{"language":"swift","build-mode":"autobuild","runner":"macos-latest"}]}'
|
||||
|
||||
# Newline-delimited input (git diff without -z) is rejected, not misread.
|
||||
if printf 'crates/gl/src/main.rs\n' | "$mapper" >/dev/null 2>&1; then
|
||||
echo "FAIL newline-delimited input: mapper accepted it" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [ "$failures" -ne 0 ]; then
|
||||
echo "$failures ci-surfaces mapping check(s) failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ci-surfaces mapping: all checks passed"
|
||||
Reference in new issue
Block a user