Compare commits

..
Author SHA1 Message Date
beardthelion 8bb36abe94 test(icaptcha-client): cover the insecure-flag resolver seam
resolve_solver_url passes icaptcha_insecure_enabled() into
resolve_solver_url_with, and that argument had no coverage in the
fail-open direction. Hardcoding it true restores the #227 bug (plain-http
loopback trusted as the solver origin unconditionally in release) while
leaving this crate at 23 passed and gl at 11 passed. Only the fail-closed
direction was caught, and only cross-crate via gl's mockito tests.

Add a guard driving the real-env path under EnvGuard. A loopback operator
URL is load-bearing: it is the only input whose verdict changes with the
flag, so it is the only input that can detect the seam being hardcoded.
One arm advertises a URL on the operator's own origin, because otherwise
nothing drives a non-None advertised argument through the env-reading
wrapper. All five seam mutations go RED: hardcoded true fails the unset
arm, hardcoded false fails the =1 arm, and forwarding None in place of
advertised, dropping operator, or swapping the two each fail as well.

Also convert the six resolver tests that called the env-reading
resolve_solver_url to pass the flag explicitly. EnvGuard writes
GITLAWB_ICAPTCHA_INSECURE under a mutex those tests never took, so
set_var ran concurrent with getenv across parallel test threads. None of
the six uses a loopback host, so their verdicts never depended on the
flag and no assertion could flake; the read was unsound rather than
wrong. Passing false preserves their meaning and leaves one env-touching
test in the binary, which also shrinks the edition-2024 set_var surface.

Test-only. No production lines change.
2026-07-28 15:38:27 -05:00
Ayush7614 45f3de1358 fix(icaptcha-client): cover real env read + fix warn spacing
Add a serialized test that drives icaptcha_insecure_enabled() against
the process env so presence-check / typo / always-true mutations fail.
Collapse the mid-sentence space run in the non-truthy warning (#246).
2026-07-27 19:26:59 +05:30
Ayush7614 16eaa3e068 fix(icaptcha-client): pure insecure flag helpers for #246 review
Warn on present non-Unicode GITLAWB_ICAPTCHA_INSECURE (fail closed).
Pass insecure mode into is_https_with / resolve_solver_url_with so
tests no longer mutate process-global env (removes InsecureEnv race).
2026-07-26 15:31:58 +05:30
Ayush7614 8e20002734 fix(icaptcha-client): do not log raw ICAPTCHA_INSECURE value
The non-truthy warn path previously recorded the full trimmed env
value. That is arbitrary deployment input; report only that the
setting is invalid (#246 review).
2026-07-26 15:24:57 +05:30
Ayush7614 7d218acd08 fix(icaptcha-client): address #246 review (trim test, fmt, lock)
Pin trim with a whitespace truthy case, recover from a poisoned
ICAPTCHA_ENV_LOCK, document trim + warn visibility, and cargo fmt.
2026-07-24 19:10:28 +05:30
Ayush7614 3346b0f014 fix(icaptcha-client): parse GITLAWB_ICAPTCHA_INSECURE as truthy only
Presence checks treated =0/=false/empty as enabled. Only explicit 1/true
now enable loopback HTTP trust relaxation (#227).
2026-07-24 03:10:55 +05:30
30 changed files with 360 additions and 11676 deletions

No files matched your search

+5 -12
View File
@@ -6,13 +6,6 @@ on:
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
# and the lock-sync job), and GitHub's recursion guard means those pushes
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
# the release branch to attach check runs to its head. Read-only workflow
# (contents: read), so the dispatch surface adds no privilege.
workflow_dispatch:
push:
branches:
- main
@@ -53,7 +46,7 @@ jobs:
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings
test:
name: test (${{ matrix.toolchain }})
@@ -101,7 +94,7 @@ jobs:
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
run: cargo test --workspace
build-release:
name: build --release
@@ -124,7 +117,7 @@ jobs:
key: release
- name: cargo build --release
run: cargo build --locked --release --workspace
run: cargo build --release --workspace
audit:
name: cargo audit
@@ -206,7 +199,7 @@ jobs:
key: msrv
- name: cargo check on MSRV
run: cargo check --locked --workspace --all-targets
run: cargo check --workspace --all-targets
docker-build:
name: Docker build smoke test
@@ -253,7 +246,7 @@ jobs:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test --locked -p gl -p git-remote-gitlawb
run: cargo test -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
+1 -139
View File
@@ -41,7 +41,6 @@ jobs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
pr: ${{ steps.release.outputs.pr }}
steps:
- name: Run release-please
id: release
@@ -49,143 +48,6 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
# The release PR bumps the workspace crate versions in the Cargo.tomls but
# release-please cannot update Cargo.lock, so without this job every release
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
# the release branch so the tagged tree is internally consistent.
#
# Runs on every push-triggered release run, not only when release-please
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
# the release PR, so gating on it would leave an already-open release PR
# (opened before this job existed, or untouched by a non-releasable push)
# permanently without the sync and without check runs. The resolve step
# discovers any open release-please PR itself and no-ops when there is none.
sync-release-lock:
name: Sync Cargo.lock on the release PR
needs: release-please
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
# actions: write lets the final step dispatch pr-checks on the release
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
# scope it to one workflow); acceptable here because reaching this job's
# steps already requires write to main. Needed because every push to the
# release branch (release-please's and the lock sync below) is made with
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
# dispatch the release PR's head carries no check runs and the --locked
# gate never validates it.
actions: write
steps:
# Prefer the fresh `pr` output when this run just created/updated the
# release PR; otherwise fall back to querying for an open release-please
# branch (prefix match: the branch carries `--components--` suffixes).
- name: Resolve the open release PR branch
id: resolve
env:
GH_TOKEN: ${{ github.token }}
OWNER: ${{ github.repository_owner }}
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
run: |
branch="$BRANCH_FROM_OUTPUT"
if [ -z "$branch" ]; then
# The branch name alone is not a trust signal. `gh pr list` includes
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
# prefix match by itself lets any fork nominate the branch this job
# checks out, commits to, and dispatches checks on. Require the head
# repo to be THIS repository and the author to be the release-please
# app, so neither a fork nor a same-repo human branch named
# `release-please--*` can be selected. Then take the highest PR
# number: `[0]` depended on gh's default ordering, which is not a
# documented guarantee, and picking a stable one keeps a second
# (component) release PR from making the choice flap between runs.
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
--json headRefName,headRepositoryOwner,number,author \
--jq '[ .[]
| select(.headRepositoryOwner.login == env.OWNER)
| select(.author.login == "app/github-actions")
| select(.headRefName | startswith("release-please--"))
] | max_by(.number).headRefName // empty')
fi
if [ -z "$branch" ]; then
echo "no open release PR; nothing to sync"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- name: Checkout release branch
if: ${{ steps.resolve.outputs.branch != '' }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.resolve.outputs.branch }}
# Resolve on a declared toolchain instead of whatever Rust the runner
# image happens to ship, so the lock this job writes comes off the same
# stable channel the other cargo jobs use. Not a claim of identical
# resolution: `stable` still moves between runs, and the MSRV gate
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
# runner image as an undeclared input.
- name: Install Rust toolchain
if: ${{ steps.resolve.outputs.branch != '' }}
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Sync workspace crate versions into Cargo.lock
if: ${{ steps.resolve.outputs.branch != '' }}
run: cargo update --workspace
- name: Commit and push when the lock changed
if: ${{ steps.resolve.outputs.branch != '' }}
run: |
if git diff --quiet Cargo.lock; then
echo "Cargo.lock already in sync"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.lock
git commit -m "chore: sync Cargo.lock with the release version bump"
git push
# Every push to the release branch is made with GITHUB_TOKEN (release-
# please's own pushes and the lock sync above), and events created by
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
# branch to attach check runs to its current head. Unconditional (also on
# the already-in-sync path) because the branch's ORIGINAL head has the
# same no-runs problem. Loud on failure by design: a silent fallback
# would recreate the checkless-head problem, and a job rerun is safe
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
# is the sync commit. Merge-queue note: this validates the branch tip;
# the enforced merge queue's merge_group run still re-validates the true
# merged result before landing.
- name: Dispatch PR checks on the release branch
if: ${{ steps.resolve.outputs.branch != '' }}
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.resolve.outputs.branch }}
run: |
# Every dispatch failure fails the job, including the 422 a release
# branch cut before pr-checks gained its workflow_dispatch trigger
# returns ("does not have a workflow_dispatch trigger", reported
# against the dispatched ref's copy of the file). An earlier revision
# tolerated exactly that 422 on the theory that release-please would
# rewrite the branch and heal it, but a rewrite only happens when a
# later releasable commit lands: an open release PR nobody touches is
# never rewritten. Tolerating it therefore left the lock-sync commit
# this job had just pushed sitting on the release head with no
# --locked validation, which is the precise state this workflow exists
# to prevent. The tolerance is also no longer needed: once this lands,
# every release branch is cut from a main that already carries the
# trigger, so the only branch that could 422 is one open at merge time
# (there is none), and a job rerun after a manual rebase is safe (the
# sync no-ops, the dispatch retries).
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
-f "ref=$BRANCH"
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
@@ -446,7 +308,7 @@ jobs:
set -euo pipefail
args=""
for b in $BINS; do args="$args -p $b"; done
cargo build --release --locked --target ${{ matrix.target }} $args
cargo build --release --target ${{ matrix.target }} $args
# Run each packaged binary's --version so a broken release artifact fails the
# build instead of shipping. Every target builds on a matching-arch runner,
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.7.1"
".": "0.7.0"
}
-26
View File
@@ -1,31 +1,5 @@
# Changelog
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
### Bug Fixes
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
Generated
+11 -87
View File
@@ -770,23 +770,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ark-ff"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
dependencies = [
"ark-ff-asm 0.6.0",
"ark-ff-macros 0.6.0",
"ark-serialize 0.6.0",
"ark-std 0.6.0",
"digest 0.10.7",
"educe",
"num-bigint",
"num-traits",
"zeroize",
]
[[package]]
name = "ark-ff-asm"
version = "0.3.0"
@@ -817,16 +800,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-asm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.3.0"
@@ -865,19 +838,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ark-ff-macros"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
dependencies = [
"num-bigint",
"num-traits",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-serialize"
version = "0.3.0"
@@ -911,30 +871,6 @@ dependencies = [
"num-bigint",
]
[[package]]
name = "ark-serialize"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
dependencies = [
"ark-serialize-derive",
"ark-std 0.6.0",
"digest 0.10.7",
"num-bigint",
"serde_with",
]
[[package]]
name = "ark-serialize-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ark-std"
version = "0.3.0"
@@ -965,16 +901,6 @@ dependencies = [
"rand 0.8.6",
]
[[package]]
name = "ark-std"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
dependencies = [
"num-traits",
"rand 0.8.6",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2630,7 +2556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 2.0.117",
"syn 1.0.109",
]
[[package]]
@@ -3002,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3374,7 +3300,7 @@ dependencies = [
[[package]]
name = "git-remote-gitlawb"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"anyhow",
"gitlawb-core",
@@ -3386,7 +3312,7 @@ dependencies = [
[[package]]
name = "gitlawb-attest"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"base64",
"ed25519-dalek",
@@ -3403,7 +3329,7 @@ dependencies = [
[[package]]
name = "gitlawb-core"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"anyhow",
"base64",
@@ -3430,7 +3356,7 @@ dependencies = [
[[package]]
name = "gitlawb-node"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"alloy",
"anyhow",
@@ -3486,11 +3412,10 @@ dependencies = [
[[package]]
name = "gl"
version = "0.7.1"
version = "0.5.1"
dependencies = [
"alloy",
"anyhow",
"base64",
"chrono",
"clap",
"dirs",
@@ -5815,15 +5740,14 @@ dependencies = [
[[package]]
name = "ruint"
version = "1.20.0"
version = "1.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970"
checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a"
dependencies = [
"alloy-rlp",
"ark-ff 0.3.0",
"ark-ff 0.4.2",
"ark-ff 0.5.0",
"ark-ff 0.6.0",
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
@@ -5897,7 +5821,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6791,7 +6715,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5 -18
View File
@@ -8,33 +8,20 @@ FROM rust:1.91-bookworm AS builder
WORKDIR /build
# Cache dependencies first for faster rebuilds.
# EVERY workspace member's manifest must be copied here, not just the ones this
# image ships: cargo loads the whole workspace before it resolves anything, so a
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
# layer before a single crate is fetched.
# Cache dependencies first for faster rebuilds
COPY Cargo.toml Cargo.lock ./
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
COPY crates/gl/Cargo.toml crates/gl/
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
# Now copy real sources and build for real.
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
@@ -47,7 +34,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
target/release/.fingerprint/gl-* \
target/release/.fingerprint/git-remote-gitlawb-* && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
# ── Runtime stage ───────────────────────────────────────────────────────────
+2 -5
View File
@@ -3,10 +3,7 @@
# Used by scripts/build-bins.sh — not used for node deployment.
ARG TARGET=x86_64-unknown-linux-musl
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
# base image here fails only when someone runs scripts/build-bins.sh.
FROM rust:1.91-bookworm AS builder
FROM rust:1.85-bookworm AS builder
ARG TARGET
WORKDIR /build
@@ -26,7 +23,7 @@ COPY crates/ crates/
# Configure cross-linker for aarch64
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
FROM debian:bookworm-slim
ARG TARGET
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "git-remote-gitlawb"
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
version = "0.7.1" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version = "0.7.1" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.7.1" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gitlawb-node"
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version = "0.7.1" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
File diff suppressed because it is too large. Load diff
+10 -30
View File
@@ -22,7 +22,6 @@ pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
pub mod status;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
@@ -68,14 +67,17 @@ pub(crate) async fn authorize_repo_read(
/// representation only within `did:key`; never let a bare id match across methods —
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
///
/// The collapse itself lives in exactly one place, [`crate::db::normalize_owner_key`],
/// which is also the function the stored `owner_did` / `authorizing_did` columns
/// are normalized through and which `OWNER_KEY_CASE_SQL` mirrors byte for byte.
/// Two identities match when they normalize to the same key; a second copy of the
/// rule here is how the Rust gate and the SQL filters would drift apart.
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
crate::db::normalize_owner_key(a) == crate::db::normalize_owner_key(b)
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
@@ -173,7 +175,6 @@ mod authz_guard {
let events = include_str!("events.rs");
let tasks = include_str!("tasks.rs");
let stars = include_str!("stars.rs");
let status = include_str!("status/mod.rs");
let protect = include_str!("protect.rs");
let visibility = include_str!("visibility.rs");
let profiles = include_str!("profiles.rs");
@@ -194,13 +195,6 @@ mod authz_guard {
// (existence hiding) and require_repo_owner guards the owner half.
(webhooks, "list_webhooks", "authorize_repo_read("),
(webhooks, "list_webhooks", "require_repo_owner("),
// Same two-half shape as list_webhooks: authorize_repo_read runs
// first (a quarantined or unreadable repo gets the missing-repo
// not-found, never a 403 that would confirm existence), then
// require_repo_owner 403s a non-owner of a readable repo. Both halves
// are pinned, because dropping either changes what a stranger learns.
(status, "create_status", "authorize_repo_read("),
(status, "create_status", "require_repo_owner("),
(labels, "add_label", "require_repo_owner("),
(labels, "remove_label", "require_repo_owner("),
// Bucket A' — owner OR author (did_matches against the author)
@@ -223,19 +217,6 @@ mod authz_guard {
(protect, "list_protected_branches", "authorize_repo_read("),
(labels, "list_labels", "authorize_repo_read("),
(events, "list_repo_events", "authorize_repo_read("),
// The catch-up poll surface gates before any push-event row is read,
// so a stranger cannot use it to learn that a private repo exists or
// is being pushed to.
(events, "list_repo_push_events", "authorize_repo_read("),
// The status read gates before any claim data is loaded, so its deny
// is the repo's own not-found rather than an existence oracle over
// which commits were reported on.
(status, "commit_status", "authorize_repo_read("),
// The rollup gates on the same helper before the pull request row is
// loaded, so a caller who cannot read the repo cannot learn which
// pull request numbers exist on it — and the fallback's branch
// resolve and head persist sit behind that same gate.
(status, "pull_request_status", "authorize_repo_read("),
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
(tasks, "create_task", "did_matches("),
(tasks, "claim_task", "did_matches("),
@@ -509,7 +490,6 @@ mod authz_guard {
(include_str!("replicas.rs"), "replicas.rs"),
(include_str!("repos.rs"), "repos.rs"),
(include_str!("stars.rs"), "stars.rs"),
(include_str!("status/mod.rs"), "status/mod.rs"),
(include_str!("visibility.rs"), "visibility.rs"),
(include_str!("webhooks.rs"), "webhooks.rs"),
];
+1 -270
View File
@@ -381,14 +381,6 @@ pub async fn notify_sync(
)));
}
// #272: the slug reaches a PathBuf::join in the sync worker, where an extra
// separator or a traversal segment places the mirror outside repos_dir. Reject
// it here, before the queue row and the ref-update row are written. The message
// names the field and nothing about where repos live on disk.
if let Err(e) = crate::git::repo_store::validate_repo_slug(&req.repo) {
return Err(AppError::BadRequest(format!("invalid repo field: {e}")));
}
state
.db
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
@@ -592,9 +584,7 @@ mod tests {
state.config = Arc::new(cfg);
}
// The slug must be a well-formed owner/name (#272); these brake tests are about
// the rate limiter, so the body carries a valid slug and reaches the brake.
const NOTIFY_BODY: &str = r#"{"repo":"z6Mkfoo/hello","ref_name":"refs/heads/main","new_sha":"0000000000000000000000000000000000000000","node_did":"PEER_DID"}"#;
const NOTIFY_BODY: &str = r#"{"repo":"demo","ref_name":"refs/heads/main","new_sha":"0000000000000000000000000000000000000000","node_did":"PEER_DID"}"#;
// ── trigger: mandatory signature ──────────────────────────────────────────
@@ -876,265 +866,6 @@ mod tests {
);
}
// ── notify: repo slug validation (#272) ───────────────────────────────────
//
// An unsigned caller who has announced their own DID is a "known peer", so
// the slug they send reaches the sync worker's path join. `a//tmp/probe`
// resolved to /tmp/probe.git, outside repos_dir entirely. notify must reject
// anything that is not a well-formed owner/name slug before it is queued.
/// Every slug shape the boundary must refuse. `a//tmp/gitlawb-probe` is the
/// verified escape; the rest are the traversal and empty-half neighbours.
const MALFORMED_SLUGS: [&str; 9] = [
"a//tmp/gitlawb-probe",
"../hello",
"./hello",
"../../etc/evil",
"a/../../x",
"..",
"demo",
"/hello",
"z6Mkfoo/",
];
fn notify_body(repo: &str, peer_did: &str) -> String {
serde_json::json!({
"repo": repo,
"ref_name": "refs/heads/main",
"new_sha": "0000000000000000000000000000000000000000",
"node_did": peer_did,
})
.to_string()
}
#[sqlx::test]
async fn sync_notify_rejects_malformed_repo_slugs(pool: PgPool) {
let state = test_state(pool).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
for (i, slug) in MALFORMED_SLUGS.iter().enumerate() {
// Distinct source IPs so the per-IP brake never masks the 400.
let ip = format!("198.51.100.{}:5000", i + 1);
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body(slug, &peer_did),
&ip,
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"slug {slug:?} must be rejected"
);
}
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert!(
queued.is_empty(),
"no malformed slug may be queued, got {:?}",
queued.iter().map(|q| &q.repo).collect::<Vec<_>>()
);
}
#[sqlx::test]
async fn sync_notify_rejection_body_names_field_not_path(pool: PgPool) {
// R3: the client learns which field was bad, never where repos live on
// disk. A message carrying the resolved path would hand an unsigned
// caller the server's layout.
let state = test_state(pool).await;
let repos_dir = state.config.repos_dir.display().to_string();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &peer_did),
"198.51.100.20:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("repo"), "body must name the field: {body}");
assert!(
!body.contains(&repos_dir),
"body must not leak repos_dir ({repos_dir}): {body}"
);
for trimmed in repos_dir.trim_start_matches("./").split('/') {
assert!(
trimmed.is_empty() || !body.contains(trimmed),
"body must not leak a repos_dir segment ({trimmed}): {body}"
);
}
// A rooted path is a leading '/' with something after it. A bare quoted
// '/' is the separator the message is allowed to talk about.
assert!(
!body
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.any(|tok| tok.starts_with('/') && tok.len() > 1),
"body must not contain a rooted filesystem path: {body}"
);
}
#[sqlx::test]
async fn sync_notify_rejection_writes_no_ref_update(pool: PgPool) {
// The second half of R1: the ref-update feed row sits after enqueue_sync,
// so the early return must skip it too. Asserted, not argued.
let state = test_state(pool.clone()).await;
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &peer_did),
"198.51.100.21:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM received_ref_updates")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 0,
"a rejected notify must not seed the ref-update feed"
);
}
#[sqlx::test]
async fn sync_notify_accepts_valid_slug_and_records_ref_update(pool: PgPool) {
// must-not-break, and the control that keeps the two negative assertions
// above from being vacuous: a well-formed slug still queues and still
// writes its ref-update row.
let state = test_state(pool.clone()).await;
let db = state.db.clone();
let peer_did = seed_peer(&state).await;
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("z6Mkfoo/hello", &peer_did),
"198.51.100.22:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].repo, "z6Mkfoo/hello");
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM received_ref_updates WHERE repo = $1")
.bind("z6Mkfoo/hello")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 1,
"the valid path must still write the ref-update row"
);
}
#[sqlx::test]
async fn sync_notify_unknown_peer_wins_over_malformed_slug(pool: PgPool) {
// Ordering: the slug check sits after the peer gate, so an unknown peer
// still gets the unknown-peer message rather than a slug complaint.
let state = test_state(pool).await;
let stranger = Keypair::generate().did().to_string();
let router = crate::server::build_router(state);
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body("a//tmp/gitlawb-probe", &stranger),
"198.51.100.23:5000",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
body.contains("unknown peer DID"),
"peer gate must still fire first: {body}"
);
}
#[sqlx::test]
async fn unsigned_announce_then_notify_cannot_queue_a_repos_dir_escape(pool: PgPool) {
// The reachability case for #272, committed in its attack form. Two
// unsigned requests through the shipped router, no signature and no
// config change: announce the attacker's own DID (which is what makes
// them a "known peer"), then notify with `a/<abs>/gitlawb-probe`. That
// slug used to reach PathBuf::join in the sync worker, where the
// absolute second component discarded repos_dir and planted a mirror at
// /<abs>/gitlawb-probe.git. The notify must now be refused outright.
//
// This has to run over crate::server::build_router rather than a
// directly mounted handler: the point of the test is that the default
// layer stack (optional_signature, not require_signature) leaves the
// route open, so mounting the handler alone would assert nothing about
// reachability.
let state = test_state(pool).await;
let db = state.db.clone();
let attacker = Keypair::generate().did().to_string();
let router = crate::server::build_router(state);
let announce =
format!(r#"{{"did":"{attacker}","http_url":"https://attacker.example.com"}}"#);
let resp = router
.clone()
.oneshot(unsigned_post(
"/api/v1/peers/announce",
&announce,
"198.51.100.40:5000",
))
.await
.unwrap();
// Asserted, not assumed: this plan does not change announce, and the
// whole attack rests on an unsigned announce still succeeding.
assert_eq!(
resp.status(),
StatusCode::OK,
"unsigned announce still admits an attacker-generated DID"
);
let outside = tempfile::TempDir::new().unwrap();
let escape_dir = outside.path().join("gitlawb-probe");
let slug = format!("a/{}", escape_dir.display());
let resp = router
.oneshot(unsigned_post(
"/api/v1/sync/notify",
&notify_body(&slug, &attacker),
"198.51.100.41:5000",
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"notify must refuse the escaping slug {slug:?}"
);
let queued = db.dequeue_pending_syncs(100).await.unwrap();
assert!(
queued.is_empty(),
"sync_queue must stay empty, got {:?}",
queued.iter().map(|q| &q.repo).collect::<Vec<_>>()
);
assert!(
!escape_dir.with_extension("git").exists(),
"nothing may be written at the escape target"
);
}
#[sqlx::test]
async fn announce_still_accepts_unsigned_in_default_mode(pool: PgPool) {
// must-not-over-reach: adding the brake must not tighten announce's
+1 -174
View File
@@ -65,7 +65,6 @@ pub async fn create_pr(
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
head_commit: None,
created_at: now.clone(),
updated_at: now,
};
@@ -217,19 +216,6 @@ pub async fn merge_pr(
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
// The source head this merge is about to consume, read under the write lock.
// The PR row above was loaded before the lock, so a push landing in between
// already moved the branch and the row's own `head_commit` is stale; the
// value frozen on the merged PR has to be what was merged. `None` when the
// ref cannot be resolved, which leaves whatever the push path last stored.
let merged_source_head = store::list_refs(&disk_path).ok().and_then(|refs| {
let want = format!("refs/heads/{}", pr.source_branch);
refs.into_iter()
.find(|(name, _)| *name == want)
.map(|(_, sha)| sha)
});
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
@@ -243,10 +229,7 @@ pub async fn merge_pr(
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
state
.db
.merge_pr(&pr.id, &merger_did, merged_source_head.as_deref())
.await?;
state.db.merge_pr(&pr.id, &merger_did).await?;
let _ = state.db.touch_repo(&record.id).await;
webhooks::fire_event(
@@ -441,159 +424,3 @@ pub async fn list_comments(
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, StatusCode};
use axum::routing::post;
use axum::Router;
use std::process::Command;
use tower::ServiceExt;
const OWNER_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
const STALE_SHA: &str = "9999999999999999999999999999999999999999";
fn git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "[email protected]")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "[email protected]")
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A bare repo with `main` and a `feature` branch one commit ahead.
/// Returns the bare path and the real `feature` tip.
fn bare_repo_with_feature(
root: &std::path::Path,
owner_slug: &str,
) -> (std::path::PathBuf, String) {
let work = root.join("work");
std::fs::create_dir_all(&work).unwrap();
git(&work, &["init", "-q", "-b", "main", "."]);
std::fs::write(work.join("a.txt"), "one").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-qm", "one"]);
git(&work, &["checkout", "-qb", "feature"]);
std::fs::write(work.join("b.txt"), "two").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-qm", "two"]);
git(&work, &["checkout", "-q", "main"]);
let bare_dir = root.join(owner_slug);
std::fs::create_dir_all(&bare_dir).unwrap();
let bare = bare_dir.join("demo.git");
git(
root,
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
let tip = Command::new("git")
.args(["rev-parse", "refs/heads/feature"])
.current_dir(&bare)
.output()
.unwrap();
let tip = String::from_utf8_lossy(&tip.stdout).trim().to_string();
assert_eq!(tip.len(), 40, "feature tip must resolve");
(bare, tip)
}
/// Merging freezes the stored head at the source commit the merge actually
/// consumed. The PR row is loaded before the write lock is taken, so a push
/// that lands in between leaves the row's own `head_commit` stale; seeding a
/// value that matches nothing on disk stands in for that race, and the
/// merged PR must still come out pointing at the real `feature` tip.
#[sqlx::test]
async fn merge_stamps_the_source_head_it_merged_over_a_racing_value(pool: sqlx::PgPool) {
let tmp = tempfile::tempdir().unwrap();
let owner_slug = OWNER_DID.replace([':', '/'], "_");
let (bare, feature_tip) = bare_repo_with_feature(tmp.path(), &owner_slug);
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store =
crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool);
let now = Utc::now();
let repo = crate::db::RepoRecord {
id: uuid::Uuid::new_v4().to_string(),
name: "demo".into(),
owner_did: OWNER_DID.into(),
description: None,
is_public: true,
default_branch: "main".into(),
created_at: now,
updated_at: now,
disk_path: bare.to_string_lossy().into_owned(),
forked_from: None,
machine_id: None,
};
state.db.create_repo(&repo).await.unwrap();
let pr = PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo.id.clone(),
number: 1,
title: "add b".into(),
body: None,
author_did: OWNER_DID.into(),
source_branch: "feature".into(),
target_branch: "main".into(),
status: "open".into(),
merged_by_did: None,
merged_at: None,
head_commit: None,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
};
state.db.create_pr(&pr).await.unwrap();
// The stale value the racing push scenario leaves on the row.
state
.db
.set_open_pr_heads(&repo.id, "feature", STALE_SHA)
.await
.unwrap();
let db = state.db.clone();
let app = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
post(merge_pr),
)
.with_state(state);
let response = app
.oneshot(crate::test_support::signed_request_as(
OWNER_DID,
Method::POST,
"/api/v1/repos/z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH/demo/pulls/1/merge",
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let merged = db.get_pr(&repo.id, 1).await.unwrap().unwrap();
assert_eq!(merged.status, "merged");
assert_eq!(
merged.head_commit,
Some(feature_tip),
"the merge must stamp the source head it consumed, not the racing value"
);
}
}
File diff suppressed because it is too large. Load diff
-657
View File
@@ -1,657 +0,0 @@
//! Commit status claims: the write path and the combined read.
//!
//! POST /api/v1/repos/:owner/:repo/statuses/:sha — append one claim (owner only)
//! GET /api/v1/repos/:owner/:repo/commits/:sha/status — the combined projection
//!
//! Claims are append-only: a producer reporting twice for the same context
//! leaves both rows and the visible status is a projection over the history.
//! Reporting twice means two REQUESTS, though. An exact repeat of one already
//! accepted is answered with 200 and the row it wrote, not a second row: the
//! projection elects the highest `seq`, so a replayed claim would not duplicate
//! a verdict but overturn the one that superseded it.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::StatusClaim;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// Claims for one (repo, commit, producer, context). Generous for a real
/// producer; a producer past it is misbehaving.
const MAX_CLAIMS_PER_TUPLE: i64 = 100;
/// Distinct contexts for one (repo, commit). The context string is caller-chosen
/// and free-form, so the tuple cap alone bounds nothing.
const MAX_CONTEXTS_PER_COMMIT: i64 = 50;
/// Claim rows for one repo within the trailing window the db layer counts over.
/// The commit SHA is caller-chosen and never existence-checked, so a writer at
/// the two caps above can still fan out over fresh 40-hex strings without this
/// one. It bounds the RATE of that fan-out rather than a lifetime total, because
/// nothing prunes the table: a lifetime bound would close the surface on a repo
/// permanently while answering with a status every client retries.
const MAX_CLAIMS_PER_REPO_PER_WINDOW: i64 = 10_000;
#[derive(Deserialize)]
pub struct CreateStatusRequest {
pub state: String,
pub context: String,
pub target_url: Option<String>,
pub description: Option<String>,
}
/// The wire state set (KTD-1): GitHub's four commit-status states, so absence
/// stays distinguishable without a fifth value no client understands.
const CLAIM_STATES: [&str; 4] = ["error", "failure", "pending", "success"];
/// Bounds on the signature material this write path persists verbatim. Every one
/// of the four is caller-influenced: the headers come off the request, the
/// signing string grows with the component list the caller's Signature-Input
/// chose, and the body is the caller's. Without a bound the claim log's row size
/// is set by whoever writes to it, so these are explicit and generous rather than
/// implicit and absent. A conforming `sign_request` produces roughly 100, 200 and
/// 400 bytes for the first three.
const MAX_SIGNATURE_CHARS: usize = 512;
const MAX_SIGNATURE_INPUT_CHARS: usize = 1024;
const MAX_SIGNING_STRING_CHARS: usize = 4096;
/// The body is a `CreateStatusRequest`, whose own fields are already capped at
/// roughly 3.3 KB in total by the three limits below; this leaves room for JSON
/// framing and nothing more.
///
/// Enforced twice, at two different moments, because the handler's check alone
/// is not the bound it advertises: `require_signature` buffers the whole body
/// before any handler runs, so by the time [`create_status`] measures it the
/// allocation has already happened. `build_router` applies the same number as a
/// `RequestBodyLimitLayer` at the transport, outside the auth layers, which is
/// what makes it a bound on what is ALLOCATED and not only on what is STORED.
/// The handler keeps its own check regardless — it is the one that holds for a
/// handler reached by any other route table, and it is what the material size
/// tests drive.
pub(crate) const MAX_REQUEST_BODY_BYTES: usize = 8192;
const MAX_CONTEXT_CHARS: usize = 255;
const MAX_TARGET_URL_CHARS: usize = 2048;
const MAX_DESCRIPTION_CHARS: usize = 1024;
/// The 201 body for an accepted claim: what the client needs to identify the row
/// it just wrote, and nothing else.
///
/// Deliberately not the stored [`StatusClaim`]. That struct carries the signature
/// material, and serializing it echoed the signature, the signing string and the
/// whole request body back on every write — the body as a JSON array of integers.
/// Write-time provenance belongs in the row, not on the wire, which is the same
/// line [`StatusEntry`] draws on the read side.
#[derive(Serialize)]
pub struct CreatedStatus {
pub id: String,
/// The database-assigned ordering key, so the client can name its own row.
pub seq: i64,
pub repo_id: String,
pub commit_sha: String,
pub state: String,
pub context: String,
pub target_url: Option<String>,
pub description: Option<String>,
pub producer_did: String,
pub created_at: String,
}
impl CreatedStatus {
fn from_claim(claim: StatusClaim, seq: i64) -> Self {
Self {
id: claim.id,
seq,
repo_id: claim.repo_id,
commit_sha: claim.commit_sha,
state: claim.state,
context: claim.context,
target_url: claim.target_url,
description: claim.description,
producer_did: claim.producer_did,
created_at: claim.created_at,
}
}
}
/// POST /api/v1/repos/:owner/:repo/statuses/:sha
pub async fn create_status(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, sha)): Path<(String, String, String)>,
material: Option<Extension<crate::auth::SignatureMaterial>>,
Json(req): Json<CreateStatusRequest>,
) -> Result<(StatusCode, Json<CreatedStatus>)> {
// Read-visibility first, then owner, and the order is the security property.
// authorize_repo_read denies a quarantined repo before the visibility gate and
// answers with the repo's own not-found, byte-identical to a missing repo, so
// a caller who cannot read the repo cannot learn it exists. Loading the repo
// and comparing the owner would answer 403 there, turning this endpoint into
// an existence oracle. require_repo_owner then 403s a non-owner of a repo the
// caller can read, where existence is not secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(&auth.0), "/").await?;
crate::api::require_repo_owner(&record, &auth.0)?;
let commit_sha = normalize_sha(&sha)?;
let claim_state = validate_state(&req.state)?;
let context = validate_context(&req.context)?;
let target_url = validate_target_url(req.target_url.as_deref())?;
let description = validate_description(req.description.as_deref())?;
// KTD-5: the signature material is captured here because the request carries
// the only copy, and `signing_string` is the exact byte sequence the
// middleware verified. Its absence means this handler was reached without
// `require_signature` — a server misconfiguration (a route group that lost
// its auth layer, a handler mounted somewhere new), not anything the client
// did, so it is a 500 and no row is written. Storing an empty payload instead
// would leave every claim from that moment on unverifiable with nothing going
// red, which is exactly the absence-renders-as-success shape.
let Some(Extension(material)) = material else {
return Err(AppError::Internal(anyhow::anyhow!(
"status claim write reached without verified signature material — \
the route is not behind require_signature"
)));
};
// The body is carried only behind the `PersistsSignedBody` marker, which
// this route's group applies outside the auth layers. Its absence means the
// marker did not reach the middleware, because the layer was dropped or
// reordered. That is the same class of misconfiguration as the branch above
// and gets the same answer. Storing an empty body instead would write claims
// that carry a signature over a digest of bytes nobody kept, which is
// exactly the case `stored_claim_re_verifies_and_a_tampered_row_does_not`
// exists to make impossible.
let Some(body) = material.body.clone() else {
return Err(AppError::Internal(anyhow::anyhow!(
"status claim write reached with signature material carrying no request body: \
the route lost its persist-body marker or applied it inside the auth layers"
)));
};
// Bounded before anything is written. The material is verified, which says
// the caller holds the key, not that what they signed is a reasonable size.
bound("signature", material.signature.len(), MAX_SIGNATURE_CHARS)?;
bound(
"signature_input",
material.signature_input.len(),
MAX_SIGNATURE_INPUT_CHARS,
)?;
bound(
"signing_string",
material.signing_string.len(),
MAX_SIGNING_STRING_CHARS,
)?;
bound("request body", body.len(), MAX_REQUEST_BODY_BYTES)?;
// After the bounds, so the scan below walks a signing string of known size.
require_body_bound_signature(&material, &body)?;
let digest = request_digest(&material, &body);
let (signature, signature_input, signing_string, request_body) = (
material.signature,
material.signature_input,
material.signing_string,
body.to_vec(),
);
let claim = StatusClaim {
id: Uuid::new_v4().to_string(),
// Ignored on insert: the database assigns the ordering key (KTD-3).
seq: 0,
repo_id: record.id.clone(),
commit_sha,
state: claim_state,
context,
target_url,
description,
// Both are the owner identity today (KTD-5); they split when delegated
// capabilities land.
producer_did: auth.0.clone(),
authorizing_did: auth.0,
signature,
signature_input,
signing_string,
request_body,
request_digest: digest,
created_at: Utc::now().to_rfc3339(),
};
let caps = crate::db::ClaimCaps {
per_tuple: MAX_CLAIMS_PER_TUPLE,
contexts_per_commit: MAX_CONTEXTS_PER_COMMIT,
per_repo_window: MAX_CLAIMS_PER_REPO_PER_WINDOW,
};
match state.db.insert_status_claim_capped(&claim, &caps).await? {
crate::db::ClaimInsert::Inserted(seq) => Ok((
StatusCode::CREATED,
Json(CreatedStatus::from_claim(claim, seq)),
)),
// Already recorded, so 200 and the original row rather than 201 and a
// second one. The claim the caller gets back is the stored one, id and
// seq included, which is what makes a retry safe to treat as success.
crate::db::ClaimInsert::AlreadyRecorded(existing) => {
let seq = existing.seq;
Ok((
StatusCode::OK,
Json(CreatedStatus::from_claim(*existing, seq)),
))
}
crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!(
"claim limit reached for {which}"
))),
}
}
/// Refuse a claim whose signature does not cover the body about to be stored.
///
/// A claim IS provenance, so the signature stored beside it has to bind the
/// bytes stored beside it. It does not do that directly: `signing_string`
/// reaches the body only through a `content-digest` line. And a caller can sign
/// a content-digest the request then never carries. `require_signature` rebuilds
/// the covered values from the request, so an ABSENT `Content-Digest` header
/// makes the covered digest the empty string on the signing side and the
/// verifying side alike; the Ed25519 check passes over method, path and an empty
/// digest, and any body at all rides along. The row that write produces asserts
/// a provenance that cannot be re-verified: the signing string covers a digest
/// of bytes that are not the ones in `request_body`.
///
/// The predicate here is the write-time form of the one `re_verify` runs against
/// a stored row in the tests — recompute the digest of the bytes about to be
/// persisted and require the signing string to carry exactly that line — so what
/// this route accepts and what a third party can later re-verify are the same
/// question, asked at both ends.
///
/// This is a route property, checked where the claim becomes history, and it is
/// deliberately not the only line of defence: requiring the header in
/// `require_signature` closes the same hole one layer earlier and for every
/// signed route, and that change belongs there rather than here because no other
/// route's correctness should depend on the status handler. The two agree and
/// neither substitutes for the other — without the middleware's requirement the
/// empty digest is still accepted everywhere else, and without this one a later
/// relaxation of the middleware would silently resume writing unverifiable
/// history.
///
/// A 400, not a 500: the material is exactly what the client signed, and what
/// they signed does not cover what they sent.
fn require_body_bound_signature(
material: &crate::auth::SignatureMaterial,
body: &[u8],
) -> Result<()> {
let covered = format!(
"\"content-digest\": {}",
gitlawb_core::http_sig::compute_content_digest(body)
);
// Line-exact rather than a substring search: `build_signing_string` emits one
// component per line, and a `contains` would also be satisfied by the digest
// appearing INSIDE some other covered component's value, which proves
// nothing about what the signature covered.
if material.signing_string.lines().any(|line| line == covered) {
return Ok(());
}
Err(AppError::BadRequest(
"the signature does not cover this request body: send a Content-Digest header \
computed over the body and name \"content-digest\" among the signature's covered \
components"
.into(),
))
}
/// The identity of one signed write, as a hex sha-256.
///
/// All four inputs together, because a replay is the same bytes arriving twice:
/// the signature, the input that names what it covers, the canonical string it
/// was verified over, and the body that string covers through a content-digest.
/// The signature alone would nearly do (it covers the other three transitively),
/// but hashing what is actually stored means the digest describes the row rather
/// than a claim about it.
///
/// Each field is length-prefixed so no two different requests can concatenate to
/// the same bytes: without it, a signature ending in some prefix of the next
/// field would collide with the shorter signature that absorbed it.
///
/// This is not a nonce, and deliberately so. A nonce table needs pruning and a
/// pruning window is a second replay window; the claim row IS the record of what
/// was accepted, so uniqueness on it is self-maintaining.
fn request_digest(material: &crate::auth::SignatureMaterial, body: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for part in [
material.signature.as_bytes(),
material.signature_input.as_bytes(),
material.signing_string.as_bytes(),
body,
] {
h.update((part.len() as u64).to_be_bytes());
h.update(part);
}
format!("{:x}", h.finalize())
}
/// One reported context in the combined response. Signature material is
/// deliberately absent: it is write-time provenance, not read-surface data.
#[derive(Serialize)]
pub struct StatusEntry {
pub state: String,
pub context: String,
pub target_url: Option<String>,
pub description: Option<String>,
pub producer_did: String,
pub created_at: String,
}
/// The combined commit status. `state` never leaves the four-value set (KTD-1),
/// so absence is carried by `total_count` 0 with the pending state rather than a
/// fifth value, and `reported_only` (R19) says out loud that the state covers the
/// contexts that reported, not every check a caller expected.
#[derive(Serialize)]
pub struct CombinedStatus {
pub state: String,
pub sha: String,
pub total_count: usize,
pub statuses: Vec<StatusEntry>,
pub reported_only: bool,
}
/// GET /api/v1/repos/:owner/:repo/commits/:sha/status
///
/// The auth extension is optional and MUST be last in the extractor list: the
/// route group carries `optional_signature`, so an unsigned caller reaches here
/// with no `AuthenticatedDid` at all and a public repo still answers.
pub async fn commit_status(
State(state): State<AppState>,
Path((owner, name, sha)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<CombinedStatus>> {
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
// The gate runs first and on the requested path, before any claim data is
// touched. Its deny is the repo's own not-found, byte-identical to a missing
// repo, so the status surface cannot answer "does this repo exist".
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let commit_sha = normalize_sha(&sha)?;
let statuses = project_claims(&state, &record, &commit_sha).await?;
Ok(Json(CombinedStatus {
state: combined_state(&statuses).to_string(),
sha: commit_sha,
total_count: statuses.len(),
statuses,
reported_only: true,
}))
}
/// The projection, in one place. Both read surfaces call it, so R3's "the rollup
/// is derived by the same projection as the commit read" holds by construction
/// rather than by two implementations happening to agree.
///
/// KTD-5: current authorization, not write-time authorization. An ownership
/// transfer drops the prior owner's claims from the projection while the
/// append-only history keeps them.
async fn project_claims(
state: &AppState,
record: &crate::db::RepoRecord,
commit_sha: &str,
) -> Result<Vec<StatusEntry>> {
let claims = state
.db
.latest_status_claims(&record.id, commit_sha, &record.owner_did)
.await?;
Ok(claims
.into_iter()
.map(|c| StatusEntry {
state: c.state,
context: c.context,
target_url: c.target_url,
description: c.description,
producer_did: c.producer_did,
created_at: c.created_at,
})
.collect())
}
/// The pull request head rollup (R11). `state` stays inside the four wire values
/// whatever happened to the head (KTD-1): an unresolvable head is carried by
/// `head_resolved`, alongside the pull request's own state, so a client can tell
/// "the head could not be resolved" from "the head resolved and nothing reported"
/// without a fifth state value no client understands.
#[derive(Serialize)]
pub struct PullRequestStatus {
pub number: i64,
/// The pull request's own state: open, closed, or merged.
pub pull_request_state: String,
pub head_resolved: bool,
/// The target commit, absent exactly when `head_resolved` is false.
pub sha: Option<String>,
pub state: String,
pub total_count: usize,
pub statuses: Vec<StatusEntry>,
pub reported_only: bool,
}
/// GET /api/v1/repos/:owner/:repo/pulls/:number/status
///
/// Same optional-auth shape as the commit read, and the auth extension MUST stay
/// last in the extractor list.
pub async fn pull_request_status(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<PullRequestStatus>> {
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
// The gate runs first, on the requested path, before the pull request row is
// loaded. Its deny is the repo's own not-found, byte-identical to a missing
// repo. Once it passes, a missing pull request number is the plain not-found:
// existence is no longer secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let head = rollup_head(&state, &record, &pr).await?;
let statuses = match &head {
Some(sha) => project_claims(&state, &record, sha).await?,
None => Vec::new(),
};
Ok(Json(PullRequestStatus {
number: pr.number,
pull_request_state: pr.status,
head_resolved: head.is_some(),
sha: head,
state: combined_state(&statuses).to_string(),
total_count: statuses.len(),
statuses,
reported_only: true,
}))
}
/// Branch-head lookups performed by the rollup fallback, counted so a test can
/// assert WORK DONE rather than only the answer returned. A fallback that
/// re-resolved on every read and then discarded the answer would be invisible in
/// the response and visible here.
#[cfg(test)]
pub(crate) static BRANCH_RESOLVES: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
/// The rollup's target commit (KTD-4).
///
/// The stored head wins whenever it is set. It is frozen at close or merge, so a
/// closed or merged pull request keeps the commit the decision was made against,
/// and only an OPEN pull request with no stored head falls back to the branch.
///
/// The fallback is one database read: the latest push this node recorded for the
/// pull request's source branch, from `repo_push_events`. It deliberately does
/// NOT go through `git::store` ref listing, which needs an acquired repository
/// path — on a cold node that downloads the whole repository from object storage,
/// and this read group carries no rate limiter, so an anonymous caller could
/// drive repeated downloads with a URL.
///
/// `repo_push_events` is the source rather than `branch_cids` because
/// `record_push_events` writes it for every ref update unconditionally, whereas
/// the sole writer of `branch_cids` sits behind a pin CID. A node with no object
/// pinning configured never writes that table, so a resolve keyed on it could
/// never answer there and every open pull request without a stored head reported
/// `head_resolved: false` forever. The residual limit is that only pushes taken
/// after this shipped have rows, so a branch last pushed before then still does
/// not resolve.
///
/// The resolved head is persisted, which makes an unauthenticated GET write. That
/// is only acceptable because the write is self-limiting: it fires exactly when
/// `head_commit` is absent and it fills it, and the fill is conditioned on that
/// same absence in SQL, so the PERSIST happens at most once per pull request.
/// The resolve attempt itself is not once-only — a pull request whose branch does
/// not resolve stores nothing, so there is nothing to short-circuit on and the
/// lookup runs again on every read until it succeeds.
async fn rollup_head(
state: &AppState,
record: &crate::db::RepoRecord,
pr: &crate::db::PullRequest,
) -> Result<Option<String>> {
if let Some(stored) = pr.head_commit.clone() {
return Ok(Some(stored));
}
if pr.status != "open" {
return Ok(None);
}
// The push path records the full ref while a pull request stores the bare
// branch name, the same mismatch `crate::api::repos::branch_from_ref`
// reconciles on the write side; this is that mapping run backwards. The empty
// branch is excluded for the same reason it is there: `refs/heads/` is not a
// branch and nothing can have written it.
if pr.source_branch.is_empty() {
return Ok(None);
}
let ref_name = format!("refs/heads/{}", pr.source_branch);
#[cfg(test)]
BRANCH_RESOLVES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let resolved = state
.db
.latest_push_sha_for_ref(&record.id, &ref_name)
.await?
// A recorded head that is not a commit SHA is no target at all, and
// storing it would leave the rollup pointing at nothing forever.
.and_then(|sha| normalize_sha(&sha).ok());
let Some(sha) = resolved else {
return Ok(None);
};
// Best effort, and deliberately not `?`. The head is already resolved and the
// response does not depend on this write landing, so a transient database
// failure here must not turn a served read into a 500; the next read resolves
// again and re-attempts the fill. Same catch-and-log the sibling writes on the
// push path use (`update_open_pr_heads`, `record_push_events`).
if let Err(e) = state.db.set_pr_head_if_absent(&pr.id, &sha).await {
tracing::warn!(
err = %e,
pr_id = %pr.id,
"failed to persist a resolved pull request head; serving the resolved head anyway"
);
}
Ok(Some(sha))
}
/// KTD-1's fold: any error or failure yields failure, else any pending yields
/// pending, else success. An empty set is pending, never success — total absence
/// of a verdict is its own state and must not read as a pass (R10).
fn combined_state(statuses: &[StatusEntry]) -> &'static str {
if statuses.is_empty() {
return "pending";
}
if statuses
.iter()
.any(|s| s.state == "error" || s.state == "failure")
{
"failure"
} else if statuses.iter().any(|s| s.state == "pending") {
"pending"
} else {
"success"
}
}
/// 40 hex characters, lowercased. The SHA is never existence-checked (KTD-7):
/// a claim describes a commit object, and the object may not have arrived yet.
fn normalize_sha(sha: &str) -> Result<String> {
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(sha.to_ascii_lowercase())
} else {
Err(AppError::BadRequest(
"commit sha must be exactly 40 hexadecimal characters".into(),
))
}
}
/// One size bound on persisted signature material, measured in bytes.
fn bound(what: &str, len: usize, max: usize) -> Result<()> {
if len > max {
return Err(AppError::BadRequest(format!(
"{what} must be at most {max} bytes"
)));
}
Ok(())
}
fn validate_state(state: &str) -> Result<String> {
if CLAIM_STATES.contains(&state) {
Ok(state.to_string())
} else {
Err(AppError::BadRequest(format!(
"state must be one of {}",
CLAIM_STATES.join(", ")
)))
}
}
/// The context is the projection key, so it is trimmed, bounded, and required to
/// be free of control characters.
fn validate_context(context: &str) -> Result<String> {
let trimmed = context.trim();
if trimmed.is_empty() || trimmed.chars().count() > MAX_CONTEXT_CHARS {
return Err(AppError::BadRequest(format!(
"context must be 1 to {MAX_CONTEXT_CHARS} characters"
)));
}
if trimmed.chars().any(char::is_control) {
return Err(AppError::BadRequest(
"context must not contain control characters".into(),
));
}
Ok(trimmed.to_string())
}
fn validate_target_url(url: Option<&str>) -> Result<Option<String>> {
let Some(url) = url else { return Ok(None) };
if url.chars().count() > MAX_TARGET_URL_CHARS {
return Err(AppError::BadRequest(format!(
"target_url must be at most {MAX_TARGET_URL_CHARS} characters"
)));
}
let lower = url.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Ok(Some(url.to_string()))
} else {
Err(AppError::BadRequest(
"target_url must be an http or https URL".into(),
))
}
}
fn validate_description(description: Option<&str>) -> Result<Option<String>> {
match description {
Some(d) if d.chars().count() > MAX_DESCRIPTION_CHARS => Err(AppError::BadRequest(format!(
"description must be at most {MAX_DESCRIPTION_CHARS} characters"
))),
other => Ok(other.map(str::to_string)),
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large. Load diff
-159
View File
@@ -17,53 +17,6 @@ use crate::state::AppState;
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
/// The RFC 9421 material this request was actually verified against, injected
/// alongside [`AuthenticatedDid`] by `require_signature`.
///
/// A handler that records a signed claim as history has to store it: the header
/// values and the canonical signing string are gone once the request ends, and a
/// stored claim nobody can re-verify is not history. `signing_string` is the exact
/// byte sequence the Ed25519 verification succeeded over, not a rebuild — a
/// handler reconstructing it from headers would be verifying a different string
/// than the middleware did.
#[derive(Clone, Debug)]
pub struct SignatureMaterial {
/// The `Signature` header value.
pub signature: String,
/// The `Signature-Input` header value.
pub signature_input: String,
/// The canonical bytes the signature covered.
pub signing_string: String,
/// The buffered request body, on the routes that persist it. The signing
/// string covers it only through the content-digest, so without these bytes
/// a stored claim can be shown to carry *a* valid signature over *some*
/// digest and nothing more. Carried here because the body is consumed
/// downstream by the extractor and cannot be recovered afterwards.
///
/// `Option`, and populated only behind [`PersistsSignedBody`], because a
/// route that never stores the body has no use for a second handle to it.
/// The unlike-sized field is this one: the three above are header-derived
/// and bounded by the server's header limit, while a receive-pack POST
/// carries up to GITLAWB_MAX_PACK_BYTES (2 GB by default). `Bytes`, not
/// `Vec<u8>`, so the handle the marked routes do take is a refcount bump
/// over the buffer the middleware already holds rather than a copy.
pub body: Option<axum::body::Bytes>,
}
/// Marks a route group whose handler persists the signed request body.
///
/// Applied as an extension layer OUTSIDE the auth layers, so it is on the
/// request before [`require_signature`] decides whether to carry the body. A
/// marker applied inside them is never seen, and the failure is silent: the
/// handler still compiles, tests that inject material by hand still pass, and
/// production stores nothing. The status write handler refuses an absent body
/// for that reason.
///
/// A marker rather than a path check because `require_signature` must not learn
/// which URL persists claims; that knowledge belongs at the route table.
#[derive(Clone, Copy, Debug)]
pub struct PersistsSignedBody;
/// Whether `caller` is authorized to push to `record`.
///
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
@@ -285,28 +238,10 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
tracing::info!(did = %sig.key_id, "✓ authenticated request");
// The body travels only where it is stored. On every other signed route the
// middleware's handle would keep the buffer alive for the rest of the layer
// chain, until the handler's own extractor consumes the request.
let body = parts
.extensions
.get::<PersistsSignedBody>()
.map(|_| body_bytes.clone());
let material = SignatureMaterial {
signature: sig_header,
signature_input: sig_input,
signing_string,
body,
};
let mut request = Request::from_parts(parts, Body::from(body_bytes));
request
.extensions_mut()
.insert(AuthenticatedDid(sig.key_id.to_string()));
// Carry the verified material forward for handlers that persist a signed
// claim; none of it can be recovered once the request is gone.
request.extensions_mut().insert(material);
next.run(request).await
}
@@ -588,100 +523,6 @@ mod tests {
}
}
/// Reports what the signature middleware put in `SignatureMaterial.body`,
/// so a test can tell "captured" from "deliberately not captured" apart
/// from "the middleware never ran".
async fn report_body(material: Option<axum::Extension<SignatureMaterial>>) -> String {
match material {
Some(axum::Extension(m)) => match m.body {
Some(bytes) => format!("captured:{}", bytes.len()),
None => "absent".to_string(),
},
None => "no-material".to_string(),
}
}
fn signed_post(path: &str, body: &[u8]) -> Request {
let kp = Keypair::generate();
let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", path, body);
Request::builder()
.method("POST")
.uri(path)
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(axum::body::Body::from(body.to_vec()))
.unwrap()
}
/// The body handle travels only on a route that asked for it.
///
/// Both arms drive a genuinely signed request through `require_signature`;
/// the only difference is the [`PersistsSignedBody`] marker layer. A
/// middleware that captured unconditionally would make both arms read
/// `captured`, and one that never read the marker would make both read
/// `absent`, so the pair pins the decision rather than the mechanism.
#[tokio::test]
async fn the_body_is_carried_only_when_the_route_marks_itself_as_persisting_it() {
let body = br#"{"state":"success"}"#;
// The marker layer is added AFTER the middleware layer, which is what
// makes it the outer of the two and therefore the one that runs first.
// This is the same relative position `build_router` uses on the status
// write group, where the marker sits outside `add_auth_layers`.
let marked = Router::new()
.route("/", axum::routing::post(report_body))
.layer(middleware::from_fn(require_signature))
.layer(axum::Extension(PersistsSignedBody));
let resp = marked.oneshot(signed_post("/", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
assert_eq!(
String::from_utf8(seen.to_vec()).unwrap(),
format!("captured:{}", body.len()),
"a route carrying the persist marker must receive the buffered body"
);
let unmarked = Router::new()
.route("/", axum::routing::post(report_body))
.layer(middleware::from_fn(require_signature));
let resp = unmarked.oneshot(signed_post("/", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
assert_eq!(
String::from_utf8(seen.to_vec()).unwrap(),
"absent",
"a signed route that never persists the body must not be handed a handle to it"
);
}
/// The marker is read from the request the middleware was given, and the
/// request it hands downstream still carries it. Both halves matter: the
/// first is the capture decision, the second is that rebuilding the request
/// from its parts does not drop extensions a later layer may want.
#[tokio::test]
async fn the_marker_survives_the_request_rebuild() {
async fn report_marker(marker: Option<axum::Extension<PersistsSignedBody>>) -> String {
match marker {
Some(_) => "marked".to_string(),
None => "unmarked".to_string(),
}
}
let app = Router::new()
.route("/", axum::routing::post(report_marker))
.layer(middleware::from_fn(require_signature))
.layer(axum::Extension(PersistsSignedBody));
let resp = app.oneshot(signed_post("/", b"{}")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
assert_eq!(
String::from_utf8(seen.to_vec()).unwrap(),
"marked",
"the marker must reach the handler, so the middleware saw it too"
);
}
#[tokio::test]
async fn require_ucan_chain_no_header_passes_through() {
let state = make_test_state(Keypair::generate().did());
File diff suppressed because it is too large. Load diff
-370
View File
@@ -303,131 +303,6 @@ fn validate_path_components(owner_did: &str, repo_name: &str) -> Result<()> {
Ok(())
}
/// Validate a peer-supplied `owner/name` sync slug and return its two halves.
///
/// The sync queue carries a single `repo` string that peers control, and the
/// worker turns it into a filesystem path. `PathBuf::join` does not normalize,
/// and an absolute second component replaces the accumulated path, so an
/// unvalidated `a//tmp/x` resolved to `/tmp/x.git` outside `repos_dir` (#272).
///
/// The halves are checked with the same validators that guard
/// `RepoStore::local_path`, so there is one owner rule and one name rule in the
/// crate. The one rule added here is the leading `.`/`-` check on the owner
/// half: `validate_owner_did` has no such rule (it also serves full DIDs, which
/// always start with `d`), and without it an owner half of `.` puts a
/// peer-controlled mirror at the `repos_dir` root, which canonicalizes back
/// inside the root and so passes containment.
pub(crate) fn validate_repo_slug(slug: &str) -> Result<(&str, &str)> {
let mut parts = slug.split('/');
let (Some(owner), Some(name)) = (parts.next(), parts.next()) else {
anyhow::bail!("repo slug must be 'owner/name'");
};
if parts.next().is_some() {
anyhow::bail!("repo slug must contain exactly one '/'");
}
if owner.is_empty() || name.is_empty() {
anyhow::bail!("repo slug has an empty owner or name");
}
if owner.starts_with('.') || owner.starts_with('-') {
anyhow::bail!("repo slug owner must not start with '.' or '-'");
}
// The owner half becomes one path component, so it is bounded by NAME_MAX
// (255), not by the DID column's 256. The two differ by exactly one, and
// that one length is the gap that matters: validate_owner_did accepts 256,
// create_dir_all then fails with ENAMETOOLONG on every attempt, and the
// worker leaves such a row pending, so it is re-picked forever. Rejecting
// it here means an undeliverable slug never enters the queue at all.
if owner.len() > 255 {
anyhow::bail!("repo slug owner exceeds 255 chars");
}
validate_owner_did(owner)?;
validate_repo_name(name)?;
Ok((owner, name))
}
/// The answer from [`path_within_root`].
///
/// Three-valued rather than a bool because the two negative answers call for
/// opposite handling. `Outside` is a deterministic verdict about a hostile or
/// misconfigured path: the same input fails the same way forever, so the caller
/// can retire the work. `IoError` says the question could not be answered at
/// all (EACCES, an unmounted root), which is transient, so the caller must keep
/// the work and try again rather than permanently retire a legitimate repo.
#[derive(Debug)]
pub(crate) enum Containment {
/// The candidate resolves inside the root.
Contained,
/// The candidate resolves outside the root.
Outside,
/// The filesystem could not answer the question.
IoError(std::io::Error),
}
/// Does `candidate` canonically resolve inside `root`?
///
/// The third layer of path defence, after the character allowlist and the
/// component walk on `RepoStore::local_path`. Those two read the path as text
/// and cannot see a symlink standing between the root and the target (#272).
///
/// One contract covers both the clone and the fetch branch. `symlink_metadata`
/// decides which:
///
/// * The candidate exists (including as a symlink), so the candidate itself is
/// canonicalized. That resolves the link and catches a mirror path that is a
/// symlink to a bare repo outside the root, which a parent-only check misses
/// entirely: the parent canonicalizes clean, `exists()` follows the link, and
/// the fetch then writes through it.
/// * The candidate does not exist, so its parent is canonicalized instead.
/// This is the first-clone case. Canonicalizing the candidate unconditionally
/// would reject every first clone, since `canonicalize` errors on a path that
/// does not exist.
///
/// Pure: it reads the filesystem and never creates, moves, or removes anything.
/// Callers that need the parent directory to exist create it themselves before
/// asking, because a predicate that created a directory as a side effect of
/// being asked would be wrong for a caller asking about a path it is about to
/// delete.
pub(crate) fn path_within_root(candidate: &Path, root: &Path) -> Containment {
let root = match root.canonicalize() {
Ok(p) => p,
// A root that cannot be resolved is an operator condition, never a
// verdict on the candidate, so every error kind is retryable here.
Err(e) => return Containment::IoError(e),
};
let resolved = match std::fs::symlink_metadata(candidate) {
// The candidate exists as an entry: resolve it, links and all. A failure
// now (a dangling symlink, a permission change mid-flight) is an I/O
// answer, since the entry was there a moment ago.
Ok(_) => match candidate.canonicalize() {
Ok(p) => p,
Err(e) => return Containment::IoError(e),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(parent) = candidate.parent() else {
return Containment::Outside;
};
match parent.canonicalize() {
Ok(p) => p,
// A parent that is not there is a real answer about where this
// path sits; anything else is the filesystem failing to answer.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Containment::Outside;
}
Err(e) => return Containment::IoError(e),
}
}
// Neither "it is there" nor "it is not there": we cannot tell.
Err(e) => return Containment::IoError(e),
};
if resolved.starts_with(&root) {
Containment::Contained
} else {
Containment::Outside
}
}
fn validate_owner_did(owner_did: &str) -> Result<()> {
if owner_did.is_empty() {
anyhow::bail!("owner_did is empty");
@@ -530,251 +405,6 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
mod tests {
use super::*;
// ── sync slug validation (#272) ────────────────────────────────────────
#[test]
fn slug_accepts_owner_and_name() {
let (owner, name) = validate_repo_slug("z6Mkfoo/hello").expect("valid slug");
assert_eq!(owner, "z6Mkfoo");
assert_eq!(name, "hello");
}
#[test]
fn slug_rejects_traversal_in_owner_half() {
assert!(validate_repo_slug("../hello").is_err());
}
#[test]
fn slug_rejects_owner_half_only_the_did_validator_catches() {
// These are the cases that isolate the `validate_owner_did` delegation.
// `../hello` does NOT: the leading-character rule above rejects it
// first, so deleting the delegation leaves that case green. Each owner
// half here has exactly one separator, a non-empty name, and a leading
// character the slug rules allow, so only the delegation can reject it.
for bad in [
"a..b/hello", // interior `..` sequence
"a%2e%2e/hello", // percent-encoded, disallowed `%`
"own\\er/hello", // backslash
] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_extra_separator() {
// The verified #272 escape: `a//tmp/x` joined to an absolute
// `/tmp/x.git` outside repos_dir.
assert!(validate_repo_slug("a//tmp/gitlawb-probe").is_err());
assert!(validate_repo_slug("../../etc/evil").is_err());
assert!(validate_repo_slug("a/../../x").is_err());
}
#[test]
fn slug_rejects_trailing_segment_only_the_separator_count_catches() {
// The case that isolates the separator-count rule. Every slug in
// `slug_rejects_extra_separator` is caught by some earlier rule
// instead: `a//tmp/...` has an empty name half, `../../etc/evil` trips
// the leading-character rule, and `a/../../x` has `..` as its name. Here
// both halves are individually valid, so only the count can reject it.
// It matters because the worker would otherwise join
// `repos_dir/z6Mkfoo/hello.git` while composing the remote URL from the
// full three-segment slug, silently mirroring one repo under another's
// path.
assert!(validate_repo_slug("z6Mkfoo/hello/extra").is_err());
}
#[test]
fn slug_rejects_missing_separator() {
for bad in ["..", "demo", ""] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_empty_half() {
for bad in ["/hello", "z6Mkfoo/"] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_leading_dot_or_dash_owner() {
// `./hello` would otherwise resolve to a mirror at the repos_dir root,
// which the containment check would approve.
for bad in ["./hello", "-owner/hello"] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_bad_name_half() {
for bad in [
"z6Mkfoo/he\0llo",
"z6Mkfoo/.hidden",
"z6Mkfoo/-dash",
"z6Mkfoo/a..b",
] {
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn slug_rejects_overlong_halves() {
let long_owner = format!("{}/hello", "z".repeat(257));
let long_name = format!("z6Mkfoo/{}", "n".repeat(101));
assert!(validate_repo_slug(&long_owner).is_err());
assert!(validate_repo_slug(&long_name).is_err());
}
#[test]
fn slug_rejects_owner_half_at_the_filesystem_name_limit() {
// The owner half becomes a single path component, and Linux NAME_MAX is
// 255, so 256 is accepted by validate_owner_did (which bails only above
// 256) but can never be created on disk. That made the sync row
// permanently un-runnable: create_dir_all failed with ENAMETOOLONG on
// every pass and the worker left the row pending, so ten unsigned
// requests could hold the whole oldest-first batch forever.
assert!(validate_repo_slug(&format!("{}/hello", "z".repeat(256))).is_err());
// 255 is the largest creatable component and must still be accepted, so
// the bound is not quietly over-tightened.
assert!(validate_repo_slug(&format!("{}/hello", "z".repeat(255))).is_ok());
}
// ── canonical containment (#272) ───────────────────────────────────────
use tempfile::TempDir;
#[test]
fn containment_accepts_a_path_inside_the_root() {
let root = TempDir::new().unwrap();
let inside = root.path().join("z6Mkfoo");
std::fs::create_dir_all(&inside).unwrap();
assert!(matches!(
path_within_root(&inside.join("hello.git"), root.path()),
Containment::Contained
));
}
#[test]
fn containment_rejects_a_sibling_outside_the_root() {
let base = TempDir::new().unwrap();
let root = base.path().join("root");
let sibling = base.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&sibling).unwrap();
assert!(matches!(
path_within_root(&sibling, &root),
Containment::Outside
));
}
#[cfg(unix)]
#[test]
fn containment_rejects_a_symlinked_directory_inside_the_root() {
use std::os::unix::fs::symlink;
let base = TempDir::new().unwrap();
let root = base.path().join("root");
let outside = base.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let link = root.join("owner");
symlink(&outside, &link).unwrap();
assert!(matches!(
path_within_root(&link, &root),
Containment::Outside
));
}
#[cfg(unix)]
#[test]
fn containment_rejects_a_symlinked_file_inside_the_root() {
use std::os::unix::fs::symlink;
let base = TempDir::new().unwrap();
let root = base.path().join("root");
std::fs::create_dir_all(&root).unwrap();
let outside = base.path().join("secret.txt");
std::fs::write(&outside, b"x").unwrap();
let link = root.join("hello.git");
symlink(&outside, &link).unwrap();
assert!(matches!(
path_within_root(&link, &root),
Containment::Outside
));
}
#[test]
fn containment_accepts_a_missing_candidate_whose_parent_is_inside() {
// The first-clone case: the mirror path does not exist yet, so only the
// parent can be canonicalized. Rejecting this is total loss of mirroring.
let root = TempDir::new().unwrap();
let owner = root.path().join("z6Mkfoo");
std::fs::create_dir_all(&owner).unwrap();
let candidate = owner.join("hello.git");
assert!(!candidate.exists());
assert!(matches!(
path_within_root(&candidate, root.path()),
Containment::Contained
));
}
#[cfg(unix)]
#[test]
fn containment_rejects_a_missing_candidate_under_a_symlinked_parent() {
use std::os::unix::fs::symlink;
let base = TempDir::new().unwrap();
let root = base.path().join("root");
let outside = base.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, root.join("z6Mkfoo")).unwrap();
let candidate = root.join("z6Mkfoo").join("hello.git");
assert!(matches!(
path_within_root(&candidate, &root),
Containment::Outside
));
}
#[cfg(unix)]
#[test]
fn containment_reports_io_error_for_a_dangling_symlink() {
// The link entry exists, so the candidate is the thing to resolve, and
// resolving it fails. That is an I/O answer, not a verdict of Outside:
// the worker must retry rather than permanently retire the row.
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let link = root.path().join("hello.git");
symlink(root.path().join("nothing-here"), &link).unwrap();
assert!(matches!(
path_within_root(&link, root.path()),
Containment::IoError(_)
));
}
#[test]
fn containment_reports_io_error_for_an_uncanonicalizable_root() {
// A repos_dir that cannot be resolved is an operator condition (an
// unmounted volume, a bad config), not a hostile path.
let base = TempDir::new().unwrap();
let root = base.path().join("not-mounted");
let candidate = base.path().join("not-mounted").join("hello.git");
assert!(matches!(
path_within_root(&candidate, &root),
Containment::IoError(_)
));
}
#[test]
fn containment_creates_nothing_on_disk() {
// The predicate is pure: the admin purge path asks it about directories
// it is about to delete, so creating one as a side effect would be wrong.
let root = TempDir::new().unwrap();
let candidate = root.path().join("z6Mkfoo").join("hello.git");
let _ = path_within_root(&candidate, root.path());
assert!(!candidate.exists());
assert!(!root.path().join("z6Mkfoo").exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
}
// ── repo_name validation ───────────────────────────────────────────────
#[test]
-121
View File
@@ -86,127 +86,6 @@ pub async fn receive_pack(
.body(Body::from(output))?)
}
// ── receive-pack report-status ────────────────────────────────────────────
//
// PORTED VERBATIM from PR #384 (`fix/issue-26-split-1-durable-post-receive`),
// which introduced these two functions for the same reason: `receive_pack`
// forwards git's protocol response untouched, and a zero exit says nothing
// about whether any individual ref was taken. Kept byte-for-byte identical so
// the two branches converge on one parser rather than growing two that disagree
// about what "accepted" means; whichever lands second should drop its copy.
/// Parse the git-receive-pack report-status output to determine per-ref
/// success/failure. Returns `(unpack_ok, per_ref_results)` where
/// `per_ref_results` is a list of `(ref_name, is_ok)`.
///
/// The report-status format (after the sideband framing) is:
/// ```text
/// unpack ok\n (or: unpack fail\n)
/// ok <refname>\n (per successful ref)
/// ng <refname> <reason>\n (per rejected ref)
/// \n (empty line terminates)
/// ```
///
/// Returns `None` if the output cannot be parsed (e.g. the client did
/// not request report-status, or the output is truncated). In that
/// case the caller should treat all refs as uncertain.
pub fn parse_report_status(output: &[u8]) -> Option<(bool, Vec<(String, bool)>)> {
let text = std::str::from_utf8(output).ok()?;
// Strip sideband framing: each line starts with a pkt-line length
// prefix and a channel byte (1 = stdout, 2 = stderr). The actual
// data starts after the first `0000` flush packet or after we
// strip sideband bytes.
let stripped = strip_sideband(text)?;
// DIVERGES FROM #384 by these three lines, and deliberately.
//
// When the client asked for `side-band-64k` — which `git push` over smart
// HTTP does — receive-pack multiplexes the report onto band 1, and what
// travels on that band is itself a pkt-line stream. One pass of
// `strip_sideband` peels the outer framing and leaves `000eunpack ok\n...`,
// whose first line is not `unpack ok`, so the parse below returns None and
// every ref falls back to inconclusive: the parser would answer "cannot
// tell" for exactly the pushes it exists to classify. A second pass peels
// the inner layer. It is a no-op on the single-wrapped shape, because
// plain report text does not begin with four hex digits and
// `strip_sideband` declines it.
let stripped = strip_sideband(&stripped).unwrap_or(stripped);
let lines: Vec<&str> = stripped.lines().collect();
if lines.is_empty() {
return None;
}
// First non-empty line is "unpack ok" or "unpack fail".
let unpack_line = lines.iter().find(|l| !l.is_empty())?;
let unpack_ok = if unpack_line.starts_with("unpack ok") {
true
} else if unpack_line.starts_with("unpack fail") {
false
} else {
return None;
};
let mut results = Vec::new();
for line in &lines[1..] {
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("ok ") {
results.push((rest.to_string(), true));
} else if let Some(rest) = line.strip_prefix("ng ") {
// "ng <refname> <reason>" — skip the reason
let ref_name = rest.split_whitespace().next()?.to_string();
results.push((ref_name, false));
}
}
Some((unpack_ok, results))
}
/// Strip git sideband framing from a pkt-line encoded output.
/// Sideband-encoded lines start with a 4-hex-digit length, then a
/// channel byte (0x01=stdout, 0x02=stderr), then payload. Returns
/// the decoded payload lines concatenated, or `None` if the framing
/// is malformed.
fn strip_sideband(text: &str) -> Option<String> {
let mut output = String::new();
let mut pos = 0;
let bytes = text.as_bytes();
loop {
if pos + 4 > bytes.len() {
break;
}
let len_str = std::str::from_utf8(&bytes[pos..pos + 4]).ok()?;
let len = usize::from_str_radix(len_str, 16).ok()?;
if len == 0 {
// Flush packet — end of sideband stream
break;
}
if len < 4 || pos + len > bytes.len() {
break;
}
let pkt_data = std::str::from_utf8(&bytes[pos + 4..pos + len]).ok()?;
pos += len;
// Sideband: first byte is channel (1=stdout, 2=stderr)
if let Some(payload) = pkt_data.strip_prefix('\x01') {
output.push_str(payload);
} else if pkt_data.starts_with('\x02') {
// stderr — skip (git error messages)
} else {
// Not sideband encoded — pass through
output.push_str(pkt_data);
}
}
if output.is_empty() {
None
} else {
Some(output)
}
}
/// Sends SIGTERM to a child's whole process group on drop, unless disarmed first.
///
/// A served `git upload-pack`/`receive-pack` forks helpers such as `pack-objects`.
+1 -5
View File
@@ -235,11 +235,7 @@ pub fn record_auth_failure(route: &str, reason: &str) {
}
}
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped,
/// rejected, deferred}. `rejected` is terminal and caused by a guard (a
/// malformed slug, or a path resolving outside repos_dir); `deferred` leaves
/// the row pending for a later pass, so a queue stalled on an operator
/// condition is visible rather than looking idle.
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped}.
pub fn record_sync_processed(status: &str) {
if let Some(c) = SYNC_PROCESSED.get() {
c.with_label_values(&[status]).inc();
+1 -65
View File
@@ -14,7 +14,7 @@ use tracing::Level;
use crate::api::{
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles,
protect, pulls, register, replicas, repos, resolve, stars, status, tasks, visibility, webhooks,
protect, pulls, register, replicas, repos, resolve, stars, tasks, visibility, webhooks,
};
use crate::auth;
use crate::rate_limit;
@@ -183,64 +183,6 @@ pub fn build_router(state: AppState) -> Router {
state.clone(),
);
// ── Status claim writes — signature, plus the per-DID throttle AND the
// per-IP flood brake `creation_routes` carries. Not in `write_routes`, which
// has neither: every call here appends a row, so an unthrottled writer grows
// the claim log on demand and the three per-repo caps would be the only bound.
let status_write_routes = add_auth_layers(
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/statuses/{sha}",
post(status::create_status),
)
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(state.rate_limiter.clone())),
state.clone(),
)
// The route's advertised body bound, applied at the transport. It sits
// OUTSIDE the auth layers because `require_signature` collects and hashes
// the whole body before any handler runs: the handler's own check measures
// an allocation that has already happened, so without this layer the 8 KiB
// figure bounded what gets stored and not what gets buffered, and
// concurrent requests multiplied exactly the cost it was written to
// prevent. It sits INSIDE the rate limiters so an oversized request is
// still counted by the per-IP brake before it is refused.
//
// A declared Content-Length over the limit is answered 413 without reading
// a byte; a body that declares nothing (or understates) is truncated at the
// limit and fails where it is read, which `require_signature` reports as an
// unreadable body.
.layer(RequestBodyLimitLayer::new(status::MAX_REQUEST_BODY_BYTES))
// The one route that persists the signed request body, so the one route
// whose signature material carries it. Applied OUTSIDE `add_auth_layers`
// (outermost = runs first) because `require_signature` reads this marker to
// decide whether to carry the body; a layer added inside the auth pair runs
// after the middleware and is never seen. `create_status` refuses an absent
// body rather than storing an empty column, so getting this order wrong
// fails loudly instead of quietly writing unverifiable claims.
.layer(axum::Extension(auth::PersistsSignedBody))
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
.layer(axum::Extension(rate_limit::IpRateLimiter {
limiter: state.create_ip_rate_limiter.clone(),
trust: state.push_limiter_trust,
}));
// ── Status claim reads — open, behind `optional_signature` so the handler
// sees the caller when one signs and still answers a public repo for an
// anonymous reader. Without the layer the handler's optional auth extension
// is None for EVERY caller, including the owner, and every private repo
// reads as not-found.
let status_read_routes = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/commits/{sha}/status",
get(status::commit_status),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/status",
get(status::pull_request_status),
)
.layer(middleware::from_fn(auth::optional_signature));
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
// routes only — all other API routes keep axum's default 2 MB cap.
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
@@ -431,10 +373,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/v1/repos/{owner}/{repo}/events",
get(events::list_repo_events),
)
.route(
"/api/v1/repos/{owner}/{repo}/push-events",
get(events::list_repo_push_events),
)
.route("/api/v1/agents", get(agents::list_agents))
.route("/api/v1/agents/{did}", get(agents::show_agent))
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
@@ -531,8 +469,6 @@ pub fn build_router(state: AppState) -> Router {
.merge(profile_write_routes)
.merge(creation_routes)
.merge(write_routes)
.merge(status_write_routes)
.merge(status_read_routes)
.merge(git_write_routes)
.merge(git_read_routes)
.merge(issue_write_routes)
File diff suppressed because it is too large. Load diff
-147
View File
@@ -109,153 +109,6 @@ pub(crate) fn signed_request_as(did: &str, method: Method, uri: &str, body: Body
.expect("request builder")
}
/// Slice a region out of a module's own source (obtained with `include_str!`)
/// and drop full-line `//` comments from it.
///
/// Several guards in `api/` assert that a piece of PRODUCTION code does or does
/// not contain something: that the receive-pack handler really calls the
/// push-event recorder, that the status module never acquires the repository.
/// Each guard carried its own copy of this parse, and three copies of a parser
/// is three places for the slicing to drift apart while every guard still reads
/// green.
///
/// `start` and `end` are literal anchors. The region runs from the first
/// occurrence of `start` (inclusive; the top of the file when `None`) to the
/// next occurrence of `end` (exclusive; end of file when `None`).
///
/// Returns `None` when an anchor is missing, rather than silently falling back
/// to a larger or empty region. Each caller supplies its own message for that
/// case and keeps its own assertion that the region really covers what it claims
/// to, which is what stops a bug in this one helper from turning every guard
/// that uses it into a vacuous pass.
///
/// Comment lines are stripped so that a doc comment naming the very call a guard
/// searches for cannot stand in for the call itself.
pub(crate) fn scrape_source_region(
src: &str,
start: Option<&str>,
end: Option<&str>,
) -> Option<String> {
let from = match start {
Some(anchor) => src.find(anchor)?,
None => 0,
};
let rest = &src[from..];
let to = match end {
// Search past the first CHARACTER, so an `end` anchor that also matches
// at the very start of the region cannot collapse it to nothing. A fixed
// one-byte skip does the same job on ASCII and lands mid-character on a
// multibyte first character, where the slice fails and the helper answers
// `None`, which is indistinguishable, to every caller, from a missing
// anchor.
Some(anchor) => {
let skip = rest.chars().next().map_or(0, char::len_utf8);
rest.get(skip..)?.find(anchor)? + skip
}
None => rest.len(),
};
Some(
rest[..to]
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n"),
)
}
#[cfg(test)]
mod scrape_source_region_tests {
use super::scrape_source_region;
const SRC: &str = "prelude line\nfn target() {\n // calls_the_thing()\n calls_the_thing();\n}\nfn later() {\n calls_the_thing();\n}\n";
#[test]
fn stops_at_the_end_anchor_so_a_later_function_cannot_satisfy_a_guard() {
let body = scrape_source_region(SRC, Some("fn target()"), Some("\n}")).unwrap();
assert!(body.contains("calls_the_thing();"));
assert!(
!body.contains("fn later()"),
"the region must stop at the first column-zero brace, got: {body}"
);
}
#[test]
fn strips_full_line_comments_so_a_doc_comment_cannot_stand_in_for_code() {
let commented = "fn target() {\n // calls_the_thing();\n}\n";
let body = scrape_source_region(commented, Some("fn target()"), Some("\n}")).unwrap();
assert!(
!body.contains("calls_the_thing"),
"a commented-out call must not survive the scrape, got: {body}"
);
}
#[test]
fn no_start_anchor_reads_from_the_top_of_the_file() {
let body = scrape_source_region(SRC, None, Some("fn later()")).unwrap();
assert!(body.starts_with("prelude line"));
assert!(!body.contains("fn later()"));
}
#[test]
fn no_end_anchor_reads_to_the_end_of_the_file() {
let body = scrape_source_region(SRC, Some("fn later()"), None).unwrap();
assert!(body.trim_end().ends_with('}'));
}
/// A missing anchor is None, never a silently wider or empty region: that is
/// what lets each caller keep its own "the thing I meant to scan is gone"
/// message instead of asserting against whatever the fallback produced.
#[test]
fn a_missing_anchor_is_none_rather_than_a_fallback_region() {
assert!(scrape_source_region(SRC, Some("fn absent()"), Some("\n}")).is_none());
assert!(scrape_source_region(SRC, Some("fn target()"), Some("~absent~")).is_none());
}
/// A region whose first character is multibyte is scraped, not reported
/// missing.
///
/// The end-anchor search skips the region's first CHARACTER. Skipping a
/// fixed byte instead lands mid-character here, the slice fails, and the
/// helper answers `None`, which every caller reads as "the anchor is gone"
/// and reports as a missing region. A guard that says "not found"
/// for input that is present is worse than one that fails loudly, and the
/// five callers sharing this helper all inherit it.
#[test]
fn a_multibyte_first_character_does_not_read_as_a_missing_anchor() {
let src = "é prelude\nfn target() {\n body();\n}\nfn later() {}\n";
let body = scrape_source_region(src, None, Some("\n}"))
.expect("a region starting on a multibyte character must be scraped");
assert!(
body.starts_with("é prelude") && body.contains("body();"),
"the region must cover the multibyte start through the end anchor, got: {body}"
);
assert!(
!body.contains("fn later()"),
"the region must still stop at the end anchor, got: {body}"
);
// The same through a multibyte START anchor, so the skip is measured
// from the region rather than from the top of the file.
let body = scrape_source_region(src, Some("é"), Some("\n}"))
.expect("a multibyte start anchor must be scraped");
assert!(body.starts_with('é'), "got: {body}");
}
/// The end anchor is searched past the region's first character, so a
/// region whose own start also matches the end anchor is not collapsed to
/// nothing.
#[test]
fn an_end_anchor_matching_at_the_start_does_not_collapse_the_region() {
let src = "\n}\nfn target() {\n body();\n}\n";
let body = scrape_source_region(src, None, Some("\n}")).unwrap();
assert!(
body.contains("fn target()") && body.contains("body();"),
"got: {body}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "gl"
description = "The gitlawb CLI — identity management, node control, MCP server"
version = "0.7.1" # x-release-please-version
version = "0.7.0" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
+277 -202
View File
@@ -88,20 +88,78 @@ fn sanitize_excerpt(s: &str) -> String {
out
}
/// Whether `u` parses as a trusted URL.
/// Fixed warning when `GITLAWB_ICAPTCHA_INSECURE` is present but not an
/// explicit truthy value. Does not include the raw env value (arbitrary
/// deployment input — secrets / terminal controls).
fn warn_icaptcha_insecure_non_truthy() {
tracing::warn!(
"GITLAWB_ICAPTCHA_INSECURE is set but not truthy (expected 1 or true); \
loopback HTTP trust relaxation remains disabled"
);
}
/// Parse a possibly-present `GITLAWB_ICAPTCHA_INSECURE` string.
///
/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a
/// runtime escape hatch for integration tests) the function also trusts
/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path
/// can be exercised against a local mockito server.
fn is_https(u: &str) -> bool {
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
/// enables the loopback-HTTP trust relaxation. Presence alone is not enough:
/// `=0`, `=false`, or an empty value leave it off. A non-empty non-truthy
/// value emits [`warn_icaptcha_insecure_non_truthy`].
fn icaptcha_insecure_from_str(raw: Option<&str>) -> bool {
let Some(v) = raw else {
return false;
};
let t = v.trim();
if t.eq_ignore_ascii_case("1") || t.eq_ignore_ascii_case("true") {
true
} else {
if !t.is_empty() {
warn_icaptcha_insecure_non_truthy();
}
false
}
}
/// Interpret `std::env::var("GITLAWB_ICAPTCHA_INSECURE")` without reading the
/// environment again — used by production and by unit tests.
///
/// `NotPresent` → disabled. `NotUnicode` → disabled + the same fixed warning
/// as a present non-truthy value (fail closed; do not treat as unset).
fn icaptcha_insecure_from_var(result: Result<String, std::env::VarError>) -> bool {
match result {
Ok(v) => icaptcha_insecure_from_str(Some(&v)),
Err(std::env::VarError::NotUnicode(_)) => {
warn_icaptcha_insecure_non_truthy();
false
}
Err(std::env::VarError::NotPresent) => false,
}
}
/// Whether `GITLAWB_ICAPTCHA_INSECURE` explicitly enables the loopback-HTTP
/// trust relaxation used by integration tests.
///
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
/// enables it. Presence alone is not enough: `=0`, `=false`, or an empty
/// value leave the relaxation off. A non-empty non-truthy value (including a
/// present non-Unicode value) emits a `tracing::warn!` (visible when the
/// `icaptcha_client` target is at WARN+, e.g. `RUST_LOG=icaptcha_client=warn`).
fn icaptcha_insecure_enabled() -> bool {
icaptcha_insecure_from_var(std::env::var("GITLAWB_ICAPTCHA_INSECURE"))
}
/// Whether `u` parses as a trusted URL, given a pre-parsed insecure flag.
///
/// Production trusts only `https`. When `insecure` is true the function also
/// trusts `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry
/// path can be exercised against a local mockito server.
fn is_https_with(u: &str, insecure: bool) -> bool {
let Ok(parsed) = reqwest::Url::parse(u) else {
return false;
};
if parsed.scheme() == "https" {
return true;
}
if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
if insecure
&& parsed.scheme() == "http"
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
{
@@ -139,7 +197,15 @@ fn origin_key(u: &str) -> Option<String> {
/// explicitly-configured host (never a node-discovered one), so a key is never
/// exfiltrated to an origin the operator did not choose.
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
let operator = operator.filter(|u| is_https(u));
resolve_solver_url_with(advertised, operator, icaptcha_insecure_enabled())
}
fn resolve_solver_url_with(
advertised: Option<&str>,
operator: Option<&str>,
insecure: bool,
) -> (String, bool) {
let operator = operator.filter(|u| is_https_with(u, insecure));
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
let default_origin = origin_key(DEFAULT_URL);
@@ -157,7 +223,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
};
let chosen = match advertised {
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
Some(a) if is_https_with(a, insecure) && allowed(&origin_key(a)) => a.to_string(),
Some(a) => {
tracing::warn!(
advertised = %a,
@@ -383,27 +449,36 @@ mod tests {
use std::ffi::OsString;
use std::sync::{Mutex, MutexGuard};
/// Serializes tests that touch the process-global
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
/// Serializes the real-env reader test so it never races with other
/// process-global env mutations (and recovers from a poisoned lock).
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
/// prior value on drop.
struct InsecureEnv {
struct EnvGuard {
_lock: MutexGuard<'static, ()>,
prev: Option<OsString>,
}
impl InsecureEnv {
fn new() -> Self {
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
impl EnvGuard {
fn lock() -> MutexGuard<'static, ()> {
ICAPTCHA_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn with_value(val: &str) -> Self {
let lock = Self::lock();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
InsecureEnv { _lock: lock, prev }
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", val);
EnvGuard { _lock: lock, prev }
}
fn unset() -> Self {
let lock = Self::lock();
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE");
EnvGuard { _lock: lock, prev }
}
}
impl Drop for InsecureEnv {
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.prev.take() {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
@@ -412,6 +487,170 @@ mod tests {
}
}
/// #227 / #246: cover the production env read in `icaptcha_insecure_enabled`.
/// Pure-helper tests alone leave mutations of this function green.
#[test]
fn icaptcha_insecure_enabled_reads_real_env() {
{
let _g = EnvGuard::unset();
assert!(!icaptcha_insecure_enabled(), "unset must disable");
}
{
let _g = EnvGuard::with_value("0");
assert!(!icaptcha_insecure_enabled(), "=0 must disable");
}
{
let _g = EnvGuard::with_value("false");
assert!(!icaptcha_insecure_enabled(), "=false must disable");
}
{
let _g = EnvGuard::with_value("");
assert!(!icaptcha_insecure_enabled(), "empty must disable");
}
{
let _g = EnvGuard::with_value("1");
assert!(icaptcha_insecure_enabled(), "=1 must enable");
}
{
let _g = EnvGuard::with_value("true");
assert!(icaptcha_insecure_enabled(), "=true must enable");
}
{
let _g = EnvGuard::with_value("TRUE");
assert!(icaptcha_insecure_enabled(), "=TRUE must enable");
}
{
let _g = EnvGuard::with_value(" 1 ");
assert!(icaptcha_insecure_enabled(), "\" 1 \" (trimmed) must enable");
}
}
/// Cover the seam in `resolve_solver_url`, which is the only place the
/// parsed flag reaches the trust decision it governs.
///
/// A loopback operator URL is load-bearing here: it is the only input whose
/// verdict changes with the flag, so it is the only input that can detect
/// the seam being hardcoded. Every other resolver test deliberately passes
/// the flag explicitly (`resolve_solver_url_with(.., false)`) and therefore
/// cannot see this. Without this test, replacing the seam's argument with
/// `true` — restoring the #227 bug, loopback HTTP trusted unconditionally
/// in release — leaves both this crate's suite and `gl`'s green.
#[test]
fn resolve_solver_url_honors_the_real_env_flag() {
let op = "http://127.0.0.1:9000";
{
// Flag on: the loopback operator origin is trusted and keeps the key.
let _g = EnvGuard::with_value("1");
let (url, key_trusted) = resolve_solver_url(None, Some(op));
assert_eq!(url, op, "=1 must honor the loopback operator origin");
assert!(key_trusted, "=1 must keep the key with the operator origin");
}
{
// Flag absent: plaintext loopback is not a valid key destination.
let _g = EnvGuard::unset();
let (url, key_trusted) = resolve_solver_url(None, Some(op));
assert_eq!(url, DEFAULT_URL, "unset must reject the loopback operator");
assert!(!key_trusted, "unset must withhold the key");
}
{
// The #227 footgun: presence is not enough, the value must be truthy.
let _g = EnvGuard::with_value("0");
let (url, key_trusted) = resolve_solver_url(None, Some(op));
assert_eq!(url, DEFAULT_URL, "=0 must reject the loopback operator");
assert!(!key_trusted, "=0 must withhold the key");
}
{
// The advertised argument must reach the resolver, not just the
// operator one. This is the only assertion driving a non-None
// advertised URL through the env-reading wrapper, so without it a
// wrapper that forwards `None` in its place stays green everywhere.
let _g = EnvGuard::with_value("1");
let advertised = "http://127.0.0.1:9000/v2";
let (url, key_trusted) = resolve_solver_url(Some(advertised), Some(op));
assert_eq!(
url, advertised,
"an advertised URL on the operator's own origin must be honored verbatim"
);
assert!(key_trusted, "the operator origin still owns the key");
}
}
/// #227: `=0` / `=false` / empty / unset must NOT enable loopback HTTP;
/// only explicit truthy `1` / `true` may. Uses pure helpers, so it covers
/// the parsing rules without touching process-global environment; the
/// env-reading path itself is covered by the two tests above, which hold
/// `ICAPTCHA_ENV_LOCK` for the duration.
#[test]
fn insecure_env_only_truthy_enables_loopback_http() {
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("0"))),
"=0 must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("0")));
assert!(
!is_https_with(
"http://127.0.0.1",
icaptcha_insecure_from_str(Some("false"))
),
"=false must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("false")));
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(""))),
"empty value must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(Some("")));
assert!(
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(None)),
"unset must leave loopback HTTP disabled"
);
assert!(!icaptcha_insecure_from_str(None));
assert!(!icaptcha_insecure_from_var(Err(
std::env::VarError::NotPresent
)));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("1"))),
"=1 must enable loopback HTTP"
);
assert!(is_https_with(
"http://localhost",
icaptcha_insecure_from_str(Some("1"))
));
assert!(icaptcha_insecure_from_str(Some("1")));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("true"))),
"=true must enable loopback HTTP"
);
assert!(icaptcha_insecure_from_str(Some("true")));
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("TRUE"))),
"=TRUE (case-insensitive) must enable loopback HTTP"
);
// Trim is load-bearing: whitespace around a truthy token must still enable.
assert!(
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(" 1 "))),
"\" 1 \" (trimmed) must enable loopback HTTP"
);
assert!(icaptcha_insecure_from_str(Some(" 1 ")));
// Present non-Unicode: fail closed + same fixed warning path as non-truthy.
assert!(!icaptcha_insecure_from_var(Err(
std::env::VarError::NotUnicode(OsString::from("x"))
)));
// https remains trusted regardless of the insecure flag.
assert!(is_https_with("https://icaptcha.gitlawb.com", false));
assert!(is_https_with("https://icaptcha.gitlawb.com", true));
}
// ── P1: hostile error bodies must not reach the terminal raw ──────────
#[test]
@@ -480,14 +719,15 @@ mod tests {
#[test]
fn rejects_non_https_advertised_url() {
// A plaintext node-advertised URL is ignored; we fall back to default.
let (url, key_trusted) = resolve_solver_url(Some("http://evil.example/x"), None);
let (url, key_trusted) =
resolve_solver_url_with(Some("http://evil.example/x"), None, false);
assert_eq!(url, DEFAULT_URL);
assert!(!key_trusted);
}
#[test]
fn rejects_https_url_with_non_allowlisted_host() {
let (url, key_trusted) = resolve_solver_url(Some("https://evil.example"), None);
let (url, key_trusted) = resolve_solver_url_with(Some("https://evil.example"), None, false);
assert_eq!(url, DEFAULT_URL, "attacker host must not be honored");
assert!(!key_trusted);
}
@@ -496,7 +736,8 @@ mod tests {
fn honors_advertised_default_host_but_withholds_key_without_operator() {
// Default public host is allowlisted for talking, but with no operator
// origin configured the API key has no trusted destination.
let (url, key_trusted) = resolve_solver_url(Some("https://icaptcha.gitlawb.com/v2"), None);
let (url, key_trusted) =
resolve_solver_url_with(Some("https://icaptcha.gitlawb.com/v2"), None, false);
assert_eq!(url, "https://icaptcha.gitlawb.com/v2");
assert!(!key_trusted);
}
@@ -506,13 +747,14 @@ mod tests {
let op = "https://icap.mynode.example";
// Node advertises the operator's own origin → talk there, key allowed.
let (url, key_trusted) =
resolve_solver_url(Some("https://icap.mynode.example/v1"), Some(op));
resolve_solver_url_with(Some("https://icap.mynode.example/v1"), Some(op), false);
assert_eq!(url, "https://icap.mynode.example/v1");
assert!(key_trusted);
// Node advertises an attacker origin while an operator is configured →
// ignore the advert, fall back to the operator origin, key stays with it.
let (url, key_trusted) = resolve_solver_url(Some("https://evil.example"), Some(op));
let (url, key_trusted) =
resolve_solver_url_with(Some("https://evil.example"), Some(op), false);
assert_eq!(url, op);
assert!(key_trusted);
}
@@ -520,9 +762,12 @@ mod tests {
#[test]
fn no_advert_uses_operator_or_default() {
let op = "https://icap.mynode.example";
assert_eq!(resolve_solver_url(None, Some(op)), (op.to_string(), true));
assert_eq!(
resolve_solver_url(None, None),
resolve_solver_url_with(None, Some(op), false),
(op.to_string(), true)
);
assert_eq!(
resolve_solver_url_with(None, None, false),
(DEFAULT_URL.to_string(), false)
);
}
@@ -530,194 +775,24 @@ mod tests {
#[test]
fn non_https_operator_is_not_trusted() {
// A misconfigured plaintext operator URL is not a valid key destination.
let (url, key_trusted) = resolve_solver_url(None, Some("http://icap.mynode.example"));
let (url, key_trusted) =
resolve_solver_url_with(None, Some("http://icap.mynode.example"), false);
assert_eq!(url, DEFAULT_URL);
assert!(!key_trusted);
}
#[test]
fn rejects_insecure_advertised_url_on_different_port() {
// With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different
// With insecure mode enabled, two loopback URLs on different
// ports must be treated as distinct origins so a hostile node cannot
// redirect the bearer key to a different listener on localhost.
let _env = InsecureEnv::new();
let op = "http://localhost:3000";
let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op));
let (url, key_trusted) =
resolve_solver_url_with(Some("http://localhost:9000"), Some(op), true);
assert_eq!(
url, op,
"must fall back to operator origin, not use different port"
);
assert!(key_trusted, "key stays trusted with operator origin");
}
#[test]
fn new_withholds_key_from_untrusted_origin_but_attaches_for_operator() {
let guard = ICAPTCHA_ENV_LOCK.lock().unwrap();
let prev_key = std::env::var_os("GITLAWB_ICAPTCHA_API_KEY");
let prev_op = std::env::var_os("GITLAWB_ICAPTCHA_URL");
std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", "secret-bearer");
// Arm 1: No operator configured, node advertises an attacker origin.
// The key has no trusted destination; the advert is rejected.
std::env::remove_var("GITLAWB_ICAPTCHA_URL");
let untrusted =
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
// Arm 1b: No operator configured, no URL advertised. The fallback goes
// to the default public origin, and with no operator to trust, the key
// must not ride.
let no_operator_no_advert = IcaptchaCfg::new("did:key:zTEST", None, None);
// Arm 2: Operator origin configured and advertised — key attached.
std::env::set_var("GITLAWB_ICAPTCHA_URL", "https://icap.mynode.example");
let operator_match = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icap.mynode.example/v1".into()),
None,
);
// Arm 3: Operator configured, default host advertised. The default host
// is allowlisted and selected, but its origin differs from the
// operator's, so key must not ride.
let default_advert = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icaptcha.gitlawb.com/v2".into()),
None,
);
// Arm 4: Operator configured, no URL advertised (the node sent only
// x-icaptcha-level, not x-icaptcha-url). resolve_solver_url falls back
// to the operator origin and marks the key trusted. This is the common
// API-key deployment path — a regression that additionally required
// url.is_some() for key attachment would leave all current tests green
// but make these fallback challenge/answer requests omit Authorization
// and fail against the protected operator service.
let operator_fallback = IcaptchaCfg::new("did:key:zTEST", None, Some(3));
// Arm 5: Operator configured, node advertises an attacker origin.
// The advert is rejected and the solver falls back to the operator's
// own origin, which keeps the key trusted — this is the exfiltration
// scenario the test name describes (withhold key from *untrusted*
// origin, not from the operator's fallback).
let operator_hostile_advert =
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
// Arm 6: Operator configured, same host but different port.
// origin_key includes the port, so :8443 must not match the
// operator origin at the default :443 — the advert is rejected
// and the solver falls back to the operator origin.
let operator_diff_port = IcaptchaCfg::new(
"did:key:zTEST",
Some("https://icap.mynode.example:8443/evil".into()),
None,
);
// Restore env and release the lock BEFORE asserting, so a failing
// assertion can't poison the shared lock or cascade into a sibling.
match prev_key {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_API_KEY"),
}
match prev_op {
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
}
drop(guard);
let mut failures: Vec<String> = Vec::new();
if untrusted.api_key.is_some() {
failures.push(format!(
"arm 1 (no operator, attacker advert): expected api_key=None, got {:?}",
untrusted.api_key
));
}
if untrusted.url != DEFAULT_URL {
failures.push(format!(
"arm 1 (no operator, attacker advert): expected url={DEFAULT_URL}, got {}",
untrusted.url
));
}
if no_operator_no_advert.api_key.is_some() {
failures.push(format!(
"arm 1b (no operator, no advert): expected api_key=None, got {:?}",
no_operator_no_advert.api_key
));
}
if no_operator_no_advert.url != DEFAULT_URL {
failures.push(format!(
"arm 1b (no operator, no advert): expected url={DEFAULT_URL}, got {}",
no_operator_no_advert.url
));
}
if operator_match.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 2 (operator match): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_match.api_key
));
}
if operator_match.url != "https://icap.mynode.example/v1" {
failures.push(format!(
"arm 2 (operator match): expected url=https://icap.mynode.example/v1, got {}",
operator_match.url
));
}
if default_advert.api_key.is_some() {
failures.push(format!(
"arm 3 (operator + default advert): expected api_key=None, got {:?}",
default_advert.api_key
));
}
if default_advert.url != "https://icaptcha.gitlawb.com/v2" {
failures.push(format!(
"arm 3 (operator + default advert): expected url=https://icaptcha.gitlawb.com/v2, got {}",
default_advert.url
));
}
if operator_fallback.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 4 (operator fallback, no advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_fallback.api_key
));
}
if operator_fallback.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 4 (operator fallback, no advert): expected url=https://icap.mynode.example, got {}",
operator_fallback.url
));
}
if operator_hostile_advert.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 5 (operator + hostile advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_hostile_advert.api_key
));
}
if operator_hostile_advert.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 5 (operator + hostile advert): expected url=https://icap.mynode.example, got {}",
operator_hostile_advert.url
));
}
if operator_diff_port.api_key.as_deref() != Some("secret-bearer") {
failures.push(format!(
"arm 6 (operator + different port advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
operator_diff_port.api_key
));
}
if operator_diff_port.url != "https://icap.mynode.example" {
failures.push(format!(
"arm 6 (operator + different port advert): expected url=https://icap.mynode.example, got {}",
operator_diff_port.url
));
}
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ build_darwin_arm64() {
echo "==> Building darwin-arm64 (native cargo)..."
cd "$ROOT"
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --locked -p gl -p git-remote-gitlawb
cargo build --release -p gl -p git-remote-gitlawb
cp target/release/gl "$OUT/gl-darwin-arm64"
cp target/release/git-remote-gitlawb "$OUT/git-remote-gitlawb-darwin-arm64"
chmod +x "$OUT/gl-darwin-arm64" "$OUT/git-remote-gitlawb-darwin-arm64"
+5 -9
View File
@@ -23,15 +23,11 @@ if [ ! -f "$ALLOW" ]; then
exit 1
fi
# `cargo tree` resolves WITHOUT --locked on purpose, unlike the blocking cargo
# jobs in pr-checks. This gate answers one question, whether gitlawb-core's
# dependency closure gained something unallowlisted, and a stale Cargo.lock
# would red it for a reason that has nothing to do with that closure. Lock
# staleness is already caught by the --locked cargo jobs, so it does not need a
# second gate here.
#
# Resolving can refresh Cargo.lock as a side effect, so snapshot and restore
# it: this check must never leave the working tree dirty when run locally.
# `cargo tree` resolves like the build/test jobs (no --locked): the committed
# Cargo.lock can lag the manifests, and --locked would red this gate for
# lock-staleness reasons unrelated to gitlawb-core's dependencies. Resolving can
# refresh Cargo.lock as a side effect, so snapshot and restore it — this check
# must never leave the working tree dirty when run locally.
lock_backup="$(mktemp)"
cp "$ROOT/Cargo.lock" "$lock_backup"
restore_lock() { cp "$lock_backup" "$ROOT/Cargo.lock"; rm -f "$lock_backup"; }