mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f4b2fbac | ||
|
|
d7551807e6 | ||
|
|
4329b2aa87 | ||
|
|
ce25bba091 | ||
|
|
1e79ae59a5 | ||
|
|
296704f7fa | ||
|
|
9152178109 | ||
|
|
03cd81ee2b | ||
|
|
1d0d3d2dca | ||
|
|
1d8c28113a | ||
|
|
556aa31e2c | ||
|
|
b484a24955 | ||
|
|
b75aa2f1da | ||
|
|
c681af70db | ||
|
|
3fa38dce4e | ||
|
|
c71bba5cd1 | ||
|
|
e3df51d76c | ||
|
|
0765c42fef | ||
|
|
94c61f7942 | ||
|
|
b0a2ed8308 | ||
|
|
cb42a4830d | ||
|
|
2d88937fba | ||
|
|
e14c2d3f3f | ||
|
|
4465beb0c2 | ||
|
|
82efd60229 | ||
|
|
e8f193a5df | ||
|
|
07d76ba556 | ||
|
|
05e434d906 | ||
|
|
7593540f62 | ||
|
|
ccc2cf46f2 | ||
|
|
1fddc81b7c | ||
|
|
62d73c4165 | ||
|
|
2f622ae5fa | ||
|
|
f9506b466e | ||
|
|
d5ec4695f4 | ||
|
|
98d728c325 | ||
|
|
b6877a6598 | ||
|
|
b07d41ea97 | ||
|
|
c7427e943d | ||
|
|
69a62e51c2 | ||
|
|
ee8d7c03b2 | ||
|
|
a12f21adb4 | ||
|
|
4408d27058 | ||
|
|
03ef9b51bd | ||
|
|
c3d6e73e50 | ||
|
|
fa4322cb2e | ||
|
|
b2ca5a7b2d | ||
|
|
2ad1c793ab | ||
|
|
c07435d245 | ||
|
|
859240b3a3 | ||
|
|
ba572b3b68 | ||
|
|
16517f85ce | ||
|
|
2fa9fa37b3 | ||
|
|
5a46ef62cc | ||
|
|
d2ba14edfe | ||
|
|
7dc7be2ecd | ||
|
|
ce5dcc3581 | ||
|
|
d1e596e580 | ||
|
|
8f3d643a72 | ||
|
|
9916bca29b | ||
|
|
3baa9ed4fc | ||
|
|
c24b5418a8 | ||
|
|
bf2a54e121 | ||
|
|
86a8228f47 | ||
|
|
0c279c9d25 | ||
|
|
49346464bf | ||
|
|
c4b337b194 | ||
|
|
2a78524826 | ||
|
|
9f7757cbf5 | ||
|
|
6ba698c99b | ||
|
|
e4c4161dbc | ||
|
|
b94d440e9e | ||
|
|
dde5ea9a3e | ||
|
|
cf669bb8aa | ||
|
|
3c1ceb8edc | ||
|
|
68c1f5c2e6 | ||
|
|
4df79f22a3 | ||
|
|
cdb102efbd | ||
|
|
bfbe5026ba | ||
|
|
63254029e5 | ||
|
|
d9eebd29c9 | ||
|
|
1fcca0d433 | ||
|
|
15522d5616 | ||
|
|
fde9cfe287 | ||
|
|
a0fcbb0813 | ||
|
|
01354199d0 | ||
|
|
dfd4f6c3d6 | ||
|
|
090019deb7 | ||
|
|
285d7c7f7a | ||
|
|
e6324d20df | ||
|
|
6fded6ef65 | ||
|
|
74e05be7f7 | ||
|
|
34df1fddc8 | ||
|
|
ad7c2b2b83 | ||
|
|
c98b503f90 |
No files matched your search
@@ -13,9 +13,16 @@ GITLAWB_PUBLIC_URL=https://your-node.example.com
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
GITLAWB_HOST=0.0.0.0
|
||||
GITLAWB_PORT=7545
|
||||
# Optional address to bind a Prometheus /metrics exposition endpoint on (e.g.
|
||||
# 127.0.0.1:9091). Leave empty (default) to disable. Bind to localhost or a
|
||||
# private interface — the metrics endpoint is unauthenticated.
|
||||
GITLAWB_METRICS_ADDR=
|
||||
|
||||
# ── Storage ───────────────────────────────────────────────────────────────
|
||||
GITLAWB_REPOS_DIR=/data/repos
|
||||
# Tigris (S3-compatible) bucket for repo storage. Leave empty (default) to
|
||||
# disable Tigris and use local-only storage.
|
||||
GITLAWB_TIGRIS_BUCKET=
|
||||
|
||||
# PostgreSQL connection URL. Required.
|
||||
# When using the bundled docker-compose, this is wired automatically.
|
||||
@@ -26,6 +33,12 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
# connections open lazily. Size against the DB server's max_connections,
|
||||
# remembering admin tooling opens its own pool.
|
||||
GITLAWB_DB_MAX_CONNECTIONS=20
|
||||
# Maximum connections in the dedicated advisory-lock pool (separate from the
|
||||
# pool above). Each in-flight repo write pins one connection here for its whole
|
||||
# lifetime, so size it to peak concurrent distinct-repo writers — keeping it
|
||||
# separate is what stops a push burst from starving request handlers. Keep
|
||||
# (main pool + lock pool) within the DB server's max_connections.
|
||||
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=32
|
||||
# Seconds a request waits for a pool connection before failing with 503.
|
||||
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
|
||||
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
|
||||
@@ -39,6 +52,9 @@ GITLAWB_DB_RETRY_INITIAL_SECS=5
|
||||
GITLAWB_DB_RETRY_MAX_SECS=60
|
||||
|
||||
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
|
||||
# URL of a local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001). Leave
|
||||
# empty (default) to disable local IPFS.
|
||||
GITLAWB_IPFS_API=
|
||||
# Get a JWT at https://app.pinata.cloud/developers/api-keys
|
||||
GITLAWB_PINATA_JWT=
|
||||
GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files
|
||||
@@ -127,6 +143,20 @@ GITLAWB_PUSH_RATE_LIMIT=600
|
||||
# the client IP. 0 disables. Default 120.
|
||||
GITLAWB_CREATE_RATE_LIMIT=120
|
||||
|
||||
# ── Write rate limiting (non-creation authenticated writes) ───────────────
|
||||
# Max non-creation write requests per client IP per hour: issue/PR comments,
|
||||
# labels, stars, merges, protect/unprotect, replicas, visibility, tasks,
|
||||
# bounties, profile, and all GraphQL HTTP requests. The brake wraps the whole
|
||||
# /graphql route, so queries and the playground GET consume this bucket too, not
|
||||
# only mutations (GraphQL WebSocket subscriptions are excluded). Its own bucket,
|
||||
# separate from the creation and push brakes. Uses GITLAWB_TRUSTED_PROXY to
|
||||
# resolve the client IP.
|
||||
# NOTE: this is a per-IP aggregate across ALL those write actions, so behind a
|
||||
# shared NAT/egress IP (or with GITLAWB_TRUSTED_PROXY unset) many users collapse
|
||||
# onto one bucket — raise this for automation-heavy or multi-user single-IP
|
||||
# deployments. 0 disables. Default 600.
|
||||
GITLAWB_WRITE_RATE_LIMIT=600
|
||||
|
||||
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
|
||||
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
|
||||
# known peers and run at higher frequency, so a generous bucket. Separate from
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
|
||||
# issue when a push-to-main run of PR Checks ends red, and closes it when a
|
||||
# subsequent run on main's tip goes green.
|
||||
#
|
||||
# Why: two individually-green PRs can break main in combination when the
|
||||
# second merges on checks that ran against a stale base (semantic merge
|
||||
# race — #113 + #145 broke test compilation, found only via #229). A red main
|
||||
# is otherwise silent, and release-please PRs inherit the breakage.
|
||||
#
|
||||
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
|
||||
# commit is no longer main's tip are ignored (a re-run of an old red run
|
||||
# after a fix has landed must not cry wolf), and a green run on the tip
|
||||
# closes any open alert.
|
||||
name: Main red alert
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Checks"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read # getBranch tip check
|
||||
|
||||
# Serialize runs: concurrent completions would race the label/issue lookups
|
||||
# into duplicates (or 422 on createLabel). queue: max keeps every pending
|
||||
# completion instead of letting a later (possibly green-skipped) arrival
|
||||
# displace a pending red alert under burst merges; it is only incompatible
|
||||
# with cancel-in-progress: true, which we don't use.
|
||||
concurrency:
|
||||
group: main-red-alert
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
track:
|
||||
name: Track main CI state
|
||||
# timed_out (the test job runs 45 min against Postgres) and
|
||||
# startup_failure (malformed workflow) are red too — only cancelled is
|
||||
# excluded as a deliberate human abort. success runs are needed to close
|
||||
# the alert once main recovers.
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Reconcile the broken-main issue with main's current state
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const label = 'ci-main-red';
|
||||
|
||||
// Freshness gate: only the run for main's current tip may open or
|
||||
// close anything. Re-runs of superseded commits are events about
|
||||
// the past, not statements about main's state.
|
||||
const { data: branch } = await github.rest.repos.getBranch({
|
||||
...context.repo, branch: 'main',
|
||||
});
|
||||
if (branch.commit.sha !== run.head_sha) {
|
||||
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
|
||||
return;
|
||||
}
|
||||
|
||||
// listForRepo returns PRs carrying the label too — those must
|
||||
// never be mistaken for the tracking issue.
|
||||
const open = (await github.rest.issues.listForRepo({
|
||||
...context.repo, state: 'open', labels: label, per_page: 100,
|
||||
})).data.filter(item => !item.pull_request);
|
||||
|
||||
if (run.conclusion === 'success') {
|
||||
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
|
||||
for (const issue of open) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo, issue_number: issue.number, body,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
|
||||
const body = [
|
||||
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
|
||||
'',
|
||||
`> ${headline}`,
|
||||
'',
|
||||
'Likely a semantic merge race: each merged PR was green on a stale base.',
|
||||
'Fix main before merging anything else — release-please PRs inherit this breakage.',
|
||||
'',
|
||||
"This issue closes automatically when a run on main's tip goes green.",
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
await github.rest.issues.getLabel({ ...context.repo, name: label });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
await github.rest.issues.createLabel({
|
||||
...context.repo,
|
||||
name: label,
|
||||
color: 'b60205',
|
||||
description: 'CI is failing on main',
|
||||
});
|
||||
}
|
||||
|
||||
if (open.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo, issue_number: open[0].number, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.create({
|
||||
...context.repo,
|
||||
title: 'CI is red on main',
|
||||
body,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,10 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Run in the merge queue so a PR is re-validated against the true merged
|
||||
# result before it lands. Guards against semantic merge races where two
|
||||
# individually-green PRs break main in combination (#113 + #145 → #229).
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -212,3 +216,34 @@ jobs:
|
||||
|
||||
- name: Smoke test --version
|
||||
run: docker run --rm gitlawb-node:ci-test --version
|
||||
|
||||
test-windows:
|
||||
name: test (windows, non-blocking)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
|
||||
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
|
||||
# test coverage, so run their suites here to make Windows-only regressions
|
||||
# VISIBLE on the PR without gating merge (#228). These two are the client
|
||||
# CLIs and depend on no database, so no Postgres service is needed (service
|
||||
# containers do not run on windows-latest regardless). `git` is preinstalled
|
||||
# on windows-latest, which the real_git_fetch harness relies on.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: windows
|
||||
|
||||
- name: cargo test (shipped Windows crates)
|
||||
run: cargo test -p gl -p git-remote-gitlawb
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Label and guide
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
+223
-52
@@ -4,6 +4,20 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Manual backfill: republish an already-cut release to a channel that
|
||||
# missed it. Fill in only the channel(s) you want — an empty input leaves
|
||||
# that channel's jobs skipped. npm reuses the existing release's binary
|
||||
# assets; docker rebuilds the image from the tag and re-tags the manifest.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_backfill_tag:
|
||||
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
|
||||
required: false
|
||||
type: string
|
||||
docker_backfill_tag:
|
||||
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -14,7 +28,10 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.repository == 'Gitlawb/node' }}
|
||||
# Skipped on manual dispatch: a backfill must never create or advance a
|
||||
# release PR as a side effect (npm-publish tolerates the skipped need via
|
||||
# !cancelled(), and every other job gates on release_created == 'true').
|
||||
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,24 +48,61 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
|
||||
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
|
||||
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
|
||||
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
|
||||
# workflow concurrency group, blocked every queued release run behind it.
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
name: Build & Push Docker Image (${{ matrix.arch }})
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Runs on a fresh release, or on manual dispatch for an existing tag
|
||||
# (release-please is skipped on dispatch, hence !cancelled()).
|
||||
if: >-
|
||||
${{ !cancelled() && (
|
||||
needs.release-please.outputs.release_created == 'true' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
|
||||
) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
- arch: arm64
|
||||
os: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing — a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
@@ -59,45 +113,130 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and load locally (smoke)
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
load: true
|
||||
tags: gitlawb-node:smoke
|
||||
cache-from: type=gha
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
|
||||
# Native runner on both arches, so the built image can always execute.
|
||||
# Assert the released version so a stale artifact fails here, not in prod.
|
||||
- name: Smoke test
|
||||
run: docker run --rm gitlawb-node:smoke --version
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$(docker run --rm gitlawb-node:smoke --version)"
|
||||
echo "$out"
|
||||
grep -qF "$VERSION" <<<"$out" || {
|
||||
echo "::error::image --version did not report expected version $VERSION (got: $out)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
# Push by digest only — tags are applied once by docker-manifest so a
|
||||
# half-finished matrix can never publish a partially-tagged image.
|
||||
# provenance:false keeps each push a plain single-arch manifest, which is
|
||||
# what imagetools create expects to merge.
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: false
|
||||
outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
env:
|
||||
DIGEST: ${{ steps.push.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/digests
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: docker-digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker-manifest:
|
||||
name: Publish Docker manifest
|
||||
needs: [release-please, docker]
|
||||
if: ${{ !cancelled() && needs.docker.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: docker-digest-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ghcr requires a lowercase repository path.
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
digests=""
|
||||
for f in /tmp/digests/*; do
|
||||
digests="$digests $IMAGE@sha256:$(basename "$f")"
|
||||
done
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:$MAJOR_MINOR" \
|
||||
-t "$IMAGE:latest" \
|
||||
$digests
|
||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
|
||||
echo "## Released $TAG"
|
||||
echo
|
||||
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
|
||||
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
|
||||
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
|
||||
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
release-binaries:
|
||||
@@ -248,37 +387,62 @@ jobs:
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
needs: [release-please, release-binaries]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
# Runs on a fresh release, or on manual dispatch for an existing tag
|
||||
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
|
||||
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
|
||||
# @gitlawb package must have this repo + workflow (release.yml) configured
|
||||
# as its trusted publisher on npmjs.com, or publish fails loudly here —
|
||||
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
|
||||
# never reached npm.
|
||||
if: >-
|
||||
${{ !cancelled() && (
|
||||
(needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '')
|
||||
) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
id-token: write # npm trusted publishing (OIDC) + provenance
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
if [ -z "${NPM_TOKEN:-}" ]; then
|
||||
echo "::warning::NPM_TOKEN is not set — skipping npm publish."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
|
||||
# package has a trusted publisher configured; older npm silently falls
|
||||
# back to (absent) token auth and fails confusingly.
|
||||
- name: Set up Node 24 + OIDC-capable npm
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g npm@^11.5.1
|
||||
npm --version
|
||||
|
||||
- name: Lay in release binaries
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm platform package -> Rust target triple (unix only; Windows is not
|
||||
@@ -305,9 +469,8 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Set versions
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e '
|
||||
@@ -327,13 +490,12 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Publish
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
# No token: npm exchanges this job's GitHub OIDC identity with the
|
||||
# registry (trusted publishing); provenance is attested automatically.
|
||||
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
|
||||
# Skip versions already on the registry so a rerun after a partial publish
|
||||
# is idempotent instead of erroring on the first existing package.
|
||||
@@ -429,6 +591,15 @@ jobs:
|
||||
bin.install "git-remote-gitlawb"
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~CAVEATS
|
||||
oh-my-zsh's git plugin aliases gl='git pull', which shadows this
|
||||
binary in interactive shells. If \`gl\` prints "fatal: not a git
|
||||
repository", run:
|
||||
echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc
|
||||
CAVEATS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/gl --version")
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.5.1"
|
||||
".": "0.6.0"
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
|
||||
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
|
||||
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
|
||||
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
|
||||
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
|
||||
|
||||
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
|
||||
|
||||
|
||||
|
||||
Generated
+9
-5
@@ -3300,7 +3300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
@@ -3312,7 +3312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3329,7 +3329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3356,13 +3356,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
"async-graphql",
|
||||
"async-graphql-axum",
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
@@ -3412,10 +3413,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
@@ -3821,9 +3823,11 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.5.1" # x-release-please-version
|
||||
version = "0.6.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -570,6 +570,13 @@ fn safe_error_body_excerpt(body: &str) -> String {
|
||||
continue;
|
||||
}
|
||||
|
||||
if gitlawb_core::sanitize::is_bidi_format(ch) {
|
||||
// Drop Cf bidi/format controls (INV-6, #183) without marking
|
||||
// pending_space: unlike a newline/tab they are not whitespace, so
|
||||
// "a\u{202e}c" collapses to "ac" with no phantom gap.
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch.is_whitespace() {
|
||||
pending_space = true;
|
||||
continue;
|
||||
@@ -1159,6 +1166,55 @@ mod tests {
|
||||
assert!(!message.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_message_strips_bidi_format_controls() {
|
||||
// Each Cf bidi class must be stripped (INV-6, #183), not just U+202E:
|
||||
// override (RLO), embedding + pop (LRE/PDF), isolate (LRI/PDI), and marks
|
||||
// (LRM/ALM). Asserted per code point so a partial predicate fails loudly.
|
||||
// The visible words must survive and JOIN with no phantom space at the
|
||||
// strip points: a bidi char is dropped like a control byte, but unlike a
|
||||
// newline/tab it is not whitespace, so it must not set pending_space. The
|
||||
// leading bidi char (index 0) also exercises the empty-excerpt path.
|
||||
let body = "\u{202e}err\u{202a}\u{202c}\u{2066}\u{2069}\u{200e}\u{061c}ok";
|
||||
let message = http_error_message(
|
||||
"GET",
|
||||
"/info/refs",
|
||||
reqwest::StatusCode::BAD_GATEWAY,
|
||||
body,
|
||||
None,
|
||||
);
|
||||
for c in [
|
||||
'\u{202e}', '\u{202a}', '\u{202c}', '\u{2066}', '\u{2069}', '\u{200e}', '\u{061c}',
|
||||
] {
|
||||
assert!(
|
||||
!message.contains(c),
|
||||
"bidi U+{:04X} leaked: {message:?}",
|
||||
c as u32
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
message.contains("errok"),
|
||||
"words should join with no phantom space: {message:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_message_preserves_legitimate_and_rtl_text() {
|
||||
// Must not over-strip: plain text, a genuine RTL SCRIPT letter (Arabic
|
||||
// U+0627, category Lo), and ZWJ (U+200D, a legitimate Cf char) survive.
|
||||
let message = http_error_message(
|
||||
"GET",
|
||||
"/info/refs",
|
||||
reqwest::StatusCode::BAD_GATEWAY,
|
||||
"ok \u{0627}\u{200D}b",
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
message.ends_with("ok \u{0627}\u{200D}b"),
|
||||
"legitimate text was altered: {message:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_error_message_truncates_long_response_body() {
|
||||
let body = "x".repeat(MAX_ERROR_BODY_CHARS + 100);
|
||||
@@ -1194,6 +1250,33 @@ mod tests {
|
||||
assert_eq!(requests.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_error_strips_bidi_from_response_body() {
|
||||
// Transport-path proof: a bidi override in the node's real HTTP error body
|
||||
// must not reach the surfaced error text. Drives the full handle_connect
|
||||
// error path, not just the safe_error_body_excerpt unit boundary (INV-6).
|
||||
let (base_url, server) = serve_http(vec![TestResponse {
|
||||
request_line: "GET /z6Mk/myrepo/info/refs?service=git-upload-pack HTTP/1.1",
|
||||
status: "404 Not Found",
|
||||
body: "not\u{202e}found",
|
||||
}]);
|
||||
|
||||
let repo_base = format!("{base_url}/z6Mk/myrepo");
|
||||
let mut stdin = std::io::Cursor::new(Vec::<u8>::new());
|
||||
let err = handle_connect(&repo_base, "git-upload-pack", None, &mut stdin).unwrap_err();
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(
|
||||
!message.contains('\u{202e}'),
|
||||
"bidi override reached the terminal: {message:?}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("notfound"),
|
||||
"body should survive with the bidi char stripped: {message:?}"
|
||||
);
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_post_error_includes_response_body() {
|
||||
let (base_url, server) = serve_http(vec![
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.5.1" # x-release-please-version
|
||||
version = "0.6.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version = "0.5.1" # x-release-please-version
|
||||
version = "0.6.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
pub mod sanitize;
|
||||
pub mod ucan;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Text sanitization predicates shared across the workspace.
|
||||
//!
|
||||
//! Untrusted peer/remote bytes that reach an operator's terminal (node error
|
||||
//! bodies, node-advertised strings) must be stripped of BOTH `Cc` control bytes
|
||||
//! (`char::is_control()`) AND `Cf` bidi/format controls before display: a
|
||||
//! right-to-left override or directional isolate can visually reorder the line
|
||||
//! and spoof the text (Trojan Source, CWE-451). This is INV-6.
|
||||
//!
|
||||
//! `is_bidi_format` is the single workspace definition of the second half of
|
||||
//! that obligation. It was split out of `gl`'s local copy so the two terminal
|
||||
//! sanitizers that shipped with only the `Cc` half (#183) — and any future
|
||||
//! sanitizer — reuse one audited predicate instead of re-deriving the set.
|
||||
|
||||
/// True for the Unicode bidirectional/directional-isolate format characters
|
||||
/// (the reordering subset of category `Cf`) that can visually reorder a terminal
|
||||
/// line. These are NOT matched by [`char::is_control()`], which covers `Cc` only.
|
||||
///
|
||||
/// This is deliberately the reordering subset, not the whole `Cf` category:
|
||||
/// legitimate `Cf` characters such as ZWJ (`U+200D`, emoji sequences) and the
|
||||
/// soft hyphen (`U+00AD`) are NOT matched, so honest international text passes
|
||||
/// through unchanged. Widening this set is a behavior change for every consumer.
|
||||
///
|
||||
/// Pair it with `!c.is_control()` in a terminal-bound sanitizer (INV-6); see the
|
||||
/// wrappers in `gl`, `git-remote-gitlawb`, and `icaptcha-client`.
|
||||
pub fn is_bidi_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
|
||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
|
||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_every_reordering_code_point() {
|
||||
// Asserted individually so a partial predicate (e.g. one that covers the
|
||||
// marks but not the isolates) fails loudly rather than passing.
|
||||
for c in [
|
||||
'\u{200E}', '\u{200F}', '\u{061C}', // LRM, RLM, ALM
|
||||
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
|
||||
'\u{202E}', // LRE, RLE, PDF, LRO, RLO
|
||||
'\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // LRI, RLI, FSI, PDI
|
||||
] {
|
||||
assert!(
|
||||
is_bidi_format(c),
|
||||
"U+{:04X} must be a bidi-format char",
|
||||
c as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_legitimate_and_control_chars() {
|
||||
// Must NOT over-strip: plain text, a genuine RTL SCRIPT letter (Lo, not a
|
||||
// format char), ZWJ (legitimate Cf), and soft hyphen (legitimate Cf) are
|
||||
// not bidi-format. ESC is a Cc control char — owned by is_control(), not
|
||||
// this predicate, which is bidi-only.
|
||||
for c in ['a', '\u{0627}', '\u{200D}', '\u{00AD}', '\u{1b}'] {
|
||||
assert!(
|
||||
!is_bidi_format(c),
|
||||
"U+{:04X} must NOT be a bidi-format char",
|
||||
c as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,11 +187,17 @@ impl Ucan {
|
||||
}
|
||||
|
||||
/// Check if this UCAN grants a specific capability on a resource.
|
||||
///
|
||||
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
|
||||
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
|
||||
/// requested resource/action, since a valid delegation chain can produce
|
||||
/// exactly that capability (see `is_attenuated_by`).
|
||||
pub fn can(&self, resource: &str, action: &str) -> bool {
|
||||
self.payload
|
||||
.att
|
||||
.iter()
|
||||
.any(|cap| cap.with == resource && cap.can == action)
|
||||
self.payload.att.iter().any(|cap| {
|
||||
let resource_ok = cap.with == resource || cap.with == "*";
|
||||
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
|
||||
resource_ok && action_ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode to a compact JSON string (the wire format).
|
||||
@@ -312,6 +318,63 @@ mod tests {
|
||||
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_honors_resource_wildcard() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
|
||||
let ucan = Ucan::issue(
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
|
||||
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
|
||||
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_honors_repo_admin_action_wildcard() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
|
||||
let ucan = Ucan::issue(
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new(
|
||||
"gitlawb://repos/test/repo",
|
||||
caps::REPO_ADMIN,
|
||||
)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_honors_action_wildcard() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
|
||||
let ucan = Ucan::issue(
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new("gitlawb://repos/test/repo", "*")],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_ucan() {
|
||||
let issuer = Keypair::generate();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.5.1" # x-release-please-version
|
||||
version = "0.6.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
async-trait = "0.1"
|
||||
ed25519-dalek = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -138,9 +138,33 @@ pub async fn list_ref_updates(
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let updates = collect_visible_ref_updates(&state.db, None, limit, caller).await?;
|
||||
|
||||
// Resolve the trusted display owner_did per row. The stored wire value is
|
||||
// untrusted (arrives over gossip / unsigned peer notify), so it is echoed
|
||||
// only when it matches the canonical owner of the local repo the slug
|
||||
// names (#P1); legacy None rows are attributed via an exact unique local
|
||||
// match (#P3). Both surfaces share this resolver so they cannot drift.
|
||||
// The batch resolver issues at most one query per distinct local repo
|
||||
// rather than one per event row (#P2).
|
||||
let raw_dids: Vec<Option<String>> = state
|
||||
.db
|
||||
.resolve_ref_update_owner_dids(
|
||||
&updates
|
||||
.iter()
|
||||
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let owner_dids: Vec<serde_json::Value> = raw_dids
|
||||
.into_iter()
|
||||
.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s)))
|
||||
.collect();
|
||||
|
||||
let events: Vec<serde_json::Value> = updates
|
||||
.iter()
|
||||
.map(|u| {
|
||||
.enumerate()
|
||||
.map(|(i, u)| {
|
||||
let owner_did = owner_dids[i].clone();
|
||||
serde_json::json!({
|
||||
"id": u.id,
|
||||
"node_did": u.node_did,
|
||||
@@ -153,6 +177,7 @@ pub async fn list_ref_updates(
|
||||
"cert_id": u.cert_id,
|
||||
"received_at": u.received_at,
|
||||
"from_peer": u.from_peer,
|
||||
"owner_did": owner_did,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -225,6 +250,7 @@ pub async fn list_repo_events(
|
||||
"pusher_did": c.pusher_did,
|
||||
"node_did": c.node_did,
|
||||
"timestamp": c.issued_at,
|
||||
"owner_did": record.owner_did,
|
||||
"source": "local",
|
||||
})
|
||||
})
|
||||
@@ -240,28 +266,46 @@ pub async fn list_repo_events(
|
||||
// collect_visible_ref_updates drops any row whose slug matches a local repo the
|
||||
// caller cannot read (fail-closed), and propagates DB errors instead of swallowing
|
||||
// them.
|
||||
let gossip_events: Vec<serde_json::Value> =
|
||||
collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|u| {
|
||||
serde_json::json!({
|
||||
"type": "gossipsub",
|
||||
"id": u.id,
|
||||
"repo": u.repo,
|
||||
"ref_name": u.ref_name,
|
||||
"old_sha": u.old_sha,
|
||||
"new_sha": u.new_sha,
|
||||
"pusher_did": u.pusher_did,
|
||||
"node_did": u.node_did,
|
||||
"timestamp": u.timestamp,
|
||||
"cert_id": u.cert_id,
|
||||
"received_at": u.received_at,
|
||||
"from_peer": u.from_peer,
|
||||
"source": "gossipsub",
|
||||
})
|
||||
let gossip_updates =
|
||||
collect_visible_ref_updates(&state.db, Some(&repo_id_str), limit, caller).await?;
|
||||
let gossip_raw: Vec<Option<String>> = state
|
||||
.db
|
||||
.resolve_ref_update_owner_dids(
|
||||
&gossip_updates
|
||||
.iter()
|
||||
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let gossip_owner_dids: Vec<serde_json::Value> = gossip_raw
|
||||
.into_iter()
|
||||
.map(|o| o.map_or(serde_json::Value::Null, |s| serde_json::json!(s)))
|
||||
.collect();
|
||||
|
||||
let gossip_events: Vec<serde_json::Value> = gossip_updates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, u)| {
|
||||
let owner_did = gossip_owner_dids[i].clone();
|
||||
serde_json::json!({
|
||||
"type": "gossipsub",
|
||||
"id": u.id,
|
||||
"repo": u.repo,
|
||||
"ref_name": u.ref_name,
|
||||
"old_sha": u.old_sha,
|
||||
"new_sha": u.new_sha,
|
||||
"pusher_did": u.pusher_did,
|
||||
"node_did": u.node_did,
|
||||
"timestamp": u.timestamp,
|
||||
"cert_id": u.cert_id,
|
||||
"received_at": u.received_at,
|
||||
"from_peer": u.from_peer,
|
||||
"owner_did": owner_did,
|
||||
"source": "gossipsub",
|
||||
})
|
||||
.collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Merge both lists
|
||||
let mut all_events: Vec<serde_json::Value> = cert_events;
|
||||
@@ -326,6 +370,7 @@ mod ref_updates_feed_tests {
|
||||
cert_id: None,
|
||||
received_at: Utc::now().to_rfc3339(),
|
||||
from_peer: "peer1".into(),
|
||||
owner_did: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,7 +1150,14 @@ mod ref_updates_feed_tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(count(&body_json(resp).await), 2);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(count(&body), 2);
|
||||
for event in body["events"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
event["owner_did"], OWNER,
|
||||
"each event must carry the local owner_did"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Anon reads a quarantined mirror → 404 (withheld without disclosing existence
|
||||
|
||||
@@ -70,11 +70,37 @@ pub async fn create_issue(
|
||||
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(create_result.is_ok()).await;
|
||||
// Always release the advisory lock, even on error; upload to Tigris only on
|
||||
// success. A false return means the durable upload failed/timed out: the
|
||||
// issue ref applied to local disk but never reached object storage, so the
|
||||
// next acquire_write would revert it from the stale archive. Report 5xx so
|
||||
// the client retries rather than trusting an issue that never landed durably,
|
||||
// and skip the trust bump below. (Same P1 data-loss guard as receive-pack.)
|
||||
// On that failure, roll back the local ref BEFORE the lock releases — an
|
||||
// orphan ref would make the retry a duplicate, and in an unlock-to-delete
|
||||
// window a concurrent same-repo write could upload an archive still
|
||||
// carrying it. Best-effort: a failed delete logs and the 5xx still returns.
|
||||
let upload_ok = guard
|
||||
.release_with_failure_cleanup(create_result.is_ok(), |path| {
|
||||
if let Err(e) = git_issues::delete_issue_ref(path, &issue_id) {
|
||||
tracing::warn!(issue = %issue_id, err = %e,
|
||||
"failed to roll back local issue ref after failed durable upload; \
|
||||
a retry may duplicate this issue");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
create_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
if !upload_ok {
|
||||
tracing::error!(repo = %record.name,
|
||||
"durable upload failed after issue create; reporting failure so the client retries");
|
||||
return Err(AppError::Git(format!(
|
||||
"durable storage upload failed for {}",
|
||||
record.name
|
||||
)));
|
||||
}
|
||||
|
||||
// Bump trust score for the issue author — increment current score by 0.05
|
||||
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only file issues)
|
||||
if let Some(ref author_did) = issue.author {
|
||||
@@ -244,11 +270,14 @@ pub async fn close_issue(
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
Ok(None) => {
|
||||
guard.release(false).await;
|
||||
// release(false): no upload attempted, so the bool is always true
|
||||
// (nothing to fail on). Ignore it on this pre-write error path.
|
||||
let _ = guard.release(false).await;
|
||||
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
|
||||
}
|
||||
Err(e) => {
|
||||
guard.release(false).await;
|
||||
// release(false): no upload attempted, nothing to fail on.
|
||||
let _ = guard.release(false).await;
|
||||
return Err(AppError::Git(e.to_string()));
|
||||
}
|
||||
};
|
||||
@@ -257,21 +286,57 @@ pub async fn close_issue(
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author {
|
||||
guard.release(false).await;
|
||||
// release(false): no upload attempted, nothing to fail on.
|
||||
let _ = guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Snapshot the issue ref's pre-close OID so a failed durable upload can roll
|
||||
// the close back on local disk, mirroring create_issue. Best-effort: if the
|
||||
// snapshot read fails we proceed without a rollback closure (the close still
|
||||
// applies and a failed upload still surfaces as a 5xx below).
|
||||
let prior_ref = git_issues::issue_ref_oid(&disk_path, &issue_id)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let close_result = git_issues::close_issue(&disk_path, &issue_id);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(close_result.is_ok()).await;
|
||||
// Always release the advisory lock, even on error; upload to Tigris only on
|
||||
// success. A false return means the durable upload failed: the close applied
|
||||
// to local disk but never reached object storage, so a later acquire_write
|
||||
// reverts it. Report 5xx so the client retries. (P1 data-loss guard.)
|
||||
// On that failure, roll the local close BACK to the captured open ref BEFORE
|
||||
// the lock releases — otherwise the local fast path in RepoStore::acquire
|
||||
// serves the "closed" state until a later write refreshes the archive, even
|
||||
// though the client got a 5xx. The cleanup runs only when an upload was
|
||||
// attempted and failed, while the advisory lock is still held.
|
||||
let upload_ok = guard
|
||||
.release_with_failure_cleanup(close_result.is_ok(), |path| {
|
||||
if let Some((full_id, oid)) = &prior_ref {
|
||||
if let Err(e) = git_issues::restore_issue_ref(path, full_id, oid) {
|
||||
tracing::warn!(issue = %issue_id, err = %e,
|
||||
"failed to roll back local issue close after failed durable upload; \
|
||||
a local-fast-path read may serve a stale closed state");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let updated = close_result
|
||||
.map_err(|e| AppError::Git(e.to_string()))?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("issue {issue_id} not found")))?;
|
||||
|
||||
if !upload_ok {
|
||||
tracing::error!(repo = %repo,
|
||||
"durable upload failed after issue close; reporting failure so the client retries");
|
||||
return Err(AppError::Git(format!(
|
||||
"durable storage upload failed for {}",
|
||||
record.name
|
||||
)));
|
||||
}
|
||||
|
||||
let issue: serde_json::Value = serde_json::from_str(&updated)
|
||||
.map_err(|e| AppError::BadRequest(format!("invalid issue data: {e}")))?;
|
||||
|
||||
@@ -279,3 +344,421 @@ pub async fn close_issue(
|
||||
|
||||
Ok(Json(issue))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::extract::{Path as AxPath, State};
|
||||
use axum::Extension;
|
||||
use std::path::Path as StdPath;
|
||||
use std::time::Duration;
|
||||
|
||||
// Object store whose upload() parks forever, so release()'s bounded timeout
|
||||
// is the only thing that completes it, modeling a stalled durable PUT. exists()
|
||||
// is false so acquire_write never downloads over the fresh local bare repo.
|
||||
struct StallStore;
|
||||
#[async_trait::async_trait]
|
||||
impl crate::git::tigris::ObjectStore for StallStore {
|
||||
async fn exists(&self, _o: &str, _r: &str) -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn upload(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
async fn download(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _o: &str, _r: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Object store whose upload succeeds immediately. exists() is false so
|
||||
// acquire_write never performs a reverting refresh over the local dir —
|
||||
// the retry test below pins that contract explicitly.
|
||||
struct OkStore;
|
||||
#[async_trait::async_trait]
|
||||
impl crate::git::tigris::ObjectStore for OkStore {
|
||||
async fn exists(&self, _o: &str, _r: &str) -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn upload(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn download(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _o: &str, _r: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Shared setup for the failed-upload rollback tests: a state whose repo
|
||||
// store stalls uploads (200ms release timeout), a public repo row for
|
||||
// `owner/name`, and an initialized bare repo at the returned path.
|
||||
async fn stall_state_with_repo(
|
||||
pool: sqlx::PgPool,
|
||||
repos_dir: &tempfile::TempDir,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> (crate::state::AppState, std::path::PathBuf) {
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir.path().to_path_buf(),
|
||||
Some(std::sync::Arc::new(StallStore)),
|
||||
pool.clone(),
|
||||
)
|
||||
.with_release_upload_timeout(Duration::from_millis(200));
|
||||
|
||||
let owner_slug = owner.replace([':', '/'], "_");
|
||||
let bare = repos_dir
|
||||
.path()
|
||||
.join(&owner_slug)
|
||||
.join(format!("{name}.git"));
|
||||
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner, name, &bare.to_string_lossy(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["init", "--bare", "-q", &bare.to_string_lossy()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git init --bare failed");
|
||||
|
||||
(state, bare)
|
||||
}
|
||||
|
||||
async fn create_issue_status(
|
||||
state: &crate::state::AppState,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> axum::http::StatusCode {
|
||||
use axum::response::IntoResponse;
|
||||
let resp = super::create_issue(
|
||||
State(state.clone()),
|
||||
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
|
||||
AxPath((owner.to_string(), name.to_string())),
|
||||
axum::Json(CreateIssueRequest {
|
||||
title: "t".into(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match resp {
|
||||
Ok((code, _)) => code,
|
||||
Err(e) => e.into_response().status(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_issue_status(
|
||||
state: &crate::state::AppState,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
issue_id: &str,
|
||||
) -> axum::http::StatusCode {
|
||||
use axum::response::IntoResponse;
|
||||
let resp = super::close_issue(
|
||||
State(state.clone()),
|
||||
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
|
||||
AxPath((owner.to_string(), name.to_string(), issue_id.to_string())),
|
||||
)
|
||||
.await;
|
||||
match resp {
|
||||
Ok(_) => axum::http::StatusCode::OK,
|
||||
Err(e) => e.into_response().status(),
|
||||
}
|
||||
}
|
||||
|
||||
// Seed an OPEN issue directly on local disk, bypassing the (stalling) upload
|
||||
// path so the close test starts from a durably-consistent open issue.
|
||||
fn seed_open_issue(bare: &StdPath, owner: &str, issue_id: &str) {
|
||||
let issue = IssueRecord {
|
||||
id: issue_id.to_string(),
|
||||
title: "t".into(),
|
||||
body: None,
|
||||
author: Some(owner.to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
status: "open".to_string(),
|
||||
signed_payload: None,
|
||||
};
|
||||
let json = serde_json::to_string(&issue).unwrap();
|
||||
git_issues::create_issue(bare, issue_id, &json).unwrap();
|
||||
}
|
||||
|
||||
fn issue_status(bare: &StdPath, issue_id: &str) -> String {
|
||||
let raw = git_issues::get_issue(bare, issue_id).unwrap().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
v["status"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
// U2 [F2, P1] — the failed-upload rollback on the close path. close_issue
|
||||
// applies the "closed" ref to LOCAL disk, then release() uploads to durable
|
||||
// storage. When that upload times out the local close is NOT persisted, so a
|
||||
// later acquire_write reverts it from the stale archive — but until then, a
|
||||
// local-fast-path read via RepoStore::acquire serves the stale "closed"
|
||||
// state even though the client got a 5xx. The fix rolls the close back to the
|
||||
// captured open ref while the advisory lock is held. RED with the cleanup
|
||||
// closure reverted to `|_| {}`: the read sees "closed".
|
||||
#[sqlx::test]
|
||||
async fn issue_close_failed_upload_rolls_back_to_open(pool: sqlx::PgPool) {
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
let owner = "z6issuecloserollbackowner";
|
||||
let name = "issuecloserollback";
|
||||
let (state, bare) = stall_state_with_repo(pool, &repos_dir, owner, name).await;
|
||||
|
||||
let issue_id = "aaaa1111-0000-0000-0000-000000000000";
|
||||
seed_open_issue(&bare, owner, issue_id);
|
||||
assert_eq!(issue_status(&bare, issue_id), "open", "seed must be open");
|
||||
|
||||
// Close via the handler — the durable upload stalls and times out (5xx).
|
||||
let status = close_issue_status(&state, owner, name, issue_id).await;
|
||||
assert!(
|
||||
status.is_server_error(),
|
||||
"a stalled durable upload on issue-close must 500, got {status}"
|
||||
);
|
||||
|
||||
// The local fast path must still see the issue OPEN: the failed upload
|
||||
// rolled the close back while the lock was held.
|
||||
assert_eq!(
|
||||
issue_status(&bare, issue_id),
|
||||
"open",
|
||||
"a failed durable upload must roll the local close back to open; a \
|
||||
local-fast-path read served the stale closed state"
|
||||
);
|
||||
}
|
||||
|
||||
// Success path: a working store closes the issue (2xx) and the read reflects
|
||||
// the close. Confirms the rollback wiring does not fire on success.
|
||||
#[sqlx::test]
|
||||
async fn issue_close_success_persists_close(pool: sqlx::PgPool) {
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
let owner = "z6issueclosesuccessowner";
|
||||
let name = "issueclosesuccess";
|
||||
let (mut state, bare) = stall_state_with_repo(pool.clone(), &repos_dir, owner, name).await;
|
||||
// Swap the stalling store for one whose upload succeeds immediately.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir.path().to_path_buf(),
|
||||
Some(std::sync::Arc::new(OkStore)),
|
||||
pool.clone(),
|
||||
)
|
||||
.with_release_upload_timeout(Duration::from_millis(200));
|
||||
|
||||
let issue_id = "bbbb2222-0000-0000-0000-000000000000";
|
||||
seed_open_issue(&bare, owner, issue_id);
|
||||
|
||||
let status = close_issue_status(&state, owner, name, issue_id).await;
|
||||
assert_eq!(status, StatusCode::OK, "working store must close (200)");
|
||||
assert_eq!(
|
||||
issue_status(&bare, issue_id),
|
||||
"closed",
|
||||
"a successful close must persist as closed"
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-write error path: closing a nonexistent issue returns 404 and never
|
||||
// attempts an upload (release(false)), so the stalling store cannot hang it.
|
||||
#[sqlx::test]
|
||||
async fn issue_close_not_found_releases_without_upload(pool: sqlx::PgPool) {
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
let owner = "z6issueclosemissingowner";
|
||||
let name = "issueclosemissing";
|
||||
let (state, _bare) = stall_state_with_repo(pool, &repos_dir, owner, name).await;
|
||||
|
||||
let status =
|
||||
close_issue_status(&state, owner, name, "cccc3333-0000-0000-0000-000000000000").await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"closing a missing issue must 404 without attempting an upload"
|
||||
);
|
||||
}
|
||||
|
||||
fn issue_refs(bare: &StdPath) -> Vec<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args([
|
||||
"for-each-ref",
|
||||
"--format=%(refname)",
|
||||
"refs/gitlawb/issues/",
|
||||
])
|
||||
.current_dir(bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git for-each-ref failed");
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| l.trim().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Rollback half of the #196 round-four issue-create fix. When the durable
|
||||
// upload fails/times out, the handler 500s (covered below) — but the issue
|
||||
// ref it wrote to LOCAL disk must also be rolled back, while the advisory
|
||||
// lock is still held. An orphan ref makes the 500 dishonest: a retry
|
||||
// re-creates the issue under a new uuid while the orphan is still listed,
|
||||
// and a concurrent same-repo write can upload an archive carrying it.
|
||||
// RED pre-fix: the orphan ref survives the failed create.
|
||||
#[sqlx::test]
|
||||
async fn issue_create_failed_upload_rolls_back_local_ref(pool: sqlx::PgPool) {
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
let owner = "z6issuerollbackowner";
|
||||
let name = "issuerollback";
|
||||
let (state, bare) = stall_state_with_repo(pool, &repos_dir, owner, name).await;
|
||||
|
||||
let status = create_issue_status(&state, owner, name).await;
|
||||
assert!(
|
||||
status.is_server_error(),
|
||||
"stalled upload must 500, got {status}"
|
||||
);
|
||||
|
||||
let refs = issue_refs(&bare);
|
||||
assert!(
|
||||
refs.is_empty(),
|
||||
"a failed durable upload must roll back the local issue ref while \
|
||||
the advisory lock is held; orphan ref(s) survived: {refs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Retry half: after the failed-upload 500, a retry with a WORKING store
|
||||
// must yield exactly one issue. Pre-fix the orphan ref from the failed
|
||||
// attempt makes the retry a duplicate (two issues in list_issues).
|
||||
#[sqlx::test]
|
||||
async fn issue_create_failed_upload_retry_does_not_duplicate(pool: sqlx::PgPool) {
|
||||
use crate::git::tigris::ObjectStore as _;
|
||||
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
let owner = "z6issueretryowner";
|
||||
let name = "issueretry";
|
||||
let (state, bare) = stall_state_with_repo(pool.clone(), &repos_dir, owner, name).await;
|
||||
|
||||
let status = create_issue_status(&state, owner, name).await;
|
||||
assert!(
|
||||
status.is_server_error(),
|
||||
"stalled upload must 500, got {status}"
|
||||
);
|
||||
|
||||
// PIN the double's contract: exists() stays false after the failed
|
||||
// upload, so the retry's acquire_write performs no reverting refresh
|
||||
// from a stale archive. Without this pin, a download could revert the
|
||||
// orphan ref and the assertion below would pass without the rollback
|
||||
// fix — it must rest on the rollback, not a stale-archive revert.
|
||||
let owner_slug = owner.replace([':', '/'], "_");
|
||||
assert!(
|
||||
!OkStore.exists(&owner_slug, name).await.unwrap(),
|
||||
"test double contract broken: exists() must stay false"
|
||||
);
|
||||
|
||||
// Retry with a store whose upload succeeds.
|
||||
let mut retry_state = state.clone();
|
||||
retry_state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir.path().to_path_buf(),
|
||||
Some(std::sync::Arc::new(OkStore)),
|
||||
pool.clone(),
|
||||
)
|
||||
.with_release_upload_timeout(Duration::from_millis(200));
|
||||
|
||||
let status = create_issue_status(&retry_state, owner, name).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::CREATED,
|
||||
"retry with working store must 201"
|
||||
);
|
||||
|
||||
let issues = crate::git::issues::list_issues(&bare).unwrap();
|
||||
assert_eq!(
|
||||
issues.len(),
|
||||
1,
|
||||
"retry after a failed-upload 500 must yield exactly ONE issue; the \
|
||||
failed attempt's orphan ref duplicated it (got {} issues)",
|
||||
issues.len()
|
||||
);
|
||||
}
|
||||
|
||||
// P1-class data-loss regression, issue-create path. create_issue writes the
|
||||
// issue ref to local disk, then release(true) uploads the repo to durable
|
||||
// storage. When that upload times out the local write is NOT persisted, so
|
||||
// the next acquire_write reverts it from the stale archive. The pre-fix
|
||||
// handler ignored release()'s bool and still returned 201 AND bumped the
|
||||
// author's trust score, so the client trusted an issue that never landed
|
||||
// durably. The fix surfaces a failed/timed-out upload as a 5xx and skips the
|
||||
// success tail (the trust bump). Stall the upload past a tiny release timeout
|
||||
// and assert (a) the handler returns 5xx and (b) the trust bump did not run.
|
||||
// RED pre-fix: 201 + trust score bumped to 0.05.
|
||||
#[sqlx::test]
|
||||
async fn issue_create_durable_upload_timeout_fails_and_skips_side_effects(pool: sqlx::PgPool) {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir.path().to_path_buf(),
|
||||
Some(std::sync::Arc::new(StallStore)),
|
||||
pool.clone(),
|
||||
)
|
||||
.with_release_upload_timeout(Duration::from_millis(200));
|
||||
|
||||
let owner = "z6issueownerdurablefail";
|
||||
let name = "issuedurablefail";
|
||||
let owner_slug = owner.replace([':', '/'], "_");
|
||||
let bare = repos_dir
|
||||
.path()
|
||||
.join(&owner_slug)
|
||||
.join(format!("{name}.git"));
|
||||
|
||||
// Public mirror repo so the owner clears authorize_repo_read, and a real
|
||||
// agent row so the post-write trust bump is an observable durable side
|
||||
// effect (register_agent seeds trust_score = 0.0).
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner, name, &bare.to_string_lossy(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
state.db.register_agent(owner, &[]).await.unwrap();
|
||||
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["init", "--bare", "-q", &bare.to_string_lossy()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git init --bare failed");
|
||||
|
||||
// acquire_write → git_issues::create_issue (writes the ref) → release
|
||||
// (upload stalls, times out after 200ms). The client MUST see a failure.
|
||||
let resp = super::create_issue(
|
||||
State(state.clone()),
|
||||
Extension(crate::auth::AuthenticatedDid(owner.to_string())),
|
||||
AxPath((owner.to_string(), name.to_string())),
|
||||
axum::Json(CreateIssueRequest {
|
||||
title: "t".into(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let status = match resp {
|
||||
Ok((code, _)) => code,
|
||||
Err(e) => e.into_response().status(),
|
||||
};
|
||||
assert!(
|
||||
status.is_server_error(),
|
||||
"a timed-out durable upload on issue-create must fail (5xx) so the \
|
||||
client retries (got {status}), which the client trusts as a landed \
|
||||
issue that a later acquire_write would silently revert"
|
||||
);
|
||||
|
||||
// The success tail (trust bump) must NOT have run: the issue was never
|
||||
// durably accepted. register_agent seeded 0.0; a bump would make it 0.05.
|
||||
let score = state.db.get_trust_score(owner).await.unwrap();
|
||||
assert_eq!(
|
||||
score, 0.0,
|
||||
"the trust bump must NOT run when the durable upload failed; the \
|
||||
issue was not durably accepted (score bumped to {score})"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,10 @@ pub async fn remove_label(
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/labels
|
||||
///
|
||||
/// Read-visibility-gated (INV-2 root listing): a public repo's labels stay
|
||||
/// anonymously listable; a private repo's label names are hidden (404) from
|
||||
/// anyone who cannot read it at the root.
|
||||
pub async fn list_labels(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
|
||||
@@ -172,6 +172,7 @@ mod authz_guard {
|
||||
let issues = include_str!("issues.rs");
|
||||
let bounties = include_str!("bounties.rs");
|
||||
let replicas = include_str!("replicas.rs");
|
||||
let events = include_str!("events.rs");
|
||||
let tasks = include_str!("tasks.rs");
|
||||
let stars = include_str!("stars.rs");
|
||||
let protect = include_str!("protect.rs");
|
||||
@@ -187,6 +188,13 @@ mod authz_guard {
|
||||
(pulls, "merge_pr", "require_repo_owner("),
|
||||
(webhooks, "create_webhook", "require_repo_owner("),
|
||||
(webhooks, "delete_webhook", "require_repo_owner("),
|
||||
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
|
||||
// authorize_repo_read 404s a non-reader of a private repo, then
|
||||
// require_repo_owner 403s a non-owner of a public one. Both halves
|
||||
// are pinned: the authorize_repo_read marker guards the read half
|
||||
// (existence hiding) and require_repo_owner guards the owner half.
|
||||
(webhooks, "list_webhooks", "authorize_repo_read("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
// Bucket A' — owner OR author (did_matches against the author)
|
||||
@@ -203,6 +211,12 @@ mod authz_guard {
|
||||
// get_by_cid gates each iterated repo row directly via visibility_check
|
||||
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
|
||||
(ipfs, "get_by_cid", "visibility_check("),
|
||||
// #94 sibling read surfaces: gate private-repo metadata on read
|
||||
// visibility (public repos stay anonymous; private repos 404).
|
||||
(replicas, "list_replicas", "authorize_repo_read("),
|
||||
(protect, "list_protected_branches", "authorize_repo_read("),
|
||||
(labels, "list_labels", "authorize_repo_read("),
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
@@ -509,11 +523,7 @@ mod authz_guard {
|
||||
|
||||
// Repo-scoped reads known to be ungated today, each tracked by an issue.
|
||||
// Remove an entry the moment its gate lands (the staleness assert enforces it).
|
||||
let known_ungated: &[(&str, &str)] = &[
|
||||
("list_webhooks", "#94 (PR #113)"),
|
||||
("list_replicas", "PR #113"),
|
||||
("list_protected_branches", "PR #113"),
|
||||
];
|
||||
let known_ungated: &[(&str, &str)] = &[];
|
||||
let is_known = |n: &str| known_ungated.iter().any(|(k, _)| *k == n);
|
||||
// Any one of these = the handler binds the caller to an authz decision: the
|
||||
// first two are read gates, the rest are the write/owner/self-binding forms.
|
||||
|
||||
@@ -347,6 +347,10 @@ pub struct NotifyRequest {
|
||||
pub timestamp: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cert_id: Option<String>,
|
||||
/// Full owner DID — added in #144 for DID-aware feed gating.
|
||||
/// Optional for backward compat with older senders.
|
||||
#[serde(default)]
|
||||
pub owner_did: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn notify_sync(
|
||||
@@ -391,6 +395,7 @@ pub async fn notify_sync(
|
||||
node_did: req.node_did.clone(),
|
||||
pusher_did: req.pusher_did.clone().unwrap_or_default(),
|
||||
repo: req.repo.clone(),
|
||||
owner_did: req.owner_did.clone(),
|
||||
ref_name: req.ref_name.clone(),
|
||||
old_sha: req.old_sha.clone().unwrap_or_default(),
|
||||
new_sha: req.new_sha.clone(),
|
||||
|
||||
@@ -79,12 +79,14 @@ pub async fn unprotect_branch(
|
||||
pub async fn list_protected_branches(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Read-visibility-gated (INV-2 root listing): a public repo's protected
|
||||
// branches stay anonymously listable; a private repo's branch names are
|
||||
// hidden (404) from anyone who cannot read it at the root.
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let branches = state.db.list_protected_branches(&record.id).await?;
|
||||
|
||||
|
||||
@@ -216,6 +216,14 @@ 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;
|
||||
|
||||
// Snapshot the target branch's pre-merge tip so a failed durable upload can
|
||||
// roll the merge commit back on local disk (mirrors create_issue). Without
|
||||
// this the local fast path in RepoStore::acquire serves the merged tree until
|
||||
// a later write refreshes the archive, even though the client got a 5xx.
|
||||
let target_ref = format!("refs/heads/{}", pr.target_branch);
|
||||
let prior_target = store::ref_oid(&disk_path, &target_ref).ok().flatten();
|
||||
|
||||
let merge_result = store::merge_branch(
|
||||
&disk_path,
|
||||
&pr.target_branch,
|
||||
@@ -224,11 +232,37 @@ pub async fn merge_pr(
|
||||
&pr.title,
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
// Always release the advisory lock, even on error; upload to Tigris only on
|
||||
// success. A false return means the durable upload failed/timed out: the merge
|
||||
// commit applied to local disk but never reached object storage, so the next
|
||||
// acquire_write reverts it from the stale archive. Report 5xx so the client
|
||||
// retries rather than marking the PR merged in the DB while the merged tree
|
||||
// was silently lost. Fail BEFORE merge_pr / touch_repo / webhooks. (P1
|
||||
// data-loss guard, same as receive-pack.) On that failure, roll the local
|
||||
// merge back to the captured tip BEFORE the lock releases.
|
||||
let upload_ok = guard
|
||||
.release_with_failure_cleanup(merge_result.is_ok(), |path| {
|
||||
if let Some(oid) = &prior_target {
|
||||
if let Err(e) = store::set_ref(path, &target_ref, oid) {
|
||||
tracing::warn!(repo = %record.name, err = %e,
|
||||
"failed to roll back local merge commit after failed durable upload; \
|
||||
a local-fast-path read may serve the un-uploaded merged tree");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
if !upload_ok {
|
||||
tracing::error!(repo = %record.name,
|
||||
"durable upload failed after merge; reporting failure so the client retries");
|
||||
return Err(AppError::Git(format!(
|
||||
"durable storage upload failed for {}",
|
||||
record.name
|
||||
)));
|
||||
}
|
||||
|
||||
state.db.merge_pr(&pr.id, &merger_did).await?;
|
||||
let _ = state.db.touch_repo(&record.id).await;
|
||||
|
||||
@@ -424,3 +458,169 @@ 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 {
|
||||
// super::* brings in the handler plus the axum Extension/Path/State
|
||||
// extractors and the Utc/Uuid/PullRequest types the seed uses.
|
||||
use super::*;
|
||||
use std::path::Path as StdPath;
|
||||
use std::time::Duration;
|
||||
|
||||
// Object store whose upload() parks forever, so release()'s bounded timeout
|
||||
// is the only thing that completes it, modeling a stalled durable PUT.
|
||||
// exists() is false so acquire_write never downloads over the local bare repo.
|
||||
struct StallStore;
|
||||
#[async_trait::async_trait]
|
||||
impl crate::git::tigris::ObjectStore for StallStore {
|
||||
async fn exists(&self, _o: &str, _r: &str) -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn upload(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
async fn download(&self, _o: &str, _r: &str, _p: &StdPath) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _o: &str, _r: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// The failed-upload rollback on the merge path. merge_pr writes the merge
|
||||
// commit to LOCAL disk, then release() uploads to durable storage. When
|
||||
// that upload times out the handler must 5xx (before merge_pr/webhooks)
|
||||
// AND roll the target branch back to its pre-merge tip while the advisory
|
||||
// lock is held, so a local-fast-path read never serves the un-uploaded
|
||||
// merged tree. RED with the cleanup closure at ~pulls.rs:244 reverted to
|
||||
// `|_| {}`: main stays at the merge commit.
|
||||
#[sqlx::test]
|
||||
async fn merge_failed_upload_rolls_back_target_ref(pool: sqlx::PgPool) {
|
||||
use axum::response::IntoResponse;
|
||||
use std::process::Command;
|
||||
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir.path().to_path_buf(),
|
||||
Some(std::sync::Arc::new(StallStore)),
|
||||
pool.clone(),
|
||||
)
|
||||
.with_release_upload_timeout(Duration::from_millis(200));
|
||||
|
||||
let owner = "z6mergerollbackowner";
|
||||
let name = "mergerollback";
|
||||
let owner_slug = owner.replace([':', '/'], "_");
|
||||
let bare = repos_dir
|
||||
.path()
|
||||
.join(&owner_slug)
|
||||
.join(format!("{name}.git"));
|
||||
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner, name, &bare.to_string_lossy(), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
fn git(args: &[&str], dir: &StdPath) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
// Scratch repo: c1 on main, c2 on feature; the bare clone carries both
|
||||
// branches, so the merge of feature into main has real work to do.
|
||||
let work = tempfile::TempDir::new().unwrap();
|
||||
git(&["init", "-q", "-b", "main", "."], work.path());
|
||||
git(&["config", "user.email", "t@t"], work.path());
|
||||
git(&["config", "user.name", "t"], work.path());
|
||||
std::fs::write(work.path().join("f.txt"), "one").unwrap();
|
||||
git(&["add", "f.txt"], work.path());
|
||||
git(&["commit", "-q", "-m", "c1"], work.path());
|
||||
git(&["checkout", "-q", "-b", "feature"], work.path());
|
||||
std::fs::write(work.path().join("g.txt"), "two").unwrap();
|
||||
git(&["add", "g.txt"], work.path());
|
||||
git(&["commit", "-q", "-m", "c2"], work.path());
|
||||
|
||||
std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
&work.path().to_string_lossy(),
|
||||
&bare.to_string_lossy(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"clone --bare failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let pre_merge_tip = store::ref_oid(&bare, "refs/heads/main")
|
||||
.unwrap()
|
||||
.expect("main exists before the merge");
|
||||
|
||||
// Seed the open PR row the handler looks up.
|
||||
let record = state.db.get_repo(owner, name).await.unwrap().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
state
|
||||
.db
|
||||
.create_pr(&PullRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: record.id.clone(),
|
||||
number: 1,
|
||||
title: "t".into(),
|
||||
body: None,
|
||||
author_did: owner.to_string(),
|
||||
source_branch: "feature".into(),
|
||||
target_branch: "main".into(),
|
||||
status: "open".to_string(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Merge as the owner: the merge commit applies locally, the durable
|
||||
// upload stalls and times out, and the handler must report 5xx.
|
||||
let resp = super::merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), name.to_string(), 1)),
|
||||
)
|
||||
.await;
|
||||
let status = match resp {
|
||||
Ok(_) => StatusCode::OK,
|
||||
Err(e) => e.into_response().status(),
|
||||
};
|
||||
assert!(
|
||||
status.is_server_error(),
|
||||
"a stalled durable upload on merge must 5xx, got {status}"
|
||||
);
|
||||
|
||||
// The target branch must still point at the pre-merge tip: the failed
|
||||
// upload rolled the merge commit back while the lock was held.
|
||||
let tip = store::ref_oid(&bare, "refs/heads/main")
|
||||
.unwrap()
|
||||
.expect("main still exists after the rollback");
|
||||
assert_eq!(
|
||||
tip, pre_merge_tip,
|
||||
"a failed durable upload must roll the target branch back to its \
|
||||
pre-merge tip; a local-fast-path read served the un-uploaded merge"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,16 +113,17 @@ pub async fn unregister_replica(
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/replicas
|
||||
/// Public — returns the list of replicas (DID + URL + registration timestamp).
|
||||
/// Read-visibility-gated: a PUBLIC repo's replica list stays anonymously
|
||||
/// listable (mirror-discovery), but a PRIVATE repo's replica URLs are hidden
|
||||
/// (404) from anyone who cannot read the repo at its root.
|
||||
pub async fn list_replicas(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let replicas = state.db.list_replicas(&record.id).await?;
|
||||
|
||||
|
||||
+3045
-437
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,8 @@
|
||||
//! Webhook CRUD API.
|
||||
//!
|
||||
//! POST /api/v1/repos/:owner/:repo/hooks — create (auth required)
|
||||
//! GET /api/v1/repos/:owner/:repo/hooks — list
|
||||
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (auth required)
|
||||
//! POST /api/v1/repos/:owner/:repo/hooks — create (owner only)
|
||||
//! GET /api/v1/repos/:owner/:repo/hooks — list (owner only; auth required)
|
||||
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (owner only)
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -73,12 +73,26 @@ pub async fn create_webhook(
|
||||
pub async fn list_webhooks(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// This route sits on `optional_signature`, so the DID is optional. Webhook
|
||||
// callback URLs are owner-secret config and there is no anonymous form, so a
|
||||
// headerless caller is rejected before any lookup (401, which fires for an
|
||||
// existing-private and an absent repo alike, so it leaks no existence).
|
||||
let Some(Extension(AuthenticatedDid(caller))) = auth else {
|
||||
return Err(AppError::Unauthorized(
|
||||
"listing webhooks requires authentication".into(),
|
||||
));
|
||||
};
|
||||
|
||||
// Read-visibility first, then owner. authorize_repo_read returns 404 on a
|
||||
// visibility deny, so a non-reader of a private repo cannot tell it exists
|
||||
// (uniform with the sibling read surfaces); require_repo_owner then yields
|
||||
// 403 only for a non-owner of a public/readable repo, where existence is not
|
||||
// secret.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(&caller), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &caller)?;
|
||||
|
||||
let hooks = state.db.list_webhooks(&record.id).await?;
|
||||
// Redact secrets in list response
|
||||
|
||||
@@ -515,6 +515,7 @@ mod tests {
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
write_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Optional admin subcommands. When none is given, the binary runs the node
|
||||
/// daemon as before — the default (no-subcommand) startup path is unchanged.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum Command {
|
||||
/// Dry-run (default) or, with --execute, delete empty spam-burst repos owned
|
||||
/// by the known burst DID. Never touches the hard-excluded DIDs, and verifies
|
||||
/// each repo is empty (zero git refs) per repo before selecting it.
|
||||
PurgeSpam {
|
||||
/// Actually delete the candidates. Omit for a dry-run that prints the
|
||||
/// candidate list and deletes nothing.
|
||||
#[arg(long, default_value_t = false)]
|
||||
execute: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
|
||||
pub struct Config {
|
||||
/// Directory where bare git repositories are stored
|
||||
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
|
||||
/// Admin subcommand to run instead of the node daemon. Absent = run the node.
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Directory where bare git repositories are stored. `global` so it can
|
||||
/// follow a subcommand (e.g. `gitlawb-node purge-spam --repos-dir …`).
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_REPOS_DIR",
|
||||
default_value = "./data/repos",
|
||||
global = true
|
||||
)]
|
||||
pub repos_dir: PathBuf,
|
||||
|
||||
/// PostgreSQL connection URL (Supabase or any Postgres instance)
|
||||
/// PostgreSQL connection URL (Supabase or any Postgres instance). `global` so
|
||||
/// admin subcommands accept it in either position.
|
||||
#[arg(
|
||||
long,
|
||||
env = "DATABASE_URL",
|
||||
default_value = "postgresql://localhost/gitlawb"
|
||||
default_value = "postgresql://localhost/gitlawb",
|
||||
global = true
|
||||
)]
|
||||
pub database_url: String,
|
||||
|
||||
@@ -194,6 +221,22 @@ pub struct Config {
|
||||
)]
|
||||
pub db_max_connections: u32,
|
||||
|
||||
/// Maximum connections in the dedicated advisory-lock pool, separate from
|
||||
/// the main pool above. Every in-flight repo write (git-receive-pack, fork,
|
||||
/// merge) pins one connection here for its whole lifetime — the write, its
|
||||
/// metadata/fan-out tail, and the bounded post-write archive upload — so
|
||||
/// size it to the expected peak number of concurrent distinct-repo writers,
|
||||
/// NOT small. Keeping it separate from GITLAWB_DB_MAX_CONNECTIONS is what
|
||||
/// stops a push burst from starving normal request handlers. Keep
|
||||
/// (main pool + lock pool) within the database server's max_connections.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS",
|
||||
default_value_t = 32,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub db_lock_pool_max_connections: u32,
|
||||
|
||||
/// Maximum time a request waits for a pool connection before failing with
|
||||
/// 503, in seconds. Bounds queueing when the database is slow or down.
|
||||
#[arg(
|
||||
|
||||
+1416
-10
File diff suppressed because it is too large.
Load diff
@@ -35,9 +35,6 @@ pub enum AppError {
|
||||
#[error("invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
#[error("incomplete: {0}")]
|
||||
Incomplete(String),
|
||||
|
||||
@@ -47,6 +44,9 @@ pub enum AppError {
|
||||
#[error("git service timed out: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("service unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
@@ -132,9 +132,6 @@ impl IntoResponse for AppError {
|
||||
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
|
||||
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
|
||||
}
|
||||
AppError::Incomplete(msg) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
|
||||
}
|
||||
@@ -142,6 +139,14 @@ impl IntoResponse for AppError {
|
||||
// 504, distinct from the 500 git_error and from the read-gate's 404 /
|
||||
// the auth 401, so the client can tell a deadline from a failure.
|
||||
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
|
||||
// 503, retryable: a transient resource contention (e.g. the repo lock
|
||||
// pool is pinned by a write burst or a purge holds the key), distinct
|
||||
// from the 500 git_error so the client knows to back off and retry.
|
||||
AppError::Unavailable(msg) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"service_unavailable",
|
||||
msg.clone(),
|
||||
),
|
||||
AppError::Db(e) if db_unavailable(e) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
DB_UNAVAILABLE_CODE,
|
||||
@@ -182,4 +187,20 @@ mod tests {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_maps_to_503_distinct_from_git_500() {
|
||||
// The create-repo lock-acquire failure returns Unavailable so a client sees
|
||||
// a retryable 503, not a terminal 500 git_error.
|
||||
assert_eq!(
|
||||
AppError::Unavailable("lock pool pinned".into())
|
||||
.into_response()
|
||||
.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(
|
||||
AppError::Git("x".into()).into_response().status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,51 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an issue ref. Used to roll back a locally written issue whose
|
||||
/// durable upload failed, so a retry does not duplicate it. The dangling
|
||||
/// blob object is harmless.
|
||||
pub fn delete_issue_ref(repo_path: &Path, issue_id: &str) -> Result<()> {
|
||||
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
|
||||
let output = Command::new("git")
|
||||
.args(["update-ref", "-d", &ref_name])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git update-ref -d")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git update-ref -d failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the object id an issue ref currently points to (resolving an 8-char
|
||||
/// prefix), returning `(full_id, oid)`. Returns None if no such issue exists.
|
||||
/// Used to snapshot an issue ref's pre-mutation state so a failed durable
|
||||
/// upload can restore it, mirroring `create_issue`'s rollback.
|
||||
pub fn issue_ref_oid(repo_path: &Path, issue_id: &str) -> Result<Option<(String, String)>> {
|
||||
let full_id = match resolve_issue_id(repo_path, issue_id)? {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let ref_name = format!("refs/gitlawb/issues/{full_id}");
|
||||
let oid = match crate::git::store::ref_oid(repo_path, &ref_name)? {
|
||||
Some(oid) => oid,
|
||||
None => return Ok(None),
|
||||
};
|
||||
Ok(Some((full_id, oid)))
|
||||
}
|
||||
|
||||
/// Reset an issue ref back to a captured object id — rolls back a local
|
||||
/// mutation (e.g. a close) whose durable upload failed, so a local-fast-path
|
||||
/// read does not serve the un-uploaded state. `full_id` must be the resolved
|
||||
/// UUID (from [`issue_ref_oid`]). The now-dangling object is harmless.
|
||||
pub fn restore_issue_ref(repo_path: &Path, full_id: &str, oid: &str) -> Result<()> {
|
||||
let ref_name = format!("refs/gitlawb/issues/{full_id}");
|
||||
crate::git::store::set_ref(repo_path, &ref_name, oid)
|
||||
}
|
||||
|
||||
/// List all issue refs and return their JSON content.
|
||||
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
|
||||
// List all refs under refs/gitlawb/issues/
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,431 @@
|
||||
//! Read-path download coordination (KTD-3) for [`RepoStore`].
|
||||
//!
|
||||
//! The read path's cache-miss handling lives here: the per-repo async mutex
|
||||
//! that serializes concurrent readers of the same repo, the holder's fetch +
|
||||
//! extract + under-lock publish, and the RAII temp-dir cleanup. `acquire` and
|
||||
//! `acquire_fresh` stay in the parent module and call into `download_published`.
|
||||
//! These methods reach the parent's private fields (`download_locks`) and
|
||||
//! private methods (`try_lock_repo`) directly, because a child module can see
|
||||
//! its parent's private items.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Outcome of a coordinated read-path download (KTD-3).
|
||||
pub(super) enum DownloadOutcome {
|
||||
/// The local dir is present: this reader downloaded and published it under
|
||||
/// the advisory lock, or a concurrent reader did and this one served it.
|
||||
Published,
|
||||
/// The download was discarded without publishing: the advisory lock was
|
||||
/// contended (a live writer or purge holds it), the lock pool errored, or
|
||||
/// the archive vanished under the lock (purged mid-download). The caller
|
||||
/// degrades to its missing-path or serve-local-copy outcome.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// RAII cleanup for a read-path download temp dir (finding 1). Its `Drop`
|
||||
/// removes the dir (best effort), so a handler future cancelled mid-download
|
||||
/// (axum drops it on client disconnect, the same hazard `RepoWriteGuard` /
|
||||
/// `RepoLockGuard` Drop impls exist for) cannot leak a fully-populated repo
|
||||
/// dir. `disarm` forgets the dir once the publish rename has consumed it.
|
||||
struct TempDownloadDir {
|
||||
path: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl TempDownloadDir {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
Self { path, armed: true }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Disarm: the temp has been consumed (renamed into place), so `Drop` must
|
||||
/// not try to remove it.
|
||||
fn disarm(mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDownloadDir {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII removal of a download-coordination map entry (finding 4). Its `Drop`
|
||||
/// removes the entry from `download_locks` whenever the map still holds THIS
|
||||
/// Arc (ptr_eq-guarded so a newer entry inserted after an earlier removal is
|
||||
/// never clobbered). Removal is guaranteed on every path (a normal return or
|
||||
/// `?` drops it in the holder; once a download is in flight it travels with
|
||||
/// the spawned task and drops at settle, covering holder timeout and a
|
||||
/// handler future cancelled mid cold-read, which axum triggers on client
|
||||
/// disconnect), which is what keeps the map from growing per arbitrary
|
||||
/// requested name: the pre-fix explicit removals ran only at the labelled
|
||||
/// return points, which a cancelled future skipped, leaking the entry. The
|
||||
/// outer map is a std::sync::Mutex so
|
||||
/// this Drop can prune it synchronously (Drop cannot be async); it is only ever
|
||||
/// held briefly for a get/insert/remove, never across an await.
|
||||
struct DownloadEntryGuard {
|
||||
locks: Arc<std::sync::Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
key: String,
|
||||
entry: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl Drop for DownloadEntryGuard {
|
||||
fn drop(&mut self) {
|
||||
// A poisoned map is still safe to prune — recover the guard and remove.
|
||||
let mut map = self.locks.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if map
|
||||
.get(&self.key)
|
||||
.is_some_and(|cur| Arc::ptr_eq(cur, &self.entry))
|
||||
{
|
||||
map.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoStore {
|
||||
/// Coordinated read-path download (KTD-3). Serializes concurrent readers
|
||||
/// of the same repo on a per-repo async mutex: the first one in becomes
|
||||
/// the holder and runs [`download_and_publish`](Self::download_and_publish);
|
||||
/// a contended reader awaits the holder, re-checks the local dir on wake,
|
||||
/// and serves what the holder published instead of re-downloading. Only
|
||||
/// callers that already confirmed the archive exists reach this, and the
|
||||
/// map entry is removed on completion, so the map cannot grow per
|
||||
/// arbitrary requested name.
|
||||
pub(super) async fn download_published(
|
||||
&self,
|
||||
owner_did: &str,
|
||||
repo_name: &str,
|
||||
owner_slug: &str,
|
||||
local_path: &Path,
|
||||
store: &Arc<dyn ObjectStore>,
|
||||
local_existed_at_entry: bool,
|
||||
) -> Result<DownloadOutcome> {
|
||||
let map_key = format!("{owner_slug}/{repo_name}");
|
||||
let entry = {
|
||||
let mut map = self.download_locks.lock().unwrap();
|
||||
Arc::clone(
|
||||
map.entry(map_key.clone())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||
)
|
||||
};
|
||||
let held = match Arc::clone(&entry).try_lock_owned() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
// Bounded coalescing wait: once a holder's download is in
|
||||
// flight, the spawned task owns this lock (and the map entry)
|
||||
// until it settles, whatever happens to the holder (timeout or
|
||||
// cancellation), so an unbounded lock().await here would park
|
||||
// this reader for as long as a stall lasts. On elapse, report
|
||||
// the same timeout error the holder would. Do NOT touch the
|
||||
// map entry on this path: pruning it while the download is in
|
||||
// flight would let the next arrival start a duplicate download.
|
||||
let waited = tokio::time::timeout(
|
||||
self.release_upload_timeout,
|
||||
Arc::clone(&entry).lock_owned(),
|
||||
)
|
||||
.await;
|
||||
let g = match waited {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
if local_path.exists() {
|
||||
// A concurrent reader published while we waited.
|
||||
return Ok(DownloadOutcome::Published);
|
||||
}
|
||||
return Err(anyhow::anyhow!("read download timed out"));
|
||||
}
|
||||
};
|
||||
if local_path.exists() {
|
||||
// A concurrent reader published while we waited — serve it.
|
||||
// The holder's own guard prunes the map entry on its exit.
|
||||
return Ok(DownloadOutcome::Published);
|
||||
}
|
||||
// The holder degraded without publishing; this reader takes over.
|
||||
// The prior holder removed the map entry on its way out, so
|
||||
// re-register THIS entry before downloading, so readers arriving
|
||||
// now coalesce onto it instead of starting a duplicate download
|
||||
// (finding 3). Narrows the duplicate-download window; publishes
|
||||
// stay serialized by the advisory lock regardless.
|
||||
{
|
||||
let mut map = self.download_locks.lock().unwrap();
|
||||
map.entry(map_key.clone())
|
||||
.or_insert_with(|| Arc::clone(&entry));
|
||||
}
|
||||
g
|
||||
}
|
||||
};
|
||||
// RAII: remove the map entry when this holder's work is done (finding
|
||||
// 4). Created only once THIS reader holds the per-repo lock, so a
|
||||
// parked or timed-out waiter never prunes the entry out from under a
|
||||
// live holder; its Drop is ptr_eq-guarded, so it never clobbers a
|
||||
// newer entry. Ownership moves into download_and_publish and from
|
||||
// there into the spawned download task itself, so on EVERY settle path
|
||||
// (success, download error, holder timeout, holder cancellation) the
|
||||
// entry stays registered and the lock stays held until the in-flight
|
||||
// download settles: waiters queue on the ONE download instead of each
|
||||
// timeout or cancel wave starting a fresh full download of the same
|
||||
// object.
|
||||
let entry_guard = DownloadEntryGuard {
|
||||
locks: Arc::clone(&self.download_locks),
|
||||
key: map_key,
|
||||
entry: Arc::clone(&entry),
|
||||
};
|
||||
self.download_and_publish(
|
||||
owner_did,
|
||||
repo_name,
|
||||
owner_slug,
|
||||
local_path,
|
||||
store,
|
||||
local_existed_at_entry,
|
||||
entry_guard,
|
||||
held,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The holder's half of the coordinated download: fetch and extract the
|
||||
/// archive into a temp sibling with NO advisory lock held (the network
|
||||
/// phase must never pin a lock-pool connection — a cold-read burst across
|
||||
/// distinct repos must not drain the writer-sized pool), then take the
|
||||
/// per-repo advisory lock only around the publish: re-check the archive
|
||||
/// still exists under the lock (a purge deletes it under this same lock,
|
||||
/// so a post-purge downloader discards rather than resurrecting), swap
|
||||
/// the temp copy into place, release. Advisory contention or a lock-pool
|
||||
/// error discards the temp copy and degrades — no blocking lock or pool
|
||||
/// wait exists anywhere on the read path.
|
||||
///
|
||||
/// `local_existed_at_entry` selects the stale-swap guard (finding 2): a cold
|
||||
/// `acquire` passes `false`, an `acquire_fresh` refresh passes `true`. Under
|
||||
/// the lock, before the swap, we detect a writer that published a fresher
|
||||
/// copy during our unlocked download window and serve theirs instead of
|
||||
/// clobbering it with our now-stale temp.
|
||||
///
|
||||
/// `entry_guard` and `held` are this holder's per-repo coordination (the
|
||||
/// download_locks map entry and the locked per-repo mutex). Both move into
|
||||
/// the spawned download task, which returns them through its JoinHandle on
|
||||
/// success; on any other settle path (download error, task panic, holder
|
||||
/// timeout, holder cancellation) they drop when the task settles, so
|
||||
/// waiters keep queueing on the SAME in-flight download until it settles
|
||||
/// rather than each starting a duplicate one.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_and_publish(
|
||||
&self,
|
||||
owner_did: &str,
|
||||
repo_name: &str,
|
||||
owner_slug: &str,
|
||||
local_path: &Path,
|
||||
store: &Arc<dyn ObjectStore>,
|
||||
local_existed_at_entry: bool,
|
||||
entry_guard: DownloadEntryGuard,
|
||||
held: tokio::sync::OwnedMutexGuard<()>,
|
||||
) -> Result<DownloadOutcome> {
|
||||
let parent = local_path.parent().context("repo path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating repo parent dir")?;
|
||||
let file_name = local_path
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
|
||||
// Best-effort sweep of leftover temp siblings from a prior download whose
|
||||
// cleanup never ran (a process crash or kill mid-download, where no Drop
|
||||
// and no owning task survive to remove the temp). Serialized per
|
||||
// repo by the download mutex, so no concurrent same-repo download owns a
|
||||
// live temp here; scoped to THIS repo's prefix, so other repos are
|
||||
// untouched.
|
||||
let tmp_prefix = format!(".{file_name}.tmp-download.");
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_name().to_string_lossy().starts_with(&tmp_prefix) {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stale-swap signal (finding 2): capture the local dir's mtime BEFORE the
|
||||
// unlocked download. Under the lock, a changed mtime (acquire_fresh,
|
||||
// refreshing over an existing copy) or the dir's mere appearance (cold
|
||||
// acquire) means a writer published a fresher copy during our download
|
||||
// window, so we serve theirs rather than swap our stale temp over it.
|
||||
let entry_mtime = std::fs::metadata(local_path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok();
|
||||
|
||||
// Unique per-download temp target (same parent as local_path so the
|
||||
// publish rename stays on one filesystem); mirrors the extract temp
|
||||
// naming in tigris.rs. Wrapped in an RAII guard so a handler future
|
||||
// cancelled mid-download (axum drops it on client disconnect) cannot
|
||||
// leak the populated temp dir (the same hazard the guard Drop impls
|
||||
// below exist for, finding 1).
|
||||
let temp = TempDownloadDir::new(parent.join(format!(
|
||||
".{file_name}.tmp-download.{}",
|
||||
uuid::Uuid::new_v4()
|
||||
)));
|
||||
|
||||
// Ownership of the download AND the temp-dir guard moves into a spawned
|
||||
// task, so timeout and handler cancellation share ONE cleanup path
|
||||
// (finding F3 and its cancellation twin). `store.download` runs its tar
|
||||
// extraction in `spawn_blocking`, and neither a tokio timeout nor a
|
||||
// dropped handler future cancels a spawn_blocking task: pre-fix,
|
||||
// dropping the download future (a timed-out await, or axum dropping the
|
||||
// whole handler on client disconnect) detached the extraction, whose
|
||||
// later rename resurrected `temp.path()` AFTER the guard's Drop had
|
||||
// removed it, leaving an orphan only a later same-repo download sweeps.
|
||||
// The spawned task is never dropped mid-flight, so the download future
|
||||
// always runs to settle. On success it returns the still-armed guard
|
||||
// through the JoinHandle: the caller consumes it and publishes below.
|
||||
// If the caller timed out or was cancelled, the JoinHandle is gone, so
|
||||
// the runtime drops the returned guard when the task settles and its
|
||||
// Drop removes the temp dir; cleanup is tied to the extraction's
|
||||
// completion, never racing it (this subsumes the previous detached
|
||||
// janitor). A download error drops the guard inside the task with the
|
||||
// same at-settle timing.
|
||||
let store_dl = Arc::clone(store);
|
||||
let (dl_owner, dl_repo, dl_target) = (
|
||||
owner_slug.to_string(),
|
||||
repo_name.to_string(),
|
||||
temp.path().to_path_buf(),
|
||||
);
|
||||
// The per-repo coordination (`held`, `entry_guard`) moves into the task
|
||||
// alongside the temp guard: whatever happens to THIS caller (timeout
|
||||
// below, or a handler future cancelled by axum on client disconnect),
|
||||
// the lock stays held and the map entry stays registered until the
|
||||
// in-flight download settles, so waiters and new arrivals queue on the
|
||||
// ONE download instead of starting duplicates. On success the tuple
|
||||
// returns through the JoinHandle: the caller publishes under `held` as
|
||||
// before. If the caller timed out or was cancelled, the runtime drops
|
||||
// the returned tuple when the task settles, in field order: `temp`
|
||||
// (dir removed), then `held` (lock released), then `entry_guard` (map
|
||||
// entry pruned), the same unlock-before-prune order as a normal
|
||||
// return.
|
||||
let dl_task = tokio::spawn(async move {
|
||||
// The guards travel as one `(held, entry_guard)` tuple so that
|
||||
// wherever it drops (here on error, in the runtime at settle after
|
||||
// a caller timeout or cancellation, or in the caller after a
|
||||
// normal publish), its field order gives unlock-before-prune.
|
||||
match store_dl.download(&dl_owner, &dl_repo, &dl_target).await {
|
||||
Ok(()) => Ok((temp, (held, entry_guard))),
|
||||
// The download settled, so cleanup is safe. Explicit drops pin
|
||||
// the order (async-block captures have no guaranteed one):
|
||||
// temp removed, lock released, map entry pruned last.
|
||||
Err(e) => {
|
||||
drop(temp);
|
||||
drop(held);
|
||||
drop(entry_guard);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
});
|
||||
// Bound the wait, not the work: a stalled GET would park this reader
|
||||
// indefinitely. On timeout the caller gets the same error as before,
|
||||
// but the coordination does NOT unwind: it lives in the task (see
|
||||
// above) and is released only at settle. Waiters therefore stay queued
|
||||
// on the SAME in-flight download (each bounded by its own timeout in
|
||||
// download_published) instead of each timeout wave starting a fresh
|
||||
// full download of the same object; the task keeps running and cleans
|
||||
// up its temp when the download settles.
|
||||
// `_coordination` is `(held, entry_guard)`, kept alive to the end of
|
||||
// this function exactly as the separate guards were before: the
|
||||
// publish below still runs with the per-repo lock held, and the map
|
||||
// entry is pruned only on exit (unlock first, prune second, per the
|
||||
// tuple's field order).
|
||||
let (temp, _coordination) =
|
||||
match tokio::time::timeout(self.release_upload_timeout, dl_task).await {
|
||||
Ok(Ok(Ok(returned))) => returned,
|
||||
Ok(Ok(Err(e))) => return Err(e),
|
||||
// The task panicked; its unwind dropped the guards, removing
|
||||
// the dir, releasing the lock, and pruning the map entry.
|
||||
Ok(Err(join_err)) => {
|
||||
return Err(anyhow::Error::from(join_err).context("read download task failed"))
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(repo = %repo_name, timeout_secs = self.release_upload_timeout.as_secs(),
|
||||
"read download timed out — discarding");
|
||||
// Dropping the JoinHandle detaches the task; the runtime
|
||||
// frees temp dir, lock, and map entry at settle (see the
|
||||
// spawn comment above).
|
||||
return Err(anyhow::anyhow!("read download timed out"));
|
||||
}
|
||||
};
|
||||
|
||||
let guard = match self.try_lock_repo(owner_did, repo_name).await {
|
||||
Ok(Some(g)) => g,
|
||||
Ok(None) => {
|
||||
debug!(repo = %repo_name,
|
||||
"read download discarded — repo lock held by a live writer or purge");
|
||||
return Ok(DownloadOutcome::Skipped);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(repo = %repo_name, err = %e,
|
||||
"read download discarded — could not acquire repo lock");
|
||||
return Ok(DownloadOutcome::Skipped);
|
||||
}
|
||||
};
|
||||
// Re-check under the lock: the archive gone here means a purge won the
|
||||
// key mid-download — discard, never publish (the read-side twin of the
|
||||
// upload paths' under-lock dir re-check). Bounded so a stalled HEAD
|
||||
// cannot pin the advisory lock + its lock-pool connection. Both a
|
||||
// timeout and an exists() error collapse to "not present" and take the
|
||||
// discard arm: fail closed rather than publish unconfirmed state.
|
||||
let archive_present = matches!(
|
||||
tokio::time::timeout(
|
||||
self.release_upload_timeout,
|
||||
store.exists(owner_slug, repo_name),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(true))
|
||||
);
|
||||
if !archive_present {
|
||||
warn!(repo = %repo_name,
|
||||
"read download discarded — archive gone under lock (purged?)");
|
||||
guard.release().await;
|
||||
return Ok(DownloadOutcome::Skipped);
|
||||
}
|
||||
|
||||
// Stale-swap guard (finding 2): a writer that locked, published a fresher
|
||||
// copy, and released during our unlocked download window must not have
|
||||
// its work clobbered by our now-stale temp. The only concurrent mutator
|
||||
// of local_path is a writer or a purge (readers of the same repo
|
||||
// serialize on the download mutex), so the signals below unambiguously
|
||||
// mean "a writer published here."
|
||||
let writer_published = match (local_existed_at_entry, entry_mtime) {
|
||||
// acquire_fresh over an existing copy: presence is always true, so it
|
||||
// cannot signal a writer; a CHANGED mtime can (the writer replaced
|
||||
// the dir via rename, minting a fresh mtime).
|
||||
(true, Some(entry_m)) => std::fs::metadata(local_path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|now| now != entry_m)
|
||||
.unwrap_or(false),
|
||||
// Cold acquire, or acquire_fresh whose dir was absent at entry: the
|
||||
// dir being present now means a writer published it during the window.
|
||||
_ => local_path.exists(),
|
||||
};
|
||||
if writer_published {
|
||||
warn!(repo = %repo_name,
|
||||
"read download discarded: a writer published a fresher copy during the download window");
|
||||
guard.release().await;
|
||||
// temp guard's Drop removes the stale temp; serve the fresher local copy.
|
||||
return Ok(DownloadOutcome::Published);
|
||||
}
|
||||
|
||||
let swapped = (|| -> std::io::Result<()> {
|
||||
if local_path.exists() {
|
||||
std::fs::remove_dir_all(local_path)?;
|
||||
}
|
||||
std::fs::rename(temp.path(), local_path)
|
||||
})();
|
||||
guard.release().await;
|
||||
match swapped {
|
||||
Ok(()) => {
|
||||
temp.disarm(); // the rename consumed the temp; nothing to clean up
|
||||
Ok(DownloadOutcome::Published)
|
||||
}
|
||||
// temp guard's Drop removes the temp if the rename left it behind.
|
||||
Err(e) => Err(e).context("publishing downloaded repo into place"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,105 @@ pub fn list_refs(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
/// Read the object id a single ref currently points to. Returns None if the
|
||||
/// ref does not exist. Used to snapshot a ref's pre-write state so a failed
|
||||
/// durable upload can roll the local write back.
|
||||
pub fn ref_oid(repo_path: &Path, ref_name: &str) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--verify", ref_name])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git rev-parse")?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if oid.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(oid))
|
||||
}
|
||||
|
||||
/// Force a single ref back to a captured object id — rolls back a local write
|
||||
/// (e.g. a merge commit) whose durable upload failed, so a local-fast-path
|
||||
/// read does not serve the un-uploaded state. The now-dangling objects are
|
||||
/// harmless.
|
||||
pub fn set_ref(repo_path: &Path, ref_name: &str, oid: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.args(["update-ref", ref_name, oid])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git update-ref")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git update-ref failed: {stderr}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore a repo's refs to a previously captured snapshot: reset every
|
||||
/// snapshot ref to its captured object id, and delete any ref that exists now
|
||||
/// but was absent from the snapshot (created by the intervening write). Used
|
||||
/// to roll back a receive-pack whose durable upload failed, so the next
|
||||
/// local-fast-path read does not serve refs that never reached object storage.
|
||||
/// Best-effort per ref: an individual failure is collected and reported, but
|
||||
/// the remaining refs are still attempted.
|
||||
pub fn restore_refs(repo_path: &Path, snapshot: &[(String, String)]) -> Result<()> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
// Reset every snapshot ref to its captured oid (recreates refs the write
|
||||
// deleted, and rewinds refs the write advanced).
|
||||
for (ref_name, oid) in snapshot {
|
||||
if let Err(e) = set_ref(repo_path, ref_name, oid) {
|
||||
errors.push(format!("{ref_name}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete refs that exist now but were not in the snapshot (created by the
|
||||
// write being rolled back). A failure to LIST the current refs is collected
|
||||
// like any per-ref failure, never returned early: the snapshot-reset half
|
||||
// above already ran, and bailing here would discard its collected errors.
|
||||
let snapshot_names: HashSet<&str> = snapshot.iter().map(|(r, _)| r.as_str()).collect();
|
||||
match list_refs(repo_path) {
|
||||
Ok(current) => {
|
||||
for (ref_name, _) in ¤t {
|
||||
if !snapshot_names.contains(ref_name.as_str()) {
|
||||
match Command::new("git")
|
||||
.args(["update-ref", "-d", ref_name])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
errors.push(format!("{ref_name} (delete): {stderr}"));
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!(
|
||||
"{ref_name} (delete): failed to run git update-ref -d: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("(extra-ref sweep) listing current refs: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
bail!(
|
||||
"failed to restore {} ref(s): {}",
|
||||
errors.len(),
|
||||
errors.join("; ")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current HEAD commit hash of a repository.
|
||||
/// Returns None if the repo is empty (no commits yet).
|
||||
pub fn head_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
@@ -455,10 +554,176 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::branch_diff_names;
|
||||
use super::{branch_diff_names, list_refs, restore_refs};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn run_git(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
/// Bare repo with main at c2 and keeper at c2, whose "pre-push" state was
|
||||
/// main and keeper both at c1. Returns (workdir, baredir, bare_path, c1, c2).
|
||||
fn advanced_bare_repo() -> (
|
||||
tempfile::TempDir,
|
||||
tempfile::TempDir,
|
||||
std::path::PathBuf,
|
||||
String,
|
||||
String,
|
||||
) {
|
||||
let work = tempfile::TempDir::new().unwrap();
|
||||
run_git(work.path(), &["init", "-q", "-b", "main", "."]);
|
||||
run_git(work.path(), &["config", "user.email", "t@t"]);
|
||||
run_git(work.path(), &["config", "user.name", "t"]);
|
||||
std::fs::write(work.path().join("f.txt"), "one").unwrap();
|
||||
run_git(work.path(), &["add", "f.txt"]);
|
||||
run_git(work.path(), &["commit", "-qm", "c1"]);
|
||||
let c1 = run_git(work.path(), &["rev-parse", "HEAD"]);
|
||||
std::fs::write(work.path().join("f.txt"), "two").unwrap();
|
||||
run_git(work.path(), &["add", "f.txt"]);
|
||||
run_git(work.path(), &["commit", "-qm", "c2"]);
|
||||
let c2 = run_git(work.path(), &["rev-parse", "HEAD"]);
|
||||
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let bare = dir.path().join("repo.git");
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--bare",
|
||||
"-q",
|
||||
&work.path().to_string_lossy(),
|
||||
&bare.to_string_lossy(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "clone --bare failed");
|
||||
// "The push" advanced main to c2 (already there from the clone), moved
|
||||
// keeper c1 -> c2, and created feature at c2.
|
||||
run_git(&bare, &["update-ref", "refs/heads/keeper", &c2]);
|
||||
run_git(&bare, &["update-ref", "refs/heads/feature", &c2]);
|
||||
(work, dir, bare, c1, c2)
|
||||
}
|
||||
|
||||
fn sorted_refs(bare: &Path) -> Vec<(String, String)> {
|
||||
let mut refs = list_refs(bare).unwrap();
|
||||
refs.sort();
|
||||
refs
|
||||
}
|
||||
|
||||
/// A single unrestorable snapshot entry (invalid oid, `set_ref` fails) must
|
||||
/// not abort the restore: every OTHER snapshot ref is still reset and the
|
||||
/// push-created extra ref is still deleted, and the fn returns Err carrying
|
||||
/// the failure. RED with the reset loop reverted to `set_ref(..)?`: the
|
||||
/// broken entry is first in the snapshot, so an early return leaves keeper
|
||||
/// at c2 and feature alive.
|
||||
#[test]
|
||||
fn restore_refs_partial_failure_still_resets_rest_and_deletes_extras() {
|
||||
let (_work, _dir, bare, c1, c2) = advanced_bare_repo();
|
||||
|
||||
let snapshot = vec![
|
||||
(
|
||||
"refs/heads/broken".to_string(),
|
||||
"not-a-valid-oid".to_string(),
|
||||
),
|
||||
("refs/heads/main".to_string(), c1.clone()),
|
||||
("refs/heads/keeper".to_string(), c1.clone()),
|
||||
];
|
||||
let err = restore_refs(&bare, &snapshot).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("refs/heads/broken"),
|
||||
"the aggregated error names the failed ref: {msg}"
|
||||
);
|
||||
|
||||
let refs = sorted_refs(&bare);
|
||||
assert_eq!(
|
||||
refs,
|
||||
vec![
|
||||
("refs/heads/keeper".to_string(), c1.clone()),
|
||||
("refs/heads/main".to_string(), c1.clone()),
|
||||
],
|
||||
"keeper and main are reset to c1 and feature is deleted despite the \
|
||||
broken entry (c2 was {c2})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Happy path unchanged by the hardening: a fully valid snapshot restores
|
||||
/// exactly (refs rewound, created ref deleted) and returns Ok.
|
||||
#[test]
|
||||
fn restore_refs_happy_path_restores_snapshot_exactly() {
|
||||
let (_work, _dir, bare, c1, _c2) = advanced_bare_repo();
|
||||
|
||||
let snapshot = vec![
|
||||
("refs/heads/main".to_string(), c1.clone()),
|
||||
("refs/heads/keeper".to_string(), c1.clone()),
|
||||
];
|
||||
restore_refs(&bare, &snapshot).unwrap();
|
||||
|
||||
let refs = sorted_refs(&bare);
|
||||
assert_eq!(
|
||||
refs,
|
||||
vec![
|
||||
("refs/heads/keeper".to_string(), c1.clone()),
|
||||
("refs/heads/main".to_string(), c1),
|
||||
],
|
||||
"restore must reproduce the snapshot exactly"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the internal `list_refs` (extra-ref sweep) fails, the snapshot-reset
|
||||
/// half must still have run and its collected failures must surface in the
|
||||
/// aggregated error, not be discarded by an early `?` return. The repo is a
|
||||
/// real bare repo declaring repositoryformatversion=999, so both `set_ref`
|
||||
/// and `list_refs` fail deterministically. RED with the sweep reverted to
|
||||
/// `let current = list_refs(repo_path)?;`: the raw for-each-ref error
|
||||
/// propagates without the "failed to restore" aggregate or the ref name.
|
||||
#[test]
|
||||
fn restore_refs_aggregates_when_internal_list_refs_fails() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let bare = dir.path().join("repo.git");
|
||||
let out = Command::new("git")
|
||||
.args(["init", "--bare", "-q", &bare.to_string_lossy()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git init --bare failed");
|
||||
std::fs::write(
|
||||
bare.join("config"),
|
||||
"[core]\n\trepositoryformatversion = 999\n\tbare = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
list_refs(&bare).is_err(),
|
||||
"precondition: list_refs must fail on the corrupted repo"
|
||||
);
|
||||
|
||||
let snapshot = vec![("refs/heads/main".to_string(), "a".repeat(40))];
|
||||
let err = restore_refs(&bare, &snapshot).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("failed to restore"),
|
||||
"must return the aggregated error, not the raw list_refs error: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("refs/heads/main"),
|
||||
"the set_ref failure from the reset half must survive the sweep \
|
||||
failure: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("listing current refs"),
|
||||
"the sweep failure itself is also collected: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_diff_names_lists_changed_paths() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -11,6 +11,21 @@ use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Object storage for git bare-repo archives. The seam that lets `RepoStore`
|
||||
/// (and tests) depend on storage behavior without a live bucket. `TigrisClient`
|
||||
/// is the production implementation.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ObjectStore: Send + Sync {
|
||||
/// Whether an archive exists for this repo.
|
||||
async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool>;
|
||||
/// Upload a local bare repo directory as an archive.
|
||||
async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()>;
|
||||
/// Download and extract a repo archive to local disk.
|
||||
async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()>;
|
||||
/// Delete a repo archive.
|
||||
async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Wrapper around the S3 client with the configured bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct TigrisClient {
|
||||
@@ -35,9 +50,12 @@ impl TigrisClient {
|
||||
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
|
||||
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ObjectStore for TigrisClient {
|
||||
/// Check if a repo archive exists in Tigris.
|
||||
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
|
||||
async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
match self
|
||||
.s3
|
||||
@@ -59,7 +77,7 @@ impl TigrisClient {
|
||||
}
|
||||
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
|
||||
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
|
||||
async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
|
||||
|
||||
@@ -89,12 +107,7 @@ impl TigrisClient {
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract to local disk.
|
||||
pub async fn download(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
|
||||
|
||||
@@ -128,8 +141,7 @@ impl TigrisClient {
|
||||
}
|
||||
|
||||
/// Delete a repo archive from Tigris.
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
|
||||
async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
self.s3
|
||||
.delete_object()
|
||||
|
||||
@@ -73,9 +73,25 @@ impl QueryRoot {
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(updates
|
||||
// Resolve the trusted display owner_did per row, identical to the REST
|
||||
// feed: the stored wire value is untrusted, so it is echoed only when it
|
||||
// matches the canonical owner of the local repo the slug names (#P1);
|
||||
// legacy None rows are attributed via an exact unique local match (#P3).
|
||||
// The batch resolver issues at most one query per distinct local repo
|
||||
// rather than one per event row (#P2).
|
||||
let pairs: Vec<(&str, Option<&str>)> = updates
|
||||
.iter()
|
||||
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
|
||||
.collect();
|
||||
let owner_dids = db
|
||||
.resolve_ref_update_owner_dids(&pairs)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
let resolved: Vec<RefUpdateType> = updates
|
||||
.into_iter()
|
||||
.map(|u| RefUpdateType {
|
||||
.zip(owner_dids)
|
||||
.map(|(u, owner_did)| RefUpdateType {
|
||||
repo: u.repo,
|
||||
ref_name: u.ref_name,
|
||||
old_sha: u.old_sha,
|
||||
@@ -83,8 +99,10 @@ impl QueryRoot {
|
||||
pusher_did: u.pusher_did,
|
||||
node_did: u.node_did,
|
||||
timestamp: u.timestamp,
|
||||
owner_did,
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn tasks(
|
||||
@@ -162,6 +180,7 @@ mod tests {
|
||||
cert_id: None,
|
||||
received_at: Utc::now().to_rfc3339(),
|
||||
from_peer: "peer1".into(),
|
||||
owner_did: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,10 +264,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo refName } }"#;
|
||||
let q = r#"{ refUpdates { repo refName ownerDid } }"#;
|
||||
let resp = anon(&schema, q).await;
|
||||
assert_eq!(count(&resp), 1);
|
||||
// The one row returned must be the public repo's.
|
||||
// The one row returned must be the public repo's with owner_did echoed.
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -262,6 +281,11 @@ mod tests {
|
||||
row.get("repo").unwrap(),
|
||||
&async_graphql::Value::from("z6MkOwner/openrepo")
|
||||
);
|
||||
assert_eq!(
|
||||
row.get("ownerDid").unwrap(),
|
||||
&async_graphql::Value::from(OWNER),
|
||||
"ownerDid must fall back to the local record's owner for legacy rows"
|
||||
);
|
||||
}
|
||||
|
||||
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
|
||||
|
||||
@@ -39,6 +39,7 @@ impl SubscriptionRoot {
|
||||
pusher_did: ev.pusher_did,
|
||||
node_did: ev.node_did,
|
||||
timestamp: ev.timestamp,
|
||||
owner_did: Some(ev.owner_did),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ pub struct RefUpdateType {
|
||||
pub pusher_did: String,
|
||||
pub node_did: String,
|
||||
pub timestamp: String,
|
||||
pub owner_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
|
||||
+179
-36
@@ -1,3 +1,4 @@
|
||||
mod admin;
|
||||
mod api;
|
||||
mod arweave;
|
||||
mod auth;
|
||||
@@ -70,6 +71,12 @@ async fn main() -> Result<()> {
|
||||
|
||||
let mut config = Config::parse();
|
||||
|
||||
// Admin subcommands run out-of-band and exit, never starting the node. The
|
||||
// no-subcommand path below is the unchanged daemon startup.
|
||||
if let Some(command) = config.command.clone() {
|
||||
return run_admin_command(command, &config).await;
|
||||
}
|
||||
|
||||
// Merge the embedded seed list of public network nodes into the runtime
|
||||
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
|
||||
bootstrap::merge_seeds(&mut config);
|
||||
@@ -279,8 +286,22 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let object_store =
|
||||
tigris.map(|t| std::sync::Arc::new(t) as std::sync::Arc<dyn git::tigris::ObjectStore>);
|
||||
// Advisory-lock guards get their own pool, separate from the app pool
|
||||
// (db.pool()) that serves normal handlers, so a concurrent-push burst pins
|
||||
// lock connections here instead of starving request handlers. Sized to peak
|
||||
// writers via GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS.
|
||||
let lock_pool_acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
|
||||
let lock_pool = Db::connect_lock_pool(
|
||||
&config.database_url,
|
||||
config.db_lock_pool_max_connections,
|
||||
lock_pool_acquire_timeout,
|
||||
)
|
||||
.await
|
||||
.context("connecting advisory-lock pool")?;
|
||||
let repo_store =
|
||||
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
|
||||
git::repo_store::RepoStore::new(config.repos_dir.clone(), object_store, lock_pool);
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
@@ -294,36 +315,24 @@ async fn main() -> Result<()> {
|
||||
// capped regardless of how many identities it mints. Sized well above any
|
||||
// legitimate per-IP creation rate; GITLAWB_CREATE_RATE_LIMIT overrides, 0
|
||||
// disables. Bounded key set — the key is a client-influenced IP.
|
||||
let create_limit = std::env::var("GITLAWB_CREATE_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(120);
|
||||
let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
create_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if create_limit == 0 {
|
||||
tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
|
||||
}
|
||||
let create_ip_rate_limiter = build_ip_limiter("GITLAWB_CREATE_RATE_LIMIT", 120, "creation");
|
||||
|
||||
// Per-IP brake for the authenticated non-creation write surface (issue/PR
|
||||
// comments, labels, stars, merges, protect, replicas, visibility, tasks,
|
||||
// bounties, profile, GraphQL mutations). Its own bucket, separate from the
|
||||
// creation brake, so a write flood cannot drain the creation budget and vice
|
||||
// versa (same rationale as the sync_trigger / peer_write split). Sized above
|
||||
// any legitimate per-IP write rate — real agent automation makes many small
|
||||
// writes per hour. GITLAWB_WRITE_RATE_LIMIT overrides; 0 disables. Bounded
|
||||
// key set — the key is a client-influenced IP.
|
||||
let write_rate_limiter = build_ip_limiter("GITLAWB_WRITE_RATE_LIMIT", 600, "write");
|
||||
|
||||
// Push-path flood brake: max git-receive-pack requests per client IP per
|
||||
// hour (counts both the info/refs advertisement and the push POST). Sized
|
||||
// for heavy agent automation while still stopping flood traffic (the June
|
||||
// 2026 attack pushed several times per second per IP). GITLAWB_PUSH_RATE_LIMIT
|
||||
// overrides; 0 disables. Bounded key set — the key is a client-influenced IP.
|
||||
let push_limit = std::env::var("GITLAWB_PUSH_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(600);
|
||||
let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
push_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if push_limit == 0 {
|
||||
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
|
||||
}
|
||||
let push_rate_limiter = build_ip_limiter("GITLAWB_PUSH_RATE_LIMIT", 600, "push");
|
||||
|
||||
// Which forwarded header the edge is trusted to set. Default None (trust
|
||||
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
|
||||
@@ -331,7 +340,7 @@ async fn main() -> Result<()> {
|
||||
let push_limiter_trust = rate_limit::TrustedProxy::from_env_value(
|
||||
&std::env::var("GITLAWB_TRUSTED_PROXY").unwrap_or_default(),
|
||||
);
|
||||
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
|
||||
tracing::info!(trust = ?push_limiter_trust, "push rate limiter trust configured");
|
||||
|
||||
// Peer-sync flood brakes, keyed on the resolved client IP (per-DID is useless
|
||||
// here — a did:key farm self-registers). Two buckets so an unsigned notify
|
||||
@@ -373,6 +382,7 @@ async fn main() -> Result<()> {
|
||||
repo_store,
|
||||
rate_limiter,
|
||||
create_ip_rate_limiter,
|
||||
write_rate_limiter,
|
||||
push_rate_limiter,
|
||||
push_limiter_trust,
|
||||
sync_trigger_rate_limiter,
|
||||
@@ -408,22 +418,17 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let rl = state.rate_limiter.clone();
|
||||
let create_ip_rl = state.create_ip_rate_limiter.clone();
|
||||
let push_rl = state.push_rate_limiter.clone();
|
||||
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
|
||||
let peer_write_rl = state.peer_write_rate_limiter.clone();
|
||||
// Clone the whole state so the sweep uses AppState::cleanup_rate_limiters
|
||||
// (the single source of truth that reaps EVERY limiter, including
|
||||
// write_rate_limiter) rather than a hand-maintained list that can omit one.
|
||||
let state = state.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
rl.cleanup().await;
|
||||
create_ip_rl.cleanup().await;
|
||||
push_rl.cleanup().await;
|
||||
sync_trigger_rl.cleanup().await;
|
||||
peer_write_rl.cleanup().await;
|
||||
state.cleanup_rate_limiters().await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
@@ -564,6 +569,110 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pure resolver for a usize rate-limit env value. Returns `(resolved, invalid)`:
|
||||
/// an absent or empty value resolves to `default` and is NOT invalid; a non-empty
|
||||
/// value that fails to parse resolves to `default` and IS invalid (so the caller
|
||||
/// warns rather than silently disabling the intended, usually stricter, brake).
|
||||
fn resolve_rate_limit(raw: Option<&str>, default: usize) -> (usize, bool) {
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") => (default, false),
|
||||
Some(t) => match t.parse::<usize>() {
|
||||
Ok(n) => (n, false),
|
||||
Err(_) => (default, true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a rate-limit env var, warning on a present-but-unparseable value. A typo
|
||||
/// like `GITLAWB_WRITE_RATE_LIMIT=5O` must not silently revert to the default and
|
||||
/// leave the operator believing a stricter cap is in force.
|
||||
fn rate_limit_from_env(var: &str, default: usize) -> usize {
|
||||
let raw = std::env::var(var).ok();
|
||||
let (value, invalid) = resolve_rate_limit(raw.as_deref(), default);
|
||||
if invalid {
|
||||
tracing::warn!(
|
||||
var,
|
||||
value = raw.as_deref().unwrap_or(""),
|
||||
default,
|
||||
"invalid rate-limit value — using default; the intended brake is NOT applied"
|
||||
);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Build a bounded per-client-IP rate limiter from an env var: resolve the limit
|
||||
/// (honoring the present-but-unparseable warning in `rate_limit_from_env`), build
|
||||
/// the limiter with the shared 1-hour window and 200k-key cap, and warn when the
|
||||
/// limit is 0 (disabled). Collapses the identical create/write/push setup.
|
||||
fn build_ip_limiter(var: &str, default: usize, label: &str) -> rate_limit::RateLimiter {
|
||||
let limit = rate_limit_from_env(var, default);
|
||||
let limiter =
|
||||
rate_limit::RateLimiter::new_bounded(limit, std::time::Duration::from_secs(3600), 200_000);
|
||||
if limit == 0 {
|
||||
tracing::warn!("{var}=0 — per-IP {label} rate limiting disabled");
|
||||
} else {
|
||||
tracing::info!(%var, limit, "per-IP {label} rate limiter configured");
|
||||
}
|
||||
limiter
|
||||
}
|
||||
|
||||
/// Dispatch an admin subcommand. Connects the database directly (no server, no
|
||||
/// p2p, no metrics) and runs the requested tool to completion, then exits.
|
||||
async fn run_admin_command(command: config::Command, config: &Config) -> Result<()> {
|
||||
match command {
|
||||
config::Command::PurgeSpam { execute } => {
|
||||
let acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
|
||||
let db = Db::connect(
|
||||
&config.database_url,
|
||||
config.db_max_connections,
|
||||
acquire_timeout,
|
||||
)
|
||||
.await
|
||||
.context("connecting to database for purge-spam")?;
|
||||
// Build the object store so purge is authoritative against remote
|
||||
// state: the emptiness recheck consults the archive (not just
|
||||
// possibly-stale local disk) and a successful purge deletes the
|
||||
// archive too. When no bucket is configured this stays None and purge
|
||||
// is local-only, exactly as before.
|
||||
let object_store: Option<std::sync::Arc<dyn git::tigris::ObjectStore>> = if config
|
||||
.tigris_bucket
|
||||
.is_empty()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
match git::tigris::TigrisClient::new(&config.tigris_bucket).await {
|
||||
Ok(client) => Some(std::sync::Arc::new(client)),
|
||||
Err(e) => {
|
||||
// Fail closed: without the configured store we cannot
|
||||
// do the authoritative recheck, and a local-only purge
|
||||
// on a Tigris deployment is the stale-view delete this
|
||||
// guards against. Refuse to run rather than fall back.
|
||||
anyhow::bail!(
|
||||
"purge-spam: GITLAWB_TIGRIS_BUCKET is set but the object-store client failed to initialize ({e}); refusing to run a local-only purge on a Tigris deployment"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Give the purge-spam store a dedicated advisory-lock pool, separate
|
||||
// from `db`'s app pool. The guard then holds a lock-pool connection
|
||||
// while `delete_repo_by_id` runs on the app pool, so a purge at
|
||||
// GITLAWB_DB_MAX_CONNECTIONS=1 does not self-deadlock (KTD3).
|
||||
let lock_pool = Db::connect_lock_pool(
|
||||
&config.database_url,
|
||||
config.db_lock_pool_max_connections,
|
||||
acquire_timeout,
|
||||
)
|
||||
.await
|
||||
.context("connecting advisory-lock pool for purge-spam")?;
|
||||
let repo_store =
|
||||
git::repo_store::RepoStore::new(config.repos_dir.clone(), object_store, lock_pool);
|
||||
admin::run_purge_spam(&db, &repo_store, &config.repos_dir, execute)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
@@ -1024,6 +1133,40 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rate_limit_env_tests {
|
||||
use super::resolve_rate_limit;
|
||||
|
||||
#[test]
|
||||
fn absent_uses_default_without_warning() {
|
||||
assert_eq!(resolve_rate_limit(None, 600), (600, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_whitespace_uses_default_without_warning() {
|
||||
assert_eq!(resolve_rate_limit(Some(""), 600), (600, false));
|
||||
assert_eq!(resolve_rate_limit(Some(" "), 600), (600, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_value_parses() {
|
||||
assert_eq!(resolve_rate_limit(Some("50"), 600), (50, false));
|
||||
assert_eq!(resolve_rate_limit(Some(" 50 "), 600), (50, false));
|
||||
// 0 is a VALID value (disables the brake); the ==0 warning is handled
|
||||
// separately at the call site. It must NOT be flagged invalid here.
|
||||
assert_eq!(resolve_rate_limit(Some("0"), 600), (0, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_but_unparseable_defaults_and_flags_invalid() {
|
||||
// The L8 case: a fat-fingered stricter cap ("5O", letter O) must default
|
||||
// AND be flagged so the caller warns, not silently disable the brake.
|
||||
assert_eq!(resolve_rate_limit(Some("5O"), 600), (600, true));
|
||||
assert_eq!(resolve_rate_limit(Some("abc"), 600), (600, true));
|
||||
assert_eq!(resolve_rate_limit(Some("-1"), 600), (600, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::ping_peer_health;
|
||||
|
||||
@@ -39,6 +39,11 @@ pub struct RefUpdateEvent {
|
||||
pub pusher_did: String,
|
||||
/// Repository identifier (owner/name)
|
||||
pub repo: String,
|
||||
/// Full owner DID — added in #144 for display and storage; not yet
|
||||
/// wired into the feed gate matcher. Optional for backward compat with
|
||||
/// older peers that don't include it.
|
||||
#[serde(default)]
|
||||
pub owner_did: Option<String>,
|
||||
/// Git ref that changed (e.g., "refs/heads/main")
|
||||
pub ref_name: String,
|
||||
/// SHA before the push (all-zeros for new ref)
|
||||
@@ -307,6 +312,7 @@ pub async fn start(
|
||||
node_did: event.node_did.clone(),
|
||||
pusher_did: event.pusher_did.clone(),
|
||||
repo: event.repo.clone(),
|
||||
owner_did: event.owner_did.clone(),
|
||||
ref_name: event.ref_name.clone(),
|
||||
old_sha: event.old_sha.clone(),
|
||||
new_sha: event.new_sha.clone(),
|
||||
@@ -432,3 +438,67 @@ pub async fn start(
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ref_update_event_round_trip_with_owner_did() {
|
||||
let event = RefUpdateEvent {
|
||||
node_did: "did:key:zNode".into(),
|
||||
pusher_did: "did:key:zPusher".into(),
|
||||
repo: "zOwner/myrepo".into(),
|
||||
owner_did: Some("did:key:zOwner".into()),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000".into(),
|
||||
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
|
||||
timestamp: "2026-07-02T12:00:00Z".into(),
|
||||
cert_id: None,
|
||||
cid: None,
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
// owner_did must be present in the serialized output
|
||||
assert_eq!(json["owner_did"], "did:key:zOwner");
|
||||
assert_eq!(json["repo"], "zOwner/myrepo");
|
||||
|
||||
let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_update_event_backward_compat_no_owner_did() {
|
||||
let old_json = serde_json::json!({
|
||||
"node_did": "did:key:zNode",
|
||||
"pusher_did": "did:key:zPusher",
|
||||
"repo": "zOwner/myrepo",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0000000000000000000000000000000000000000",
|
||||
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"timestamp": "2026-07-02T12:00:00Z",
|
||||
"cert_id": null,
|
||||
"cid": null
|
||||
});
|
||||
let deserialized: RefUpdateEvent = serde_json::from_value(old_json).unwrap();
|
||||
assert_eq!(deserialized.owner_did, None);
|
||||
assert_eq!(deserialized.repo, "zOwner/myrepo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_update_event_backward_compat_null_owner_did() {
|
||||
let with_null = serde_json::json!({
|
||||
"node_did": "did:key:zNode",
|
||||
"pusher_did": "did:key:zPusher",
|
||||
"repo": "zOwner/myrepo",
|
||||
"owner_did": null,
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0000000000000000000000000000000000000000",
|
||||
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"timestamp": "2026-07-02T12:00:00Z",
|
||||
"cert_id": null,
|
||||
"cid": null
|
||||
});
|
||||
let deserialized: RefUpdateEvent = serde_json::from_value(with_null).unwrap();
|
||||
assert_eq!(deserialized.owner_did, None);
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,23 @@ impl RateLimiter {
|
||||
self.window.min(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
/// Returns `true` if the request is allowed. Thin wrapper over
|
||||
/// [`check_retry`](Self::check_retry) for callers that only need the
|
||||
/// allow/deny decision, not the rejection's Retry-After delay. Test-only now:
|
||||
/// production 429 sites use `check_retry` so they can advertise Retry-After.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn check(&self, key: &str) -> bool {
|
||||
self.check_retry(key).await.is_none()
|
||||
}
|
||||
|
||||
/// Record a request against `key`. Returns `None` when it is allowed, or
|
||||
/// `Some(retry_after)` when it is rejected — the delay a client should wait
|
||||
/// before retrying, so a 429 can advertise a value consistent with the
|
||||
/// actual window instead of a constant.
|
||||
pub(crate) async fn check_retry(&self, key: &str) -> Option<Duration> {
|
||||
// max_requests == 0 means the limiter is disabled, not "block all".
|
||||
if self.max_requests == 0 {
|
||||
return true;
|
||||
return None;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -85,10 +98,25 @@ impl RateLimiter {
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
if window.timestamps.len() >= self.max_requests {
|
||||
return false;
|
||||
// Insertion order is preserved, so after the retain the first
|
||||
// element is the oldest live request. It ages out (freeing a
|
||||
// slot) one window after it landed, so that remaining time is
|
||||
// the delay to advertise. Clamp to >= 1 so a 429 never tells a
|
||||
// client to retry immediately.
|
||||
let retry = window
|
||||
.timestamps
|
||||
.first()
|
||||
.map(|oldest| {
|
||||
self.window
|
||||
.as_secs()
|
||||
.saturating_sub(now.duration_since(*oldest).as_secs())
|
||||
})
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
return Some(Duration::from_secs(retry));
|
||||
}
|
||||
window.timestamps.push(now);
|
||||
return true;
|
||||
return None;
|
||||
}
|
||||
|
||||
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
|
||||
@@ -109,7 +137,11 @@ impl RateLimiter {
|
||||
}
|
||||
drop(last_sweep);
|
||||
if state.len() >= self.max_keys {
|
||||
return false;
|
||||
// The key is untracked (never inserted), so there is no oldest
|
||||
// entry to derive a delay from. Advertise the full window as a
|
||||
// conservative bound: capacity frees as other keys expire, at
|
||||
// the latest one window from now.
|
||||
return Some(self.window.max(Duration::from_secs(1)));
|
||||
}
|
||||
}
|
||||
state.insert(
|
||||
@@ -118,7 +150,7 @@ impl RateLimiter {
|
||||
timestamps: vec![now],
|
||||
},
|
||||
);
|
||||
true
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) {
|
||||
@@ -130,6 +162,41 @@ impl RateLimiter {
|
||||
!w.timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
/// Test-only: insert a fully-expired entry (no live timestamps) that the next
|
||||
/// `cleanup()` must reclaim. Models a key whose window has aged out, without
|
||||
/// depending on wall-clock sleeps or a short window. Used cross-module by the
|
||||
/// AppState cleanup guard test (H2).
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn insert_reclaimable_for_test(&self, key: &str) {
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.insert(key.to_string(), Window { timestamps: vec![] });
|
||||
}
|
||||
|
||||
/// Test-only: number of distinct keys currently tracked.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn tracked_keys(&self) -> usize {
|
||||
self.state.lock().await.len()
|
||||
}
|
||||
|
||||
/// Test-only: seed a key's bucket with entries of the given ages (how long
|
||||
/// ago each request landed), so a test can drive the Retry-After computation
|
||||
/// off a controlled oldest-entry age without wall-clock sleeps. Ages that
|
||||
/// would underflow the monotonic clock collapse to `now`.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn seed_aged_entry_for_test(&self, key: &str, ages: &[Duration]) {
|
||||
let now = Instant::now();
|
||||
let timestamps = ages
|
||||
.iter()
|
||||
.map(|a| now.checked_sub(*a).unwrap_or(now))
|
||||
.collect();
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.insert(key.to_string(), Window { timestamps });
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
@@ -141,13 +208,8 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
.map(|a| a.0.clone());
|
||||
|
||||
if let (Some(limiter), Some(did)) = (limiter, did) {
|
||||
if !limiter.check(&did).await {
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response();
|
||||
if let Some(retry_after) = limiter.check_retry(&did).await {
|
||||
return too_many_requests(retry_after);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,13 +312,13 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
|
||||
/// middleware now serves the push path AND the peer-sync routes, so the message
|
||||
/// stays generic (the offending path is recorded in the warn log below).
|
||||
pub fn too_many_requests() -> Response {
|
||||
/// The shared 429 response for the rate-limit middlewares (per-DID and per-IP
|
||||
/// alike). Route-agnostic: the message stays generic (the offending path, when
|
||||
/// logged, is recorded at the call site).
|
||||
pub fn too_many_requests(retry_after: Duration) -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
[("retry-after", retry_after.as_secs().to_string())],
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response()
|
||||
@@ -274,9 +336,9 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
|
||||
if let Some(limiter) = limiter {
|
||||
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
|
||||
if !limiter.limiter.check(&key).await {
|
||||
if let Some(retry_after) = limiter.limiter.check_retry(&key).await {
|
||||
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
|
||||
return too_many_requests();
|
||||
return too_many_requests(retry_after);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -605,4 +667,196 @@ mod tests {
|
||||
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Adversarial TrustedProxy verification through the middleware (U5) ──
|
||||
// These complement the client_key unit tests above by driving hostile
|
||||
// headers through rate_limit_by_ip on a real router: the property under test
|
||||
// is that the *bucket key* cannot be rotated by a spoofer.
|
||||
|
||||
async fn post_with(
|
||||
router: &axum::Router,
|
||||
peer: SocketAddr,
|
||||
hdrs: &[(&str, &str)],
|
||||
) -> StatusCode {
|
||||
use tower::ServiceExt;
|
||||
let mut b = axum::http::Request::builder()
|
||||
.method(axum::http::Method::POST)
|
||||
.uri("/api/v1/repos");
|
||||
for (k, v) in hdrs {
|
||||
b = b.header(*k, *v);
|
||||
}
|
||||
let mut req = b.body(axum::body::Body::empty()).unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
router.clone().oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xff_spoofed_leftmost_hop_cannot_rotate_bucket() {
|
||||
// XForwardedFor mode, budget 1. A spoofer varies the client-controlled
|
||||
// leftmost hop every request but the trusted proxy always appends the
|
||||
// same rightmost hop. All requests must key to the rightmost hop, so the
|
||||
// second is braked despite a fresh leftmost value.
|
||||
let router = ip_limited_over_auth_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::XForwardedFor,
|
||||
});
|
||||
let p1: SocketAddr = "10.0.0.1:1".parse().unwrap();
|
||||
let p2: SocketAddr = "10.0.0.2:1".parse().unwrap();
|
||||
// First request passes the brake, reaches (failing) auth.
|
||||
assert_eq!(
|
||||
post_with(&router, p1, &[("x-forwarded-for", "9.9.9.1, 203.0.113.5")]).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Different leftmost + different socket peer, SAME rightmost trusted hop:
|
||||
// braked, because the key is the rightmost hop, not the spoofed value.
|
||||
assert_eq!(
|
||||
post_with(&router, p2, &[("x-forwarded-for", "9.9.9.2, 203.0.113.5")]).await,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"a spoofer varying the leftmost XFF hop must not rotate its bucket key"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xff_distinct_real_clients_get_distinct_buckets() {
|
||||
// Distinct trusted (rightmost) hops are distinct clients: neither throttles
|
||||
// the other, so a busy legitimate client cannot starve a different one.
|
||||
let router = ip_limited_over_auth_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::XForwardedFor,
|
||||
});
|
||||
let peer: SocketAddr = "10.0.0.9:1".parse().unwrap();
|
||||
assert_eq!(
|
||||
post_with(
|
||||
&router,
|
||||
peer,
|
||||
&[("x-forwarded-for", "1.1.1.1, 203.0.113.5")]
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
post_with(
|
||||
&router,
|
||||
peer,
|
||||
&[("x-forwarded-for", "1.1.1.1, 203.0.113.6")]
|
||||
)
|
||||
.await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a different trusted client hop must get its own bucket"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_trusted_header_collapses_to_socket_peer() {
|
||||
// The documented fallback: in a trusted-proxy mode, a request with no
|
||||
// forwarded header keys on the socket peer. Behind a real edge every
|
||||
// request shares the proxy's socket, so they collapse onto one bucket —
|
||||
// asserted here by name so any change to this behavior is deliberate.
|
||||
let router = ip_limited_over_auth_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::XForwardedFor,
|
||||
});
|
||||
let proxy: SocketAddr = "172.16.0.1:1".parse().unwrap();
|
||||
assert_eq!(
|
||||
post_with(&router, proxy, &[]).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
post_with(&router, proxy, &[]).await,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"with no trusted header, requests fall back to the socket peer key (collapse)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Retry-After derived from the window, not a constant 60 (U5) ───────
|
||||
// A 429 must advertise the delay until the oldest live request ages out of
|
||||
// the limiter's window, so the value tracks the real window instead of the
|
||||
// hardcoded 60 that contradicted every limiter's 3600s window.
|
||||
|
||||
async fn resp_from(router: &axum::Router, peer: SocketAddr) -> Response {
|
||||
use tower::ServiceExt;
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method(axum::http::Method::POST)
|
||||
.uri("/o/r/git-receive-pack")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
fn retry_after_secs(resp: &Response) -> u64 {
|
||||
resp.headers()
|
||||
.get("retry-after")
|
||||
.expect("429 must carry a retry-after header")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.expect("retry-after must be an integer number of seconds")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_reflects_full_window_for_freshly_filled_bucket() {
|
||||
// A just-filled bucket's oldest entry is ~now, so the advertised delay
|
||||
// must be close to the whole window (100s), NOT the old constant 60.
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(100)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let peer: SocketAddr = "203.0.113.50:6000".parse().unwrap();
|
||||
assert_eq!(resp_from(&router, peer).await.status(), StatusCode::OK);
|
||||
let resp = resp_from(&router, peer).await;
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let retry = retry_after_secs(&resp);
|
||||
assert!(
|
||||
(95..=100).contains(&retry),
|
||||
"freshly-filled bucket must advertise ~window (95..=100s), got {retry} (constant-60 bug if 60)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_shrinks_as_oldest_entry_ages() {
|
||||
// Seed the sole slot with an entry already 95s into a 100s window: the
|
||||
// next request is rejected and must advertise only the ~5s remaining,
|
||||
// not 60.
|
||||
let limiter = RateLimiter::new(1, Duration::from_secs(100));
|
||||
let peer: SocketAddr = "203.0.113.51:6000".parse().unwrap();
|
||||
limiter
|
||||
.seed_aged_entry_for_test(&peer.ip().to_string(), &[Duration::from_secs(95)])
|
||||
.await;
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter,
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let resp = resp_from(&router, peer).await;
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let retry = retry_after_secs(&resp);
|
||||
assert!(
|
||||
(1..=10).contains(&retry),
|
||||
"an aged bucket must advertise the small remaining delay (1..=10s), got {retry}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_clamps_to_at_least_one_second() {
|
||||
// Oldest entry is 99s into a 100s window: the raw remainder rounds toward
|
||||
// 1s and must never be advertised as 0 (a 0/blank retry lets a rejected
|
||||
// client hammer immediately).
|
||||
let limiter = RateLimiter::new(1, Duration::from_secs(100));
|
||||
let peer: SocketAddr = "203.0.113.52:6000".parse().unwrap();
|
||||
limiter
|
||||
.seed_aged_entry_for_test(&peer.ip().to_string(), &[Duration::from_secs(99)])
|
||||
.await;
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter,
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let resp = resp_from(&router, peer).await;
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let retry = retry_after_secs(&resp);
|
||||
assert!(retry >= 1, "retry-after must clamp to >= 1, got {retry}");
|
||||
assert!(
|
||||
retry <= 2,
|
||||
"with 99s of a 100s window elapsed, the remaining delay is ~1s, got {retry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,18 +55,50 @@ fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState
|
||||
.layer(middleware::from_fn(auth::require_signature))
|
||||
}
|
||||
|
||||
/// Attach the shared per-IP write brake to an authenticated write-route group,
|
||||
/// outermost so a flood is rejected before signature verification. Every
|
||||
/// authenticated non-creation write group MUST call this or it silently ships
|
||||
/// unbraked with no compile error. Deliberately a separate call, NOT folded into
|
||||
/// `add_auth_layers`: the creation routes use a different bucket
|
||||
/// (`create_ip_rate_limiter`) and the graphql group is not built through
|
||||
/// `add_auth_layers`, so a helper keyed on auth would attach the wrong limiter.
|
||||
trait WriteBraked {
|
||||
fn write_braked(self, limiter: &rate_limit::IpRateLimiter) -> Self;
|
||||
}
|
||||
|
||||
impl WriteBraked for Router<AppState> {
|
||||
fn write_braked(self, limiter: &rate_limit::IpRateLimiter) -> Self {
|
||||
self.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(limiter.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
// ── GraphQL routes ─────────────────────────────────────────────────────
|
||||
let schema = state.graphql_schema.as_ref().clone();
|
||||
|
||||
// Per-IP write brake, shared across every authenticated non-creation write
|
||||
// group (see `AppState::write_rate_limiter`). Built once here so each group
|
||||
// clones the same bucket; attached outermost on each group so a flood is
|
||||
// rejected before auth runs. Separate bucket from the creation brake.
|
||||
let write_ip_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.write_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
|
||||
let graphql_routes = Router::new()
|
||||
.route("/graphql", get(graphql_playground).post(graphql_handler))
|
||||
// Attach the verified DID to /graphql when a signature is present. The
|
||||
// layer covers only routes added before it, so /graphql/ws (added after,
|
||||
// read-only subscriptions) stays open.
|
||||
// read-only subscriptions) stays open. The per-IP write brake covers the
|
||||
// same /graphql (KTD-5: an HTTP-layer brake cannot tell a query POST from
|
||||
// a mutation POST, so it counts every /graphql request — acceptable, both
|
||||
// cost work); /graphql/ws subscriptions stay unbraked.
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.write_braked(&write_ip_limiter)
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
// ── Task routes (write — require HTTP Signature + per-IP write brake) ──
|
||||
let task_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route("/api/v1/tasks", post(tasks::create_task))
|
||||
@@ -74,7 +106,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
|
||||
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.write_braked(&write_ip_limiter);
|
||||
|
||||
// ── Task routes (read — open) ──────────────────────────────────────────
|
||||
let task_read_routes = Router::new()
|
||||
@@ -111,7 +144,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(create_ip_limiter));
|
||||
|
||||
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
|
||||
// ── Write routes — require HTTP Signature + per-IP write brake ────────
|
||||
// Same per-IP flood defense as the creation routes, on its own `write`
|
||||
// bucket (see `AppState::write_rate_limiter`). Per-DID is deliberately not
|
||||
// paired (a DID farm never trips it; busy legitimate agents would false-
|
||||
// positive). The bundled visibility GET is a read that also draws from the
|
||||
// write bucket — harmless, since the bucket is sized far above any legit
|
||||
// per-IP read rate.
|
||||
let write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -181,7 +220,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
axum::routing::delete(agents::deregister_agent),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.write_braked(&write_ip_limiter);
|
||||
|
||||
// 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.
|
||||
@@ -247,7 +287,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(bounties::dispute_bounty),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.write_braked(&write_ip_limiter);
|
||||
|
||||
// ── Bounty routes (read — open) ──────────────────────────────────────
|
||||
let bounty_read_routes = Router::new()
|
||||
@@ -268,9 +309,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let profile_write_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.write_braked(&write_ip_limiter);
|
||||
|
||||
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
|
||||
// ── Issue routes (write — require HTTP Signature + per-IP write brake) ─
|
||||
let issue_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -282,7 +324,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
post(issues::create_issue_comment),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
)
|
||||
.write_braked(&write_ip_limiter);
|
||||
|
||||
// ── Peer discovery routes ─────────────────────────────────────────────
|
||||
// Peer writes accept signatures when present and can require them after a
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct RefUpdateBroadcast {
|
||||
pub pusher_did: String,
|
||||
pub node_did: String,
|
||||
pub timestamp: String,
|
||||
pub owner_did: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -61,6 +62,16 @@ pub struct AppState {
|
||||
/// resolved client IP is what actually stops a single-source flood (same
|
||||
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
|
||||
pub create_ip_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for the authenticated write surface that is
|
||||
/// not repo/agent creation: issue/PR comments, labels, stars, merges,
|
||||
/// protect/unprotect, replicas, visibility, tasks, bounties, profile, and
|
||||
/// the GraphQL `MutationRoot`. Separate bucket from `create_ip_rate_limiter`
|
||||
/// (its own budget, per the `sync_trigger`/`peer_write` precedent) so a
|
||||
/// write flood cannot drain the creation budget and vice versa. Per-DID is
|
||||
/// deliberately NOT paired here: a DID farm never trips it, and busy
|
||||
/// legitimate agents would false-positive. `GITLAWB_WRITE_RATE_LIMIT`
|
||||
/// overrides the default, 0 disables. Keyed by `push_limiter_trust`.
|
||||
pub write_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
@@ -117,4 +128,48 @@ impl AppState {
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
*self.shutdown_tx.borrow()
|
||||
}
|
||||
|
||||
/// Reclaim expired entries from EVERY rate limiter. Single source of truth for
|
||||
/// the periodic cleanup loop (main.rs), co-located with the limiter fields so a
|
||||
/// newly-added limiter is cleaned here rather than silently omitted from a
|
||||
/// hand-maintained list in main() (H2: `write_rate_limiter` was the omitted
|
||||
/// one). Every limiter field above MUST be swept here; the guard test
|
||||
/// `cleanup_rate_limiters_reaps_the_write_limiter` fails if it is not.
|
||||
pub(crate) async fn cleanup_rate_limiters(&self) {
|
||||
self.rate_limiter.cleanup().await;
|
||||
self.create_ip_rate_limiter.cleanup().await;
|
||||
self.write_rate_limiter.cleanup().await;
|
||||
self.push_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
self.peer_write_rate_limiter.cleanup().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// H2 guard: the write-surface limiter must be reaped by the shared cleanup
|
||||
/// routine. Seeds a reclaimable entry into `write_rate_limiter` and asserts
|
||||
/// `cleanup_rate_limiters` removes it. Goes RED if `write_rate_limiter` is
|
||||
/// dropped from `cleanup_rate_limiters` — the exact H2 omission, now guarded.
|
||||
#[tokio::test]
|
||||
async fn cleanup_rate_limiters_reaps_the_write_limiter() {
|
||||
let state = crate::test_support::test_state_lazy();
|
||||
state
|
||||
.write_rate_limiter
|
||||
.insert_reclaimable_for_test("some-ip")
|
||||
.await;
|
||||
assert_eq!(
|
||||
state.write_rate_limiter.tracked_keys().await,
|
||||
1,
|
||||
"precondition: the reclaimable entry is tracked"
|
||||
);
|
||||
|
||||
state.cleanup_rate_limiters().await;
|
||||
|
||||
assert_eq!(
|
||||
state.write_rate_limiter.tracked_keys().await,
|
||||
0,
|
||||
"write_rate_limiter must be reaped by cleanup_rate_limiters (H2)"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.5.1" # x-release-please-version
|
||||
version = "0.6.0" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -13,6 +13,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
icaptcha-client = { path = "../icaptcha-client" }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
+216
-27
@@ -41,6 +41,16 @@ pub enum CertCmd {
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Exit non-zero unless the Ed25519 signature verifies AND the
|
||||
/// issuing node matches the queried node (or --expect-node)
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
/// Expected issuing node DID for --verify. A valid signature alone
|
||||
/// only proves the cert is internally consistent — signed by whatever
|
||||
/// key it names — so --verify also anchors the issuer to a DID you
|
||||
/// trust: this value when given, else the queried node's DID.
|
||||
#[arg(long, requires = "verify")]
|
||||
expect_node: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,7 +62,9 @@ pub async fn run(args: CertArgs) -> Result<()> {
|
||||
id,
|
||||
node,
|
||||
dir,
|
||||
} => cmd_show(repo, id, node, dir).await,
|
||||
verify,
|
||||
expect_node,
|
||||
} => cmd_show(repo, id, node, dir, verify, expect_node).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +126,14 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
async fn cmd_show(
|
||||
repo: String,
|
||||
id: String,
|
||||
node: String,
|
||||
dir: Option<PathBuf>,
|
||||
require_valid: bool,
|
||||
expect_node: Option<String>,
|
||||
) -> Result<()> {
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
@@ -148,39 +167,124 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
|
||||
println!(" Signature: {signature}");
|
||||
println!();
|
||||
|
||||
// Reconstruct the signing payload and verify
|
||||
// Fetch the node's current public key to verify
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let current_node_did = info["did"].as_str().unwrap_or("");
|
||||
// Verify the Ed25519 signature: rebuild the exact canonical payload the
|
||||
// node signed (see gitlawb-node/src/cert.rs::issue_ref_certificate) and
|
||||
// check it against the public key embedded in the certificate's node DID.
|
||||
// This proves the cert is internally authentic — signed by the key it
|
||||
// names; the node-DID comparison below covers *which* node that is.
|
||||
let repo_id = cert["repo_id"].as_str().unwrap_or("");
|
||||
let verdict = verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
);
|
||||
|
||||
println!("Signature verification:");
|
||||
println!(" Signing payload would be:");
|
||||
println!(" {{\"repo_id\": ..., \"ref\": \"{ref_name}\", \"old\": \"{old_sha}\",");
|
||||
println!(" \"new\": \"{new_sha}\", \"pusher\": \"{pusher}\",");
|
||||
println!(" \"node\": \"{node_did}\", \"ts\": \"{issued_at}\"}}");
|
||||
println!();
|
||||
|
||||
if current_node_did == node_did {
|
||||
println!(" Node DID matches current node. Signature is an Ed25519/base64url value.");
|
||||
println!(" To verify offline, use the node's Ed25519 public key derived from:");
|
||||
println!(" did:key → {node_did}");
|
||||
} else {
|
||||
println!(" WARNING: Certificate node DID ({node_did}) does not match");
|
||||
println!(" current node DID ({current_node_did}).");
|
||||
println!(" This certificate was issued by a different node.");
|
||||
match &verdict {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
" VALID — Ed25519 signature verified against the key the certificate names ({node_did})"
|
||||
);
|
||||
}
|
||||
Err(reason) => {
|
||||
println!(" INVALID — {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(" Signature (base64url): {signature}");
|
||||
// Contextual only — the verdict above stands on its own, so a node-info
|
||||
// hiccup here must not turn a successfully displayed certificate into an
|
||||
// error exit.
|
||||
let current_node_did = match client.get("/").await {
|
||||
Ok(resp) => resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info["did"].as_str().map(str::to_string)),
|
||||
Err(_) => None,
|
||||
};
|
||||
match current_node_did.as_deref() {
|
||||
Some(current) if current == node_did => {
|
||||
println!(" Issuing node DID matches the node being queried.");
|
||||
}
|
||||
Some(current) => {
|
||||
println!(" WARNING: Certificate node DID ({node_did}) does not match");
|
||||
println!(" current node DID ({current}).");
|
||||
println!(" This certificate was issued by a different node.");
|
||||
}
|
||||
None => {
|
||||
println!(" NOTE: could not fetch current node info — skipping node-DID comparison.");
|
||||
}
|
||||
}
|
||||
|
||||
if require_valid {
|
||||
if let Err(reason) = verdict {
|
||||
anyhow::bail!("certificate signature did not verify: {reason}");
|
||||
}
|
||||
// A valid signature proves internal consistency only: the payload was
|
||||
// signed by whatever key the certificate itself names. A hostile
|
||||
// source can mint a keypair, put its DID in node_did, and self-sign.
|
||||
// --verify therefore also anchors the issuer to a trusted DID:
|
||||
// --expect-node when given, else the DID of the node being queried.
|
||||
let expected = expect_node.as_deref().or(current_node_did.as_deref());
|
||||
match expected {
|
||||
Some(expected) if expected == node_did => {}
|
||||
Some(expected) => anyhow::bail!(
|
||||
"certificate is signed by {node_did}, but the expected issuer is {expected} — \
|
||||
a valid signature alone proves internal consistency, not a trusted issuer"
|
||||
),
|
||||
None => anyhow::bail!(
|
||||
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the node's canonical signing payload (field order must match
|
||||
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
|
||||
/// certificate's Ed25519 signature against the key embedded in `node_did`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn verify_signature(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
signature_b64: &str,
|
||||
) -> std::result::Result<(), String> {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use std::str::FromStr;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
});
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?;
|
||||
|
||||
let did =
|
||||
gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?;
|
||||
let verifying_key = did
|
||||
.to_verifying_key()
|
||||
.map_err(|e| format!("cannot derive a public key from {node_did}: {e}"))?;
|
||||
|
||||
let sig_vec = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.map_err(|e| format!("signature is not valid base64url: {e}"))?;
|
||||
let sig_bytes: [u8; 64] = sig_vec
|
||||
.try_into()
|
||||
.map_err(|_| "signature is not 64 bytes".to_string())?;
|
||||
|
||||
gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes)
|
||||
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
|
||||
}
|
||||
|
||||
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
|
||||
if id.len() >= 36 {
|
||||
return Ok(id.to_string());
|
||||
@@ -209,3 +313,88 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
|
||||
_ => anyhow::bail!("certificate prefix {id} matches multiple certificates"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pins gl's payload reconstruction to the frozen canonical byte form the
|
||||
/// node signs (default serde_json maps = alphabetically ordered keys). If
|
||||
/// serialization drifts — a field added, or a preserve_order feature
|
||||
/// landing anywhere in the workspace (feature unification flips every
|
||||
/// crate at once) — this literal stops matching and the test fails,
|
||||
/// instead of every real certificate silently rendering INVALID.
|
||||
#[test]
|
||||
fn payload_serialization_matches_frozen_canonical_form() {
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "oldsha",
|
||||
"new": "newsha",
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": "did:key:z6MkNode",
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let frozen = concat!(
|
||||
r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#,
|
||||
r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#,
|
||||
r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#,
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
|
||||
}
|
||||
|
||||
/// Signing exactly as the node does must round-trip through
|
||||
/// verify_signature; any field tampering must fail it.
|
||||
#[test]
|
||||
fn verify_signature_round_trip_and_tamper() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
let ok = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
&sig,
|
||||
);
|
||||
assert!(ok.is_ok(), "expected valid signature, got: {ok:?}");
|
||||
|
||||
let tampered = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"b".repeat(40), // new_sha changed after signing
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
&sig,
|
||||
);
|
||||
assert!(tampered.is_err(), "tampered payload must not verify");
|
||||
|
||||
let garbage = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
"not-base64url!!!",
|
||||
);
|
||||
assert!(garbage.is_err(), "malformed signature must not verify");
|
||||
}
|
||||
}
|
||||
+238
-11
@@ -16,6 +16,7 @@ use std::path::PathBuf;
|
||||
use crate::http::NodeClient;
|
||||
|
||||
const PUBLIC_NODE: &str = "https://node.gitlawb.com";
|
||||
const GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DoctorArgs {
|
||||
@@ -141,18 +142,20 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
|
||||
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
|
||||
match std::env::var("GITLAWB_NODE") {
|
||||
Ok(v) if !v.is_empty() && !v.contains("127.0.0.1") && !v.contains("localhost") => {
|
||||
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
|
||||
}
|
||||
Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => {
|
||||
checks.push(Check::fail(
|
||||
// A loopback host is a legitimate setup (self-hosted node, dev
|
||||
// harness) — the connectivity check below fails loudly if it is not
|
||||
// actually reachable, so don't red-flag the configuration itself.
|
||||
Ok(v) if is_loopback_url(&v) => {
|
||||
checks.push(Check::pass(
|
||||
"GITLAWB_NODE",
|
||||
format!(
|
||||
"set to local address ({v}) — git push/clone will fail against remote nodes"
|
||||
"{v} (local node — intentional for self-hosting/dev; unset to target the public network)"
|
||||
),
|
||||
"export GITLAWB_NODE=https://node.gitlawb.com",
|
||||
));
|
||||
}
|
||||
Ok(v) if !v.is_empty() => {
|
||||
checks.push(Check::pass("GITLAWB_NODE", v.to_string()));
|
||||
}
|
||||
_ => {
|
||||
checks.push(Check::fail(
|
||||
"GITLAWB_NODE",
|
||||
@@ -242,6 +245,15 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
// ── 5b. shell alias shadowing ─────────────────────────────────────────
|
||||
// oh-my-zsh's default `git` plugin aliases gl='git pull', which silently
|
||||
// shadows this binary in every interactive zsh — the classic symptom is
|
||||
// `gl` printing "fatal: not a git repository". Aliases beat PATH, so no
|
||||
// install method can fix it; the user's rc file has to unalias.
|
||||
if let Some(check) = check_shell_alias_shadowing(dirs::home_dir()) {
|
||||
checks.push(check);
|
||||
}
|
||||
|
||||
// ── 6. git ────────────────────────────────────────────────────────────
|
||||
match std::process::Command::new("git")
|
||||
.arg("--version")
|
||||
@@ -263,7 +275,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
|
||||
// ── 7. Version up to date ─────────────────────────────────────────────
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
checks.push(check_version(current).await);
|
||||
checks.push(check_version(current, GITHUB_API_BASE).await);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
for check in &checks {
|
||||
@@ -308,6 +320,83 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Check if a binary name exists anywhere on PATH.
|
||||
/// True when the rc file contains a real `unalias` command naming `gl` —
|
||||
/// not a comment, and not a longer word like `unalias global`. Ordering
|
||||
/// relative to oh-my-zsh loading is not modeled (an unalias placed before the
|
||||
/// plugin loads would be ineffective); acceptable slack for a warning check.
|
||||
fn rc_unaliases_gl(rc: &str) -> bool {
|
||||
rc.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
line.split([';', '&', '|']).any(|cmd| {
|
||||
// A trailing comment must not count: `unalias foo # gl`
|
||||
let cmd = cmd.split('#').next().unwrap_or("");
|
||||
let mut tokens = cmd.split_whitespace();
|
||||
tokens.next() == Some("unalias") && tokens.any(|t| t == "gl")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the URL's host is exactly a loopback address. Substring checks
|
||||
/// misclassify hosts like `localhost.example` or URLs with "localhost" in the
|
||||
/// path, so parse and compare the actual host.
|
||||
fn is_loopback_url(value: &str) -> bool {
|
||||
reqwest::Url::parse(value)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.host_str()
|
||||
.map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]" || h == "::1")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Detect interactive-shell setups where an alias shadows `gl` (aliases beat
|
||||
/// PATH, so no install method can fix this). Best-effort heuristic over
|
||||
/// ~/.zshrc: an explicit `alias gl=`, or oh-my-zsh's `git` plugin (which
|
||||
/// ships gl='git pull'). Returns None when nothing suspicious is found or the
|
||||
/// rc file already contains an `unalias gl`.
|
||||
fn check_shell_alias_shadowing(home: Option<PathBuf>) -> Option<Check> {
|
||||
let home = home?;
|
||||
let rc = std::fs::read_to_string(home.join(".zshrc")).ok()?;
|
||||
if rc_unaliases_gl(&rc) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let explicit_alias = rc.lines().any(|l| l.trim_start().starts_with("alias gl="));
|
||||
// Single-line `plugins=(git ...)` is the overwhelmingly common form; a
|
||||
// multi-line plugins array slips past this heuristic, which is acceptable
|
||||
// for a warning-level check.
|
||||
let omz_git_plugin = home.join(".oh-my-zsh/plugins/git").exists()
|
||||
&& rc.lines().any(|l| {
|
||||
let l = l.trim_start();
|
||||
l.starts_with("plugins=")
|
||||
&& l.trim_start_matches("plugins=(")
|
||||
.trim_end_matches(')')
|
||||
.split_whitespace()
|
||||
.any(|p| p == "git")
|
||||
});
|
||||
|
||||
if explicit_alias || omz_git_plugin {
|
||||
let source = if explicit_alias {
|
||||
"~/.zshrc defines `alias gl=`"
|
||||
} else {
|
||||
"oh-my-zsh's git plugin aliases gl='git pull'"
|
||||
};
|
||||
Some(Check::warn(
|
||||
"shell alias",
|
||||
format!(
|
||||
"{source} — interactive shells run that instead of this binary \
|
||||
(symptom: `gl` prints \"fatal: not a git repository\")"
|
||||
),
|
||||
"echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc (must come after oh-my-zsh loads)",
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn which_in_path(name: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.map(|paths| {
|
||||
@@ -319,8 +408,9 @@ fn which_in_path(name: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from gitlawb/releases and compare to current version.
|
||||
async fn check_version(current: &'static str) -> Check {
|
||||
/// Fetch the latest release tag from Gitlawb/node (the actual release repo — see install.sh's
|
||||
/// `GITLAWB_RELEASE_REPO` default) and compare to current version.
|
||||
async fn check_version(current: &'static str, github_api_base: &str) -> Check {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent(format!("gl/{current}"))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
@@ -331,7 +421,9 @@ async fn check_version(current: &'static str) -> Check {
|
||||
};
|
||||
|
||||
let resp = match client
|
||||
.get("https://api.github.com/repos/gitlawb/releases/releases/latest")
|
||||
.get(format!(
|
||||
"{github_api_base}/repos/Gitlawb/node/releases/latest"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -339,6 +431,13 @@ async fn check_version(current: &'static str) -> Check {
|
||||
Err(_) => return Check::pass("version", format!("v{current} (offline — could not check)")),
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Check::pass(
|
||||
"version",
|
||||
format!("v{current} (GitHub API returned HTTP {})", resp.status()),
|
||||
);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Check::pass("version", format!("v{current} (invalid response)")),
|
||||
@@ -382,6 +481,88 @@ fn is_newer(latest: &str, current: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_home(zshrc: Option<&str>, with_omz_git: bool) -> tempfile::TempDir {
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
if let Some(rc) = zshrc {
|
||||
std::fs::write(home.path().join(".zshrc"), rc).unwrap();
|
||||
}
|
||||
if with_omz_git {
|
||||
std::fs::create_dir_all(home.path().join(".oh-my-zsh/plugins/git")).unwrap();
|
||||
}
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_shadowing_flags_omz_git_plugin() {
|
||||
let home = fake_home(Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\n"), true);
|
||||
let check = check_shell_alias_shadowing(Some(home.path().to_path_buf()));
|
||||
assert!(check.is_some(), "omz git plugin without unalias must warn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_shadowing_flags_explicit_alias() {
|
||||
let home = fake_home(Some("alias gl='git pull'\n"), false);
|
||||
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_shadowing_silent_when_unaliased() {
|
||||
let home = fake_home(
|
||||
Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\nunalias gl 2>/dev/null\n"),
|
||||
true,
|
||||
);
|
||||
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_shadowing_ignores_fake_unaliases() {
|
||||
// None of these actually free `gl` — the warning must survive them.
|
||||
for rc in [
|
||||
"plugins=(git)\n# unalias gl\n", // commented out
|
||||
"plugins=(git)\nunalias global\n", // longer word
|
||||
"plugins=(git)\nunalias foo # gl\n", // gl only in a comment
|
||||
"plugins=(git)\necho unalias-gl-later\n", // not an unalias command
|
||||
] {
|
||||
let home = fake_home(Some(rc), true);
|
||||
assert!(
|
||||
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some(),
|
||||
"must still warn for rc: {rc:?}"
|
||||
);
|
||||
}
|
||||
// Real unalias forms that do free it — all must silence the warning.
|
||||
for rc in [
|
||||
"plugins=(git)\nunalias gl\n",
|
||||
"plugins=(git)\nunalias gl 2>/dev/null\n",
|
||||
"plugins=(git)\ntrue; unalias gl\n",
|
||||
"plugins=(git)\nunalias glog gl gst\n",
|
||||
] {
|
||||
let home = fake_home(Some(rc), true);
|
||||
assert!(
|
||||
check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none(),
|
||||
"must be silent for rc: {rc:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_url_detection_is_host_exact() {
|
||||
assert!(is_loopback_url("http://127.0.0.1:7545"));
|
||||
assert!(is_loopback_url("http://localhost:7545"));
|
||||
assert!(is_loopback_url("http://[::1]:7545"));
|
||||
assert!(!is_loopback_url("https://localhost.example"));
|
||||
assert!(!is_loopback_url("https://node.gitlawb.com/localhost"));
|
||||
assert!(!is_loopback_url("not a url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_shadowing_silent_without_signals() {
|
||||
let home = fake_home(Some("plugins=(z)\nexport EDITOR=vim\n"), false);
|
||||
assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none());
|
||||
let no_rc = fake_home(None, false);
|
||||
assert!(check_shell_alias_shadowing(Some(no_rc.path().to_path_buf())).is_none());
|
||||
assert!(check_shell_alias_shadowing(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_newer_minor_bump() {
|
||||
assert!(is_newer("0.2.0", "0.1.0"));
|
||||
@@ -417,4 +598,50 @@ mod tests {
|
||||
fn test_which_in_path_missing_binary() {
|
||||
assert!(!which_in_path("this-binary-does-not-exist-gl-test-12345"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_version_queries_gitlawb_node_releases() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tag_name":"v9.9.9"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let check = check_version("0.1.0", &server.url()).await;
|
||||
assert!(matches!(check.state, CheckState::Warn));
|
||||
assert!(check.detail.contains("v9.9.9"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_version_up_to_date() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"tag_name":"v0.1.0"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let check = check_version("0.1.0", &server.url()).await;
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("up to date"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_version_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/repos/Gitlawb/node/releases/latest")
|
||||
.with_status(403)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let check = check_version("0.1.0", &server.url()).await;
|
||||
assert!(matches!(check.state, CheckState::Ok));
|
||||
assert!(check.detail.contains("GitHub API returned HTTP 403"));
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,23 @@ impl NodeClient {
|
||||
req.send().await.with_context(|| format!("GET {url}"))
|
||||
}
|
||||
|
||||
/// GET that signs when an identity keypair is present and falls back to an
|
||||
/// anonymous GET otherwise — for read-visibility endpoints, where a public
|
||||
/// repo is readable anonymously but a private repo requires the owner/reader
|
||||
/// to be authenticated. Mirrors the conditional signing of post/put/delete.
|
||||
pub async fn get_maybe_signed(&self, path: &str) -> Result<reqwest::Response> {
|
||||
let url = format!("{}{}", self.node_url, path);
|
||||
let mut req = self.inner.get(&url);
|
||||
if let Some(kp) = &self.keypair {
|
||||
let signed = sign_request(kp, "GET", path, b"");
|
||||
req = req
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
}
|
||||
req.send().await.with_context(|| format!("GET {url}"))
|
||||
}
|
||||
|
||||
/// POST with JSON body + RFC 9421 signing + transparent iCaptcha solve/retry.
|
||||
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
|
||||
self.send_signed("POST", path, body).await
|
||||
@@ -185,3 +202,389 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
.await
|
||||
.context("iCaptcha solver task panicked")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use mockito::Server;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Serializes the two integration tests that touch the process-global
|
||||
/// `GITLAWB_ICAPTCHA_URL` / `GITLAWB_ICAPTCHA_INSECURE` env vars so they
|
||||
/// never race.
|
||||
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn test_keypair() -> Keypair {
|
||||
Keypair::generate()
|
||||
}
|
||||
|
||||
fn headers_from_pairs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
k.parse::<reqwest::header::HeaderName>().unwrap(),
|
||||
v.parse::<reqwest::header::HeaderValue>().unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
// ── icaptcha_cfg ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_returns_some_when_both_headers_present() {
|
||||
let kp = test_keypair();
|
||||
let client = NodeClient::new("http://localhost", Some(kp.clone()));
|
||||
let headers = headers_from_pairs(&[
|
||||
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
|
||||
("x-icaptcha-level", "3"),
|
||||
]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.did, kp.did().to_string());
|
||||
assert_eq!(cfg.level, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_defaults_level_when_only_url_present() {
|
||||
let kp = test_keypair();
|
||||
let client = NodeClient::new("http://localhost", Some(kp));
|
||||
let headers = headers_from_pairs(&[("x-icaptcha-url", "https://icaptcha.gitlawb.com")]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_defaults_url_when_only_level_present() {
|
||||
let kp = test_keypair();
|
||||
let client = NodeClient::new("http://localhost", Some(kp));
|
||||
let headers = headers_from_pairs(&[("x-icaptcha-level", "5")]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.level, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_returns_none_without_icaptcha_headers() {
|
||||
let client = NodeClient::new("http://localhost", Some(test_keypair()));
|
||||
let headers = reqwest::header::HeaderMap::new();
|
||||
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_returns_none_with_unrelated_headers() {
|
||||
let client = NodeClient::new("http://localhost", Some(test_keypair()));
|
||||
let headers = headers_from_pairs(&[("content-type", "application/json")]);
|
||||
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_errors_when_no_keypair() {
|
||||
let client = NodeClient::new("http://localhost", None);
|
||||
let headers = headers_from_pairs(&[("x-icaptcha-level", "3")]);
|
||||
let err = client.icaptcha_cfg(&headers).unwrap_err();
|
||||
assert!(err.to_string().contains("identity keypair"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icaptcha_cfg_ignores_unparseable_level() {
|
||||
let client = NodeClient::new("http://localhost", Some(test_keypair()));
|
||||
let headers = headers_from_pairs(&[
|
||||
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
|
||||
("x-icaptcha-level", "not-a-number"),
|
||||
]);
|
||||
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
|
||||
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
|
||||
}
|
||||
|
||||
// ── send_once ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_attaches_proof_header_when_provided() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("x-icaptcha-proof", "test.proof.token")
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", Some("test.proof.token"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_omits_proof_header_when_not_provided() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("x-icaptcha-proof", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_signs_request_when_keypair_present() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("Signature", mockito::Matcher::Any)
|
||||
.match_header("Signature-Input", mockito::Matcher::Any)
|
||||
.match_header("Content-Digest", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_once_does_not_sign_when_no_keypair() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/test")
|
||||
.match_header("Signature", mockito::Matcher::Missing)
|
||||
.match_header("Signature-Input", mockito::Matcher::Missing)
|
||||
.match_header("Content-Digest", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_body("ok")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.send_once("POST", "/api/test", b"{}", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
// ── send_signed ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_returns_non_icaptcha_403_without_retry() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"forbidden"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 403);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_returns_first_response_on_success() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"created"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_handles_405_not_icaptcha() {
|
||||
let mut server = Server::new_async().await;
|
||||
let m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(405)
|
||||
.with_body(r#"{"error":"method not allowed"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 405);
|
||||
m.assert();
|
||||
}
|
||||
|
||||
// ── send_signed iCaptcha retry (full integration) ────────────────────
|
||||
|
||||
/// Set GITLAWB_ICAPTCHA_URL and GITLAWB_ICAPTCHA_INSECURE so the iCaptcha
|
||||
/// client trusts a local mockito HTTP server, restoring any prior values on
|
||||
/// drop so a test run launched with those variables keeps working.
|
||||
/// Holds [`ICAPTCHA_ENV_LOCK`] for its lifetime so concurrent tests don't
|
||||
/// race on the process-global env vars.
|
||||
struct IcaptchaEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
prev_url: Option<OsString>,
|
||||
prev_insecure: Option<OsString>,
|
||||
}
|
||||
|
||||
impl IcaptchaEnv {
|
||||
fn new(url: &str) -> Self {
|
||||
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
|
||||
let prev_url = std::env::var_os("GITLAWB_ICAPTCHA_URL");
|
||||
let prev_insecure = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_URL", url);
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
|
||||
IcaptchaEnv {
|
||||
_lock: lock,
|
||||
prev_url,
|
||||
prev_insecure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IcaptchaEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.prev_url.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
|
||||
}
|
||||
match self.prev_insecure.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up a mock iCaptcha server that responds to challenge + answer.
|
||||
/// `hits` sets the expected call count for both endpoints so the test can
|
||||
/// verify the solve loop was entered the correct number of times.
|
||||
struct MockIcaptcha {
|
||||
challenge: mockito::Mock,
|
||||
answer: mockito::Mock,
|
||||
_guard: IcaptchaEnv,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl MockIcaptcha {
|
||||
async fn new(server: &mut mockito::ServerGuard, hits: usize) -> Self {
|
||||
let url = server.url();
|
||||
let guard = IcaptchaEnv::new(&url);
|
||||
let challenge = server
|
||||
.mock("POST", "/v1/challenge")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"challengeId":"c1","type":"arithmetic","difficulty":1,"prompt":"What is 1 + 1?","token":"tk1"}"#,
|
||||
)
|
||||
.expect(hits)
|
||||
.create_async()
|
||||
.await;
|
||||
let answer = server
|
||||
.mock("POST", "/v1/answer")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"passed","proof":"mock.proof"}"#)
|
||||
.expect(hits)
|
||||
.create_async()
|
||||
.await;
|
||||
Self {
|
||||
challenge,
|
||||
answer,
|
||||
_guard: guard,
|
||||
url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_solves_icaptcha_and_retries_to_success() {
|
||||
let mut node = Server::new_async().await;
|
||||
let mut icaptcha = Server::new_async().await;
|
||||
let ic = MockIcaptcha::new(&mut icaptcha, 1).await;
|
||||
|
||||
let n1 = node
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("x-icaptcha-url", &ic.url)
|
||||
.with_header("x-icaptcha-level", "3")
|
||||
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let n2 = node
|
||||
.mock("POST", "/api/register")
|
||||
.match_header("x-icaptcha-proof", "mock.proof")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"created"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
n1.assert();
|
||||
n2.assert();
|
||||
ic.challenge.assert();
|
||||
ic.answer.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_signed_returns_403_after_icaptcha_retries_exhausted() {
|
||||
let mut node = Server::new_async().await;
|
||||
let mut icaptcha = Server::new_async().await;
|
||||
// MAX_ICAPTCHA_RETRIES = 2, so with every call returning 403 with
|
||||
// iCaptcha headers the solve loop runs twice (2 challenge + 2 answer).
|
||||
let ic = MockIcaptcha::new(&mut icaptcha, 2).await;
|
||||
|
||||
// The original + 2 retries = 3 node calls before the loop gives up.
|
||||
let n = node
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_header("x-icaptcha-url", &ic.url)
|
||||
.with_header("x-icaptcha-level", "3")
|
||||
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
|
||||
.expect(3)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = NodeClient::new(node.url(), Some(test_keypair()));
|
||||
let resp = client
|
||||
.send_signed("POST", "/api/register", b"{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 403);
|
||||
n.assert();
|
||||
ic.challenge.assert();
|
||||
ic.answer.assert();
|
||||
}
|
||||
}
|
||||
+52
-4
@@ -38,13 +38,23 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
let git_dir = cwd.join(".git");
|
||||
if !git_dir.exists() {
|
||||
println!("Initializing git repository...");
|
||||
// -b main: the push flow targets `main`; without it a fresh repo uses
|
||||
// the user's init.defaultBranch (often `master`). Older git (<2.28)
|
||||
// lacks the flag, so fall back to a plain init.
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(&cwd)
|
||||
.status()
|
||||
.context("failed to run git init")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("git init failed");
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&cwd)
|
||||
.status()
|
||||
.context("failed to run git init")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("git init failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Git repository detected.");
|
||||
@@ -165,9 +175,47 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// The hint must match the repo's actual state: with no commits yet, a bare
|
||||
// `git push` fails with "src refspec ... does not match any" — exactly the
|
||||
// trap a zero-to-push command must not set.
|
||||
let has_commits = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--verify", "HEAD"])
|
||||
.current_dir(&cwd)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
// symbolic-ref succeeds on a branch (including an unborn one) and fails
|
||||
// on a detached HEAD — where guessing "main" could push the wrong ref.
|
||||
let branch = std::process::Command::new("git")
|
||||
.args(["symbolic-ref", "--short", "HEAD"])
|
||||
.current_dir(&cwd)
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
println!();
|
||||
println!("Ready! Push with:");
|
||||
println!(" git push gitlawb main");
|
||||
match (branch, has_commits) {
|
||||
(Some(branch), true) => {
|
||||
println!("Ready! Push with:");
|
||||
println!(" git push gitlawb {branch}");
|
||||
}
|
||||
(Some(branch), false) => {
|
||||
println!("Ready! Nothing is committed yet — commit, then push:");
|
||||
println!(" git add -A && git commit -m \"initial commit\"");
|
||||
println!(" git push gitlawb {branch}");
|
||||
}
|
||||
(None, _) => {
|
||||
println!("Ready! HEAD is detached — create or switch to a branch, then push:");
|
||||
println!(" git switch -c main");
|
||||
println!(" git push gitlawb main");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+207
-6
@@ -932,13 +932,33 @@ async fn call_tool(
|
||||
|
||||
"webhook_list" => {
|
||||
let repo = args["repo"].as_str().context("missing 'repo'")?;
|
||||
let owner = resolve_owner(&args, &client).await?;
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
|
||||
.await?
|
||||
.json()
|
||||
// Owner-gated route: an explicit `owner` arg wins, otherwise default to
|
||||
// the signing keypair's short DID (NOT the node DID). The request is
|
||||
// signed by this keypair, so the path owner must match it or the node
|
||||
// returns 403/404.
|
||||
let owner = if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
|
||||
o.to_string()
|
||||
} else {
|
||||
let kp = keypair
|
||||
.as_ref()
|
||||
.context("no identity found — run `gl identity new` first")?;
|
||||
let did = kp.did().to_string();
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
};
|
||||
// Owner-gated route: must be signed (get_signed), not a plain get().
|
||||
let resp = client
|
||||
.get_signed(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
|
||||
.await?;
|
||||
Ok(serde_json::to_string_pretty(&resp)?)
|
||||
// Check the HTTP status before deserializing: a 401/403/404 JSON error
|
||||
// body (missing identity, wrong owner, private/deleted repo) must fail
|
||||
// the tool call, not be returned as a successful result.
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await?;
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("webhook_list failed ({status}): {msg}");
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(&body)?)
|
||||
}
|
||||
|
||||
"webhook_delete" => {
|
||||
@@ -1825,6 +1845,187 @@ mod tests {
|
||||
assert_eq!(parsed["body"], "looks good");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_list_via_mcp_signs_the_request() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// /hooks is owner-gated, so the MCP webhook_list tool must send a SIGNED
|
||||
// request (get_signed). Requiring the header catches a regression to get().
|
||||
// Passing `owner` in args makes resolve_owner skip the node-root lookup.
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[],"count":0}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool(
|
||||
"webhook_list",
|
||||
json!({"owner": "alice", "repo": "myrepo"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.contains("webhooks"), "got: {result}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_list_default_owner_is_keypair_not_node_did() {
|
||||
// When `owner` is omitted, the owner segment must come from the signing
|
||||
// keypair's short DID, NOT the node root DID. The "/" mock returns a
|
||||
// different DID; the /hooks mock only matches the keypair's short DID,
|
||||
// so a regression to the node DID would leave the request unmatched.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6MkNodeRootDid"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let did = kp.did().to_string();
|
||||
let short = did.split(':').next_back().unwrap_or(&did).to_string();
|
||||
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(format!(r"/api/v1/repos/{short}/myrepo/hooks$")),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[],"count":0}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool(
|
||||
"webhook_list",
|
||||
json!({"repo": "myrepo"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.contains("webhooks"), "got: {result}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_list_explicit_owner_overrides_keypair() {
|
||||
// An explicit `owner` arg must win over the keypair default and the node
|
||||
// DID. The path must use the supplied owner; "/" must not even be hit.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/repos/someoneelse/myrepo/hooks$".to_string()),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[],"count":0}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = call_tool(
|
||||
"webhook_list",
|
||||
json!({"repo": "myrepo", "owner": "someoneelse"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.contains("webhooks"), "got: {result}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_list_non_success_status_errors() {
|
||||
// A 403 (or any non-2xx) JSON error body for a missing identity, wrong
|
||||
// owner, or private/deleted repo must fail the tool call, NOT be returned
|
||||
// as a successful result. Mirrors the `gl webhook list` status check.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"only the repo owner can perform this action"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool(
|
||||
"webhook_list",
|
||||
json!({"owner": "alice", "repo": "myrepo"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("non-2xx must error, not return the error body as success");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("webhook_list failed (403"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("only the repo owner"),
|
||||
"must surface the node message, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_list_without_keypair_errors() {
|
||||
// No identity loaded and no explicit owner → the tool must error rather
|
||||
// than fall back to the node DID and issue an unsigned, mis-scoped GET.
|
||||
let server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty: no identity.pem
|
||||
|
||||
let err = call_tool(
|
||||
"webhook_list",
|
||||
json!({"repo": "myrepo"}),
|
||||
&server.url(),
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("must error without an identity");
|
||||
assert!(err.to_string().contains("no identity found"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_count_is_42() {
|
||||
let tools = tool_definitions();
|
||||
|
||||
@@ -154,10 +154,12 @@ async fn cmd_remove(
|
||||
|
||||
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_owner_repo(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, None);
|
||||
// Read-visibility-gated on the node: public repos list anonymously, private
|
||||
// repos need the owner's signature. Sign when an identity is available.
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
|
||||
let resp = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
@@ -307,6 +309,9 @@ mod tests {
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"branches/protected".to_string()),
|
||||
)
|
||||
// An identity is supplied, so get_maybe_signed must sign the request.
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"protected_branches":[],"count":0}"#)
|
||||
@@ -338,6 +343,8 @@ mod tests {
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"branches/protected".to_string()),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
|
||||
|
||||
+431
-34
@@ -146,6 +146,8 @@ pub enum RepoCmd {
|
||||
repo: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -203,7 +205,7 @@ pub async fn run(args: RepoArgs) -> Result<()> {
|
||||
RepoCmd::ReplicaUnregister { repo, node, dir } => {
|
||||
cmd_replica_unregister(repo, node, dir).await
|
||||
}
|
||||
RepoCmd::Replicas { repo, node } => cmd_replicas(repo, node).await,
|
||||
RepoCmd::Replicas { repo, node, dir } => cmd_replicas(repo, node, dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +331,10 @@ async fn cmd_clone(name: String, node: String, dir: Option<PathBuf>) -> Result<(
|
||||
}
|
||||
|
||||
async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
// Sign when an identity is available so the read-visibility-gated replica
|
||||
// sub-fetch below resolves for a private repo's owner (public repos still
|
||||
// work anonymously).
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
|
||||
let (owner, name) = if repo.contains('/') {
|
||||
let (o, n) = repo.split_once('/').unwrap();
|
||||
@@ -339,7 +344,10 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
(short, repo)
|
||||
};
|
||||
|
||||
let resp = client.get(&format!("/api/v1/repos/{owner}/{name}")).await?;
|
||||
let resp = client
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
// A non-existent (or unreadable/quarantined) repo is a real 404 from the
|
||||
// node — surface it plainly instead of printing a stub card with `?` fields
|
||||
// and a placeholder owner DID.
|
||||
@@ -379,7 +387,7 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
// Replica count — failure to fetch is non-fatal (older nodes don't expose this).
|
||||
if let Ok(resp) = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
|
||||
.await
|
||||
{
|
||||
if resp.status().is_success() {
|
||||
@@ -459,15 +467,17 @@ async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_replicas(repo: String, node: String) -> Result<()> {
|
||||
async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = repo
|
||||
.split_once('/')
|
||||
.map(|(o, n)| (o.to_string(), n.to_string()))
|
||||
.context("use owner/repo format")?;
|
||||
|
||||
let client = NodeClient::new(&node, None);
|
||||
// Read-visibility-gated: public repos list anonymously, private repos need
|
||||
// the owner's signature. Sign when an identity is available.
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
let resp = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
@@ -585,7 +595,7 @@ async fn cmd_fork(
|
||||
}
|
||||
|
||||
/// Returns (owner, repo_name) — if repo contains '/', splits on it; otherwise uses caller's DID.
|
||||
async fn resolve_owner_repo_pair(
|
||||
pub(crate) async fn resolve_owner_repo_pair(
|
||||
repo: &str,
|
||||
node: &str,
|
||||
dir: Option<&std::path::Path>,
|
||||
@@ -658,16 +668,23 @@ async fn cmd_label_remove(
|
||||
|
||||
async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
|
||||
// Read-visibility-gated like the sibling read surfaces (replicas, protected
|
||||
// branches): sign when an identity is available so a private-repo owner can
|
||||
// read their own labels, while public repos stay anonymously listable.
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
|
||||
let resp: Value = client
|
||||
.get_authed(&format!("/api/v1/repos/{owner}/{name}/labels"))
|
||||
.await?
|
||||
.json()
|
||||
let resp = client
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list labels failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
|
||||
let labels = body["labels"].as_array().cloned().unwrap_or_default();
|
||||
if labels.is_empty() {
|
||||
println!("No labels on {owner}/{name}");
|
||||
} else {
|
||||
@@ -679,17 +696,39 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the protected-branch list from the node's `GET /branches/protected`
|
||||
/// response. The endpoint returns `{"protected_branches": [...], "count": N}`
|
||||
/// (see `crates/gitlawb-node/src/api/protect.rs`); each entry may be a plain
|
||||
/// branch-name string or an object carrying a `name` field.
|
||||
fn parse_protected_branches(val: &Value) -> Vec<String> {
|
||||
val["protected_branches"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|b| {
|
||||
b.as_str()
|
||||
.map(String::from)
|
||||
.or_else(|| b["name"].as_str().map(String::from))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: bool) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let my_did = keypair.did().to_string();
|
||||
let my_short = my_did.split(':').next_back().unwrap_or(&my_did).to_string();
|
||||
|
||||
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, None);
|
||||
// Sign with the loaded identity so the read-visibility-gated protected-branch
|
||||
// fetch below works on the owner's own private repos.
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
|
||||
// Fetch repo info
|
||||
// Fetch repo info (get_repo is read-visibility-gated; sign so the owner can
|
||||
// inspect their own private repo).
|
||||
let resp = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}"))
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -704,25 +743,14 @@ async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: b
|
||||
.to_string();
|
||||
let is_owner = my_did == owner_did || my_short == owner_short;
|
||||
|
||||
// Fetch protected branches
|
||||
// Fetch protected branches (read-visibility-gated; signed via the client).
|
||||
let protected: Vec<String> = match client
|
||||
.get(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/branches/protected"))
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let val: Value = resp.json().await.unwrap_or_default();
|
||||
val.as_array()
|
||||
.or_else(|| val["branches"].as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|b| {
|
||||
b.as_str()
|
||||
.map(String::from)
|
||||
.or_else(|| b["name"].as_str().map(String::from))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
parse_protected_branches(&val)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
@@ -760,6 +788,20 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parse_protected_branches_reads_node_shape() {
|
||||
use serde_json::json;
|
||||
assert_eq!(
|
||||
parse_protected_branches(&json!({"protected_branches":["main","release"],"count":2})),
|
||||
vec!["main".to_string(), "release".to_string()]
|
||||
);
|
||||
assert!(parse_protected_branches(&json!({"count":0})).is_empty());
|
||||
assert_eq!(
|
||||
parse_protected_branches(&json!({"protected_branches":[{"name":"main"}]})),
|
||||
vec!["main".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
fn write_identity(dir: &TempDir) {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
@@ -1097,6 +1139,10 @@ mod tests {
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
|
||||
)
|
||||
// Identity is loaded, so get_maybe_signed must sign — requiring the
|
||||
// header guards against a regression back to bare get().
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"labels":[]}"#)
|
||||
@@ -1123,6 +1169,10 @@ mod tests {
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
|
||||
)
|
||||
// Identity is loaded, so get_maybe_signed must sign — requiring the
|
||||
// header guards against a regression back to bare get().
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"labels":["language:rust","topic:defi"]}"#)
|
||||
@@ -1138,6 +1188,97 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_label_list_anonymous_when_no_identity() {
|
||||
// Empty dir → no identity → get_maybe_signed must fall back to an
|
||||
// anonymous GET (the public-repo path). Assert NO signature header.
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/labels$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"labels":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_label_list(
|
||||
"owner/pubrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_label_list_404_errors() {
|
||||
// A non-reader of a private repo gets 404 from the now-gated endpoint.
|
||||
// The client must surface that as an error, not print "No labels" and
|
||||
// exit 0 (the error body has no `labels` key).
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_label_list(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("list labels failed (404")
|
||||
&& err.to_string().contains("repo not found"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_label_list_non_json_error_surfaces_status() {
|
||||
// A non-2xx response with a non-JSON body (e.g. a proxy 502) must still
|
||||
// surface the HTTP status, not collapse to a bare JSON-parse error.
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels$".to_string()),
|
||||
)
|
||||
.with_status(502)
|
||||
.with_header("content-type", "text/html")
|
||||
.with_body("<html>502 Bad Gateway</html>")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_label_list(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("list labels failed (502"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_owner_is_owner() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1148,11 +1289,15 @@ mod tests {
|
||||
let short = did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Both fetches are read-visibility-gated; with an identity loaded they
|
||||
// must be signed. Requiring the header guards a regression to get().
|
||||
let _repo = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(format!(r"^/api/v1/repos/{short}/myrepo$")),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(
|
||||
@@ -1167,9 +1312,11 @@ mod tests {
|
||||
r"^/api/v1/repos/{short}/myrepo/branches/protected$"
|
||||
)),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"["main"]"#)
|
||||
.with_body(r#"{"protected_branches":["main"],"count":1}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1210,7 +1357,7 @@ mod tests {
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"["main","release"]"#)
|
||||
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1251,7 +1398,7 @@ mod tests {
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"["main"]"#)
|
||||
.with_body(r#"{"protected_branches":["main"],"count":1}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1292,4 +1439,254 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_replicas_signs_when_identity_present() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Read-visibility-gated: with an identity the request must be signed.
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"repo":"owner/myrepo","replica_count":0,"replicas":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_replicas(
|
||||
"owner/myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_replicas_anonymous_when_no_identity() {
|
||||
// Empty dir → no identity → get_maybe_signed must fall back to an
|
||||
// anonymous GET (the public-repo path). Assert NO signature header.
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"repo":"owner/pubrepo","replica_count":0,"replicas":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_replicas(
|
||||
"owner/pubrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_info_signs_repo_and_replica_fetches() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Both the primary repo fetch and the replica sub-fetch are
|
||||
// read-visibility-gated; with an identity loaded both must be signed.
|
||||
let _repo = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
|
||||
)
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"r1","name":"myrepo","owner_did":"did:key:zOwner","is_public":false,"default_branch":"main"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _replicas = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/replicas$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"replica_count":0,"replicas":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_info(
|
||||
"owner/myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_info_404_surfaces_error_not_fake_repo() {
|
||||
// A non-reader of a private repo gets a JSON 404 from the now-gated
|
||||
// GET /api/v1/repos/{owner}/{name}. The command must surface that as an
|
||||
// error, not parse the error body and print a fabricated repo summary
|
||||
// (owner / "?" / public=true / main) before exiting 0.
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _repo = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_info(
|
||||
"owner/myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
// The merged cmd_info maps a 404 to a generic "not found" (it does not
|
||||
// echo the server's message body on 404), which still surfaces the error
|
||||
// rather than fabricating a repo card, and avoids leaking any
|
||||
// server-provided detail on a private-repo denial.
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("repository 'owner/myrepo' not found"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_info_non_json_error_surfaces_status() {
|
||||
// A non-2xx response with a non-JSON body (e.g. a proxy 502) must still
|
||||
// surface the HTTP status, not collapse to a bare JSON-parse error.
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _repo = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
|
||||
)
|
||||
.with_status(502)
|
||||
.with_header("content-type", "text/html")
|
||||
.with_body("<html>502 Bad Gateway</html>")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_info(
|
||||
"owner/myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("repo info failed (502"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Drives the three deny/negative arms that the merge reconciliation changed but
|
||||
// that no existing test executed (they were only reasoned): resolve_owner_did
|
||||
// with no identity, cmd_info on an authenticated-but-denied 403, and
|
||||
// get_maybe_signed with no keypair. Each asserts the must-not case.
|
||||
#[cfg(test)]
|
||||
mod vet_merge_deny_arms {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_identity(dir: &TempDir) {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
// No local identity must hard-error, never fall back to the node's own DID.
|
||||
// (Load-bearing: #113's pre-merge resolve_owner_did returned Ok via a node
|
||||
// fetch here; a reintroduced fallback makes this go red.)
|
||||
#[tokio::test]
|
||||
async fn resolve_owner_did_no_identity_errors_not_fallback() {
|
||||
let empty = TempDir::new().unwrap(); // no identity.pem written
|
||||
let err = resolve_owner_did("http://unused.invalid", Some(empty.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("no local identity to resolve the repo owner"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// An authenticated caller DENIED with a 403 must surface an error, never
|
||||
// render a repo card (deny-as-success is the INV-8 client-contract break).
|
||||
#[tokio::test]
|
||||
async fn cmd_info_403_denied_surfaces_error_not_card() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/owner/myrepo$".to_string()),
|
||||
)
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_info(
|
||||
"owner/myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("repo info failed (403"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// No keypair must send an unsigned GET; the mock only matches when the
|
||||
// Signature header is absent, so a wrongly-signed request fails to match.
|
||||
#[tokio::test]
|
||||
async fn get_maybe_signed_no_keypair_sends_unsigned() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/repos/owner/pub")
|
||||
.match_header("signature", mockito::Matcher::Missing)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("{}")
|
||||
.create_async()
|
||||
.await;
|
||||
let client = NodeClient::new(server.url(), None);
|
||||
let resp = client
|
||||
.get_maybe_signed("/api/v1/repos/owner/pub")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"unsigned GET should match the Missing-signature mock; got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
-13
@@ -59,7 +59,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
|
||||
println!(" repo {short_did}/{repo}");
|
||||
}
|
||||
None => {
|
||||
println!(" repo (not in a gitlawb repo — no gitlawb:// origin)");
|
||||
println!(" repo (not in a gitlawb repo — no gitlawb:// remote)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,29 +143,68 @@ fn trust_bar(score: f64) -> String {
|
||||
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
|
||||
}
|
||||
|
||||
/// Parse `gitlawb://<did>/<repo>` from the git origin remote.
|
||||
/// Find a `gitlawb://<did>/<repo>` remote in the current repo.
|
||||
///
|
||||
/// Checks `origin` first (clones), then `gitlawb` (the name `gl init` adds),
|
||||
/// then any other remote with a gitlawb:// URL.
|
||||
fn detect_gitlawb_remote() -> Option<(String, String)> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.args(["remote"])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let url = String::from_utf8(out.stdout).ok()?;
|
||||
let rest = url.trim().strip_prefix("gitlawb://")?;
|
||||
let slash = rest.rfind('/')?;
|
||||
let did = rest[..slash].to_string();
|
||||
let repo = rest[slash + 1..].to_string();
|
||||
if did.is_empty() || repo.is_empty() {
|
||||
return None;
|
||||
let names: Vec<String> = String::from_utf8(out.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
|
||||
let preferred = ["origin", "gitlawb"];
|
||||
let ordered = preferred
|
||||
.iter()
|
||||
.filter(|p| names.iter().any(|n| n == *p))
|
||||
.map(|p| p.to_string())
|
||||
.chain(
|
||||
names
|
||||
.iter()
|
||||
.filter(|n| !preferred.contains(&n.as_str()))
|
||||
.cloned(),
|
||||
);
|
||||
|
||||
for name in ordered {
|
||||
// A remote can carry several URLs, and the gitlawb:// one may be a
|
||||
// push URL behind an https fetch URL — check every fetch and push URL.
|
||||
for extra in [&["--all"][..], &["--push", "--all"][..]] {
|
||||
let mut args = vec!["remote", "get-url"];
|
||||
args.extend_from_slice(extra);
|
||||
args.push(&name);
|
||||
let Ok(out) = std::process::Command::new("git")
|
||||
.args(&args)
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !out.status.success() {
|
||||
continue;
|
||||
}
|
||||
let Ok(urls) = String::from_utf8(out.stdout) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(parsed) = urls.lines().find_map(parse_gitlawb_url) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((did, repo))
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a gitlawb:// URL string into (did, repo) — extracted for testing.
|
||||
#[cfg(test)]
|
||||
/// Parse a gitlawb:// URL string into (did, repo).
|
||||
fn parse_gitlawb_url(url: &str) -> Option<(String, String)> {
|
||||
let rest = url.trim().strip_prefix("gitlawb://")?;
|
||||
let slash = rest.rfind('/')?;
|
||||
|
||||
+11
-13
@@ -132,23 +132,11 @@ async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
fn sanitize_node_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !is_bidi_format(*c))
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Unicode bidirectional and directional-isolate format characters (category
|
||||
/// `Cf`). These are not `char::is_control()` (that is category `Cc` only), but a
|
||||
/// right-to-left override or isolate can visually reorder a terminal line to
|
||||
/// spoof the error text, so they are stripped alongside the control bytes.
|
||||
fn is_bidi_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
|
||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
|
||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -293,6 +281,16 @@ mod tests {
|
||||
assert_eq!(sanitize_node_msg(&long).chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_legitimate_and_rtl_text() {
|
||||
// Must not over-strip: a plain word, a genuine RTL SCRIPT letter (Arabic
|
||||
// U+0627, category Lo — NOT a format char), and ZWJ (U+200D, a legitimate
|
||||
// Cf char, e.g. emoji sequences) all survive. Guards the shared predicate
|
||||
// against being widened into a blanket Cf stripper.
|
||||
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
|
||||
assert_eq!(out, "ok \u{0627}\u{200D}b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_handles_oversized_error_body_without_unbounded_output() {
|
||||
// A hostile/broken node returns a 2 MB error body. The command must still
|
||||
|
||||
+169
-26
@@ -42,6 +42,8 @@ pub enum WebhookCmd {
|
||||
repo: String,
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Delete a webhook
|
||||
Delete {
|
||||
@@ -66,7 +68,7 @@ pub async fn run(args: WebhookArgs) -> Result<()> {
|
||||
node,
|
||||
dir,
|
||||
} => cmd_create(repo, url, events, secret, node, dir).await,
|
||||
WebhookCmd::List { repo, node } => cmd_list(repo, node).await,
|
||||
WebhookCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
|
||||
WebhookCmd::Delete {
|
||||
repo,
|
||||
id,
|
||||
@@ -76,15 +78,6 @@ pub async fn run(args: WebhookArgs) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_owner(client: &NodeClient) -> Result<String> {
|
||||
let info: Value = client.get("/").await?.json().await?;
|
||||
let did = info["did"]
|
||||
.as_str()
|
||||
.context("node missing DID")?
|
||||
.to_string();
|
||||
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
|
||||
}
|
||||
|
||||
async fn cmd_create(
|
||||
repo: String,
|
||||
url: String,
|
||||
@@ -94,8 +87,8 @@ async fn cmd_create(
|
||||
dir: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
let owner = resolve_owner(&client).await?;
|
||||
|
||||
let event_list: Vec<&str> = events.split(',').map(str::trim).collect();
|
||||
|
||||
@@ -106,7 +99,7 @@ async fn cmd_create(
|
||||
}))?;
|
||||
|
||||
let resp = client
|
||||
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &payload)
|
||||
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
@@ -143,18 +136,31 @@ async fn cmd_create(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_list(repo: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let owner = resolve_owner(&client).await?;
|
||||
async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
// The node owner-gates GET /hooks (callback URLs are owner-secret), so the
|
||||
// list request must be signed — anonymous callers get 401.
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
// get_signed (not get) attaches the RFC 9421 signature — plain get() never
|
||||
// signs, and the node owner-gates this route, so an unsigned GET 401s.
|
||||
let resp = client
|
||||
.get_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
let hooks = resp["webhooks"].as_array().cloned().unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list webhooks failed ({status}): {msg}");
|
||||
}
|
||||
|
||||
let hooks = body
|
||||
.get("webhooks")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.context("node response missing webhooks array")?;
|
||||
if hooks.is_empty() {
|
||||
println!("No webhooks for {repo}");
|
||||
return Ok(());
|
||||
@@ -185,13 +191,13 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
|
||||
|
||||
async fn cmd_delete(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let (owner, name) = crate::repo::resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
let owner = resolve_owner(&client).await?;
|
||||
|
||||
let payload = serde_json::to_vec(&serde_json::json!({}))?;
|
||||
let resp = client
|
||||
.delete(
|
||||
&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"),
|
||||
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
@@ -337,33 +343,170 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_list_webhooks_empty() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (dir, _kp) = tmp_identity();
|
||||
let _root = mock_root(&mut server).await;
|
||||
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
|
||||
// The route is owner-gated, so the list request must be signed.
|
||||
// Requiring the header here is what catches a regression to plain get().
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list("my-repo".to_string(), server.url()).await.unwrap();
|
||||
cmd_list(
|
||||
"my-repo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_webhooks_with_items() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (dir, _kp) = tmp_identity();
|
||||
let _root = mock_root(&mut server).await;
|
||||
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[{"id":"h1","url":"https://a.com","events":["push"],"active":true},{"id":"h2","url":"https://b.com","events":["*"],"active":false}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list("my-repo".to_string(), server.url()).await.unwrap();
|
||||
cmd_list(
|
||||
"my-repo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_owner_comes_from_keypair_not_node_did() {
|
||||
// The owner segment must be derived from the caller's keypair (loaded
|
||||
// from --dir), NOT the node root DID. mock_root returns a different DID;
|
||||
// if the code regressed to using it, this path mock would not match.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (dir, kp) = tmp_identity();
|
||||
let _root = mock_root(&mut server).await;
|
||||
|
||||
let did = kp.did().to_string();
|
||||
let short = did.split(':').next_back().unwrap_or(&did).to_string();
|
||||
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(format!(r"/api/v1/repos/{short}/my-repo/hooks$")),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[]}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(
|
||||
"my-repo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_honors_owner_repo_arg() {
|
||||
// An explicit `owner/repo` argument is split and used verbatim.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (dir, _kp) = tmp_identity();
|
||||
let _root = mock_root(&mut server).await;
|
||||
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/repos/someoneelse/their-repo/hooks$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"webhooks":[]}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(
|
||||
"someoneelse/their-repo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_webhooks_non_success_status_errors() {
|
||||
// A 403 (or any non-2xx) JSON body has no `webhooks` field; cmd_list must
|
||||
// surface the failure, not print "No webhooks" and exit 0.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (dir, _kp) = tmp_identity();
|
||||
let _root = mock_root(&mut server).await;
|
||||
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not the owner"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(
|
||||
"my-repo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not the owner"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_webhooks_2xx_missing_array_errors() {
|
||||
// A 2xx body without a `webhooks` array signals a node/API contract
|
||||
// regression. cmd_list must error rather than print "No webhooks".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (dir, _kp) = tmp_identity();
|
||||
let _root = mock_root(&mut server).await;
|
||||
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
|
||||
.match_header("signature", mockito::Matcher::Any)
|
||||
.match_header("signature-input", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"ok":true}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(
|
||||
"my-repo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("missing webhooks array"));
|
||||
}
|
||||
|
||||
// ── delete ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,3 +15,7 @@ serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
@@ -19,6 +19,7 @@ use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub mod pow;
|
||||
pub mod solvers;
|
||||
|
||||
/// Default iCaptcha service base URL (used when the node doesn't advertise one).
|
||||
@@ -63,6 +64,12 @@ fn sanitize_excerpt(s: &str) -> String {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if gitlawb_core::sanitize::is_bidi_format(ch) {
|
||||
// Drop Cf bidi/format controls (INV-6, #183) without setting
|
||||
// pending_space: they are not whitespace, so stripping leaves no
|
||||
// phantom gap.
|
||||
continue;
|
||||
}
|
||||
if ch.is_whitespace() {
|
||||
pending_space = true;
|
||||
continue;
|
||||
@@ -81,18 +88,42 @@ fn sanitize_excerpt(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `u` parses as an `https` URL.
|
||||
/// Whether `u` parses as a trusted URL.
|
||||
///
|
||||
/// 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 {
|
||||
reqwest::Url::parse(u)
|
||||
.map(|p| p.scheme() == "https")
|
||||
.unwrap_or(false)
|
||||
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()
|
||||
&& parsed.scheme() == "http"
|
||||
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Lowercased host of a URL, if it parses and has one.
|
||||
fn host_of(u: &str) -> Option<String> {
|
||||
reqwest::Url::parse(u)
|
||||
.ok()
|
||||
.and_then(|p| p.host_str().map(|h| h.to_ascii_lowercase()))
|
||||
/// Lowercased origin string (`scheme://host[:port]`) of a URL.
|
||||
///
|
||||
/// Includes the effective port so that `http://localhost:3000` and
|
||||
/// `http://localhost:9000` are treated as distinct origins — a hostile node
|
||||
/// cannot redirect the iCaptcha solve to a different loopback port.
|
||||
fn origin_key(u: &str) -> Option<String> {
|
||||
let parsed = reqwest::Url::parse(u).ok()?;
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.map(|p| format!(":{p}"))
|
||||
.unwrap_or_default();
|
||||
Some(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
/// Decide which iCaptcha origin to actually talk to, and whether it is trusted
|
||||
@@ -109,12 +140,13 @@ fn host_of(u: &str) -> Option<String> {
|
||||
/// 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));
|
||||
let operator_host = operator.and_then(host_of);
|
||||
let default_host = host_of(DEFAULT_URL);
|
||||
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
|
||||
let default_origin = origin_key(DEFAULT_URL);
|
||||
|
||||
let allowed = |host: &Option<String>| -> bool {
|
||||
host.as_ref()
|
||||
.map(|h| Some(h) == default_host.as_ref() || Some(h) == operator_host.as_ref())
|
||||
let allowed = |origin: &Option<String>| -> bool {
|
||||
origin
|
||||
.as_ref()
|
||||
.map(|o| Some(o) == default_origin.as_ref() || Some(o) == operator_origin.as_ref())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
@@ -125,7 +157,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
|
||||
};
|
||||
|
||||
let chosen = match advertised {
|
||||
Some(a) if is_https(a) && allowed(&host_of(a)) => a.to_string(),
|
||||
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
|
||||
Some(a) => {
|
||||
tracing::warn!(
|
||||
advertised = %a,
|
||||
@@ -137,7 +169,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
|
||||
};
|
||||
|
||||
// Key goes only to the operator's own configured origin.
|
||||
let key_trusted = operator_host.is_some() && host_of(&chosen) == operator_host;
|
||||
let key_trusted = operator_origin.is_some() && origin_key(&chosen) == operator_origin;
|
||||
(chosen, key_trusted)
|
||||
}
|
||||
|
||||
@@ -201,6 +233,10 @@ pub struct Challenge {
|
||||
pub difficulty: u32,
|
||||
pub prompt: String,
|
||||
pub token: String,
|
||||
/// Proof-of-work to solve and echo back as `powNonce` on the answer. Present
|
||||
/// only when the service has PoW enabled; absent/None means no PoW required.
|
||||
#[serde(default)]
|
||||
pub pow: Option<pow::PowChallenge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -237,7 +273,28 @@ pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result<String
|
||||
)
|
||||
})?;
|
||||
|
||||
match submit_answer(&client, cfg, &challenge.token, &answer)? {
|
||||
// Solve the proof-of-work bound to this challenge, if the service
|
||||
// requires one. A required-but-unsolvable PoW (unknown algorithm or a
|
||||
// difficulty above our cap) is a hard error — submitting without it
|
||||
// would just be rejected.
|
||||
let pow_nonce = match &challenge.pow {
|
||||
Some(p) => Some(pow::solve(p).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"cannot solve iCaptcha proof-of-work (algorithm '{}', difficulty {})",
|
||||
p.algorithm,
|
||||
p.difficulty
|
||||
)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
match submit_answer(
|
||||
&client,
|
||||
cfg,
|
||||
&challenge.token,
|
||||
&answer,
|
||||
pow_nonce.as_deref(),
|
||||
)? {
|
||||
AnswerResult::Passed { proof } => return Ok(proof),
|
||||
AnswerResult::Continue { challenge: next } => challenge = next,
|
||||
AnswerResult::Failed { reason } => {
|
||||
@@ -273,11 +330,14 @@ fn submit_answer(
|
||||
cfg: &IcaptchaCfg,
|
||||
token: &str,
|
||||
answer: &str,
|
||||
pow_nonce: Option<&str>,
|
||||
) -> Result<AnswerResult> {
|
||||
let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/'));
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.json(&json!({ "token": token, "answer": answer }));
|
||||
let mut body = json!({ "token": token, "answer": answer });
|
||||
if let Some(nonce) = pow_nonce {
|
||||
body["powNonce"] = json!(nonce);
|
||||
}
|
||||
let mut req = client.post(&url).json(&body);
|
||||
if let Some(key) = &cfg.api_key {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
@@ -320,6 +380,37 @@ fn interactive_prompt(challenge: &Challenge) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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.
|
||||
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
|
||||
/// prior value on drop.
|
||||
struct InsecureEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
prev: Option<OsString>,
|
||||
}
|
||||
|
||||
impl InsecureEnv {
|
||||
fn new() -> Self {
|
||||
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
|
||||
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
|
||||
InsecureEnv { _lock: lock, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InsecureEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
|
||||
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1: hostile error bodies must not reach the terminal raw ──────────
|
||||
|
||||
@@ -336,6 +427,34 @@ mod tests {
|
||||
assert!(clean.contains("owned") && clean.contains("done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_bidi_format_controls() {
|
||||
// The Cc-only test above is vacuous for Cf: it passes whether or not bidi
|
||||
// controls survive, which is why this gap shipped (#183). Here every Cf
|
||||
// bidi class is stripped — override (RLO), embedding + pop (LRE/PDF),
|
||||
// isolate (LRI/PDI), marks (LRM/ALM) — asserted per code point, and the
|
||||
// visible words JOIN with no phantom space (a bidi char is dropped like a
|
||||
// control byte, but unlike a newline/tab it is not whitespace). The leading
|
||||
// bidi char (index 0) also exercises the empty-out path.
|
||||
let out = sanitize_excerpt("\u{202e}err\u{202a}\u{202c}\u{2066}\u{2069}\u{200e}\u{061c}ok");
|
||||
for c in [
|
||||
'\u{202e}', '\u{202a}', '\u{202c}', '\u{2066}', '\u{2069}', '\u{200e}', '\u{061c}',
|
||||
] {
|
||||
assert!(!out.contains(c), "bidi U+{:04X} leaked: {out:?}", c as u32);
|
||||
}
|
||||
assert_eq!(out, "errok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_legitimate_and_rtl_text() {
|
||||
// Must not over-strip: plain text, a genuine RTL SCRIPT letter (Arabic
|
||||
// U+0627, category Lo), and ZWJ (U+200D, a legitimate Cf char) survive.
|
||||
assert_eq!(
|
||||
sanitize_excerpt("ok \u{0627}\u{200D}b"),
|
||||
"ok \u{0627}\u{200D}b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_collapses_whitespace_and_caps_length() {
|
||||
let collapsed = sanitize_excerpt("a\n\n\t b");
|
||||
@@ -344,6 +463,18 @@ mod tests {
|
||||
assert_eq!(sanitize_excerpt(&long).chars().count(), MAX_ERR_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_bidi_chars_do_not_consume_the_length_cap() {
|
||||
// A dropped bidi char must not count toward MAX_ERR_CHARS: it is dropped
|
||||
// before `kept` increments, so interleaving bidi controls cannot truncate
|
||||
// the visible output early. MAX_ERR_CHARS + 100 visible 'x', each trailed by
|
||||
// a bidi override, must still yield exactly MAX_ERR_CHARS visible 'x' with
|
||||
// no phantom spaces. (Would regress to fewer 'x' if bidi consumed budget, or
|
||||
// to spaces if the drop branch wrongly set pending_space.)
|
||||
let body = "x\u{202e}".repeat(MAX_ERR_CHARS + 100);
|
||||
assert_eq!(sanitize_excerpt(&body), "x".repeat(MAX_ERR_CHARS));
|
||||
}
|
||||
|
||||
// ── P2: never talk to (or hand a key to) an untrusted node-chosen URL ──
|
||||
|
||||
#[test]
|
||||
@@ -403,4 +534,20 @@ mod tests {
|
||||
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
|
||||
// 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));
|
||||
assert_eq!(
|
||||
url, op,
|
||||
"must fall back to operator origin, not use different port"
|
||||
);
|
||||
assert!(key_trusted, "key stays trusted with operator origin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Proof-of-work solver for the iCaptcha answer step.
|
||||
//!
|
||||
//! iCaptcha requires a small proof-of-work with each answer so that minting a
|
||||
//! proof costs CPU — a per-proof cost a distributed abuser cannot dodge by
|
||||
//! rotating IPs. The work is bound to the per-challenge id: find a `nonce` such
|
||||
//! that `sha256("{challenge}:{nonce}")` has at least `difficulty` leading zero
|
||||
//! bits. This mirrors `icaptcha/src/pow.ts`.
|
||||
//!
|
||||
//! At the service default (~20 bits) this is well under a second for a single
|
||||
//! solve; we still cap the search so a hostile/misconfigured difficulty can
|
||||
//! never hang the client.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// PoW parameters advertised by the service in a challenge (mirrors the
|
||||
/// service's `PowChallenge`).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct PowChallenge {
|
||||
/// Only `sha256-leading-zero-bits` is understood; other algorithms are
|
||||
/// rejected by [`solve`] rather than silently mis-solved.
|
||||
pub algorithm: String,
|
||||
/// The string to hash against (the per-challenge id).
|
||||
pub challenge: String,
|
||||
/// Required leading zero bits.
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
/// Hard cap on nonce search iterations. 2^26 ≈ 67M keeps a ~20-bit target (~1M
|
||||
/// expected hashes) comfortably solvable while bounding worst-case work if the
|
||||
/// service ever advertises an unexpectedly high difficulty.
|
||||
const MAX_ITERS: u64 = 1 << 26;
|
||||
|
||||
const ALGORITHM: &str = "sha256-leading-zero-bits";
|
||||
|
||||
/// Leading zero bits of a byte slice.
|
||||
fn leading_zero_bits(bytes: &[u8]) -> u32 {
|
||||
let mut bits = 0;
|
||||
for &b in bytes {
|
||||
if b == 0 {
|
||||
bits += 8;
|
||||
} else {
|
||||
bits += b.leading_zeros();
|
||||
break;
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// sha256(`{challenge}:{nonce}`).
|
||||
fn pow_hash(challenge: &str, nonce: &str) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(challenge.as_bytes());
|
||||
hasher.update(b":");
|
||||
hasher.update(nonce.as_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Solve the PoW, returning a nonce whose hash meets the difficulty. Returns
|
||||
/// `None` for an unknown algorithm or if no nonce is found within the cap (a
|
||||
/// difficulty far above what the service uses in practice).
|
||||
pub fn solve(pow: &PowChallenge) -> Option<String> {
|
||||
if pow.algorithm != ALGORITHM {
|
||||
tracing::warn!(algorithm = %pow.algorithm, "unknown iCaptcha PoW algorithm; cannot solve");
|
||||
return None;
|
||||
}
|
||||
if pow.difficulty == 0 {
|
||||
return Some("0".to_string());
|
||||
}
|
||||
for i in 0..MAX_ITERS {
|
||||
let nonce = format!("{i:x}");
|
||||
if leading_zero_bits(&pow_hash(&pow.challenge, &nonce)) >= pow.difficulty {
|
||||
return Some(nonce);
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
difficulty = pow.difficulty,
|
||||
max_iters = MAX_ITERS,
|
||||
"iCaptcha PoW not solved within iteration cap"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pow(challenge: &str, difficulty: u32) -> PowChallenge {
|
||||
PowChallenge {
|
||||
algorithm: ALGORITHM.to_string(),
|
||||
challenge: challenge.to_string(),
|
||||
difficulty,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_zero_bits_counts_across_bytes() {
|
||||
assert_eq!(leading_zero_bits(&[0xff]), 0);
|
||||
assert_eq!(leading_zero_bits(&[0x7f]), 1);
|
||||
assert_eq!(leading_zero_bits(&[0x01]), 7);
|
||||
assert_eq!(leading_zero_bits(&[0x00, 0xff]), 8);
|
||||
assert_eq!(leading_zero_bits(&[0x00, 0x01]), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solves_and_the_solution_verifies() {
|
||||
let p = pow("challenge-abc", 12);
|
||||
let nonce = solve(&p).expect("should solve a 12-bit target");
|
||||
assert!(leading_zero_bits(&pow_hash(&p.challenge, &nonce)) >= 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solution_is_bound_to_the_challenge() {
|
||||
// A nonce solving one challenge should (overwhelmingly) not solve another
|
||||
// at the same difficulty — the work is challenge-specific.
|
||||
let nonce = solve(&pow("challenge-A", 12)).unwrap();
|
||||
assert!(leading_zero_bits(&pow_hash("challenge-B", &nonce)) < 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_algorithm() {
|
||||
let mut p = pow("c", 8);
|
||||
p.algorithm = "scrypt".to_string();
|
||||
assert!(solve(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_difficulty_is_trivially_solved() {
|
||||
assert_eq!(solve(&pow("c", 0)).as_deref(), Some("0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! INV-19 guard: the icaptcha flow must actually call the service (consume the
|
||||
//! mock), not short-circuit or make a live call. If a future change let
|
||||
//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which
|
||||
//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go
|
||||
//! RED because the mocked endpoints were never hit.
|
||||
//!
|
||||
//! The mock-consumed assertions alone only prove the mock was hit; they cannot
|
||||
//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or
|
||||
//! any other origin (a different host the mock never observes). To make the
|
||||
//! "no live call" half of the guard load-bearing, the test blackholes every
|
||||
//! non-loopback destination: `NO_PROXY` lets the loopback mock through while a
|
||||
//! closed proxy port routes any other host nowhere, so the client contacting
|
||||
//! anything but the injected mock fails the request instead of silently
|
||||
//! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED.
|
||||
//!
|
||||
//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific
|
||||
//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so
|
||||
//! a runner with a working `HTTPS_PROXY` in its environment would route an https
|
||||
//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback
|
||||
//! mock is still consumed and this test stayed green. So we blackhole the
|
||||
//! scheme-specific variables too. No restore is needed: this file has a single
|
||||
//! `#[test]`, so it runs in its own process and the override never races a
|
||||
//! sibling test.
|
||||
|
||||
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
|
||||
|
||||
#[test]
|
||||
fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
|
||||
// Blackhole every non-loopback origin: the loopback mock bypasses the proxy
|
||||
// (NO_PROXY); any other host is forced through a closed port and fails.
|
||||
// reqwest reads these at client-build time, and obtain_proof builds its
|
||||
// client fresh, so this bounds the transport for the whole flow.
|
||||
//
|
||||
// Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest
|
||||
// gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so
|
||||
// an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a
|
||||
// runner whose environment already has a working HTTPS_PROXY.
|
||||
let blackhole = "http://127.0.0.1:1";
|
||||
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||
std::env::set_var("no_proxy", "127.0.0.1,localhost");
|
||||
for var in [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
] {
|
||||
std::env::set_var(var, blackhole);
|
||||
}
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
|
||||
// The two endpoints the flow hits, each expected exactly once.
|
||||
let challenge = server
|
||||
.mock("POST", "/v1/challenge")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"challengeId":"c1","type":"anagram","difficulty":1,"prompt":"listen","token":"tok-1"}"#,
|
||||
)
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let answer = server
|
||||
.mock("POST", "/v1/answer")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"passed","proof":"PROOF-XYZ"}"#)
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let cfg = IcaptchaCfg {
|
||||
url: server.url(),
|
||||
did: "did:key:zTEST".to_string(),
|
||||
level: 1,
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
// "anagram" is not a built-in solvable type, so the flow consults this
|
||||
// solver callback. The answer body itself does not matter: the mocked
|
||||
// /v1/answer returns "passed" regardless of what is submitted.
|
||||
let solve = |_c: &Challenge| Some("silent".to_string());
|
||||
let solver: &dyn Fn(&Challenge) -> Option<String> = &solve;
|
||||
|
||||
let proof = obtain_proof(&cfg, Some(solver))
|
||||
.expect("obtain_proof should complete against the mocked service");
|
||||
assert_eq!(proof, "PROOF-XYZ");
|
||||
|
||||
// INV-19: prove the client actually called the mocked endpoints. Had it made
|
||||
// a live call or skipped the network, these would fail (mock never hit).
|
||||
challenge.assert();
|
||||
answer.assert();
|
||||
}
|
||||
+14
@@ -193,5 +193,19 @@ else
|
||||
echo " gl quickstart"
|
||||
fi
|
||||
|
||||
# oh-my-zsh's default git plugin aliases gl='git pull', which silently shadows
|
||||
# the gl binary in every interactive zsh (aliases beat PATH). Detect the common
|
||||
# setup and say so now, at install time, instead of letting the first `gl`
|
||||
# print git's baffling "fatal: not a git repository".
|
||||
if [ -f "$HOME/.zshrc" ] && [ -d "$HOME/.oh-my-zsh/plugins/git" ] \
|
||||
&& grep -Eq '^[[:space:]]*plugins=\(([^)]*[[:space:]])?git([[:space:]][^)]*)?\)' "$HOME/.zshrc" \
|
||||
&& ! grep -Eq '^([^#]*[[:space:];&|])?unalias[[:space:]]+([^;&|#]*[[:space:]])?gl([[:space:]]|$)' "$HOME/.zshrc"; then
|
||||
echo ""
|
||||
echo "NOTE: oh-my-zsh's git plugin aliases gl='git pull', which will shadow"
|
||||
echo "the gl command in interactive shells. To use gl by name, run:"
|
||||
echo ""
|
||||
echo " echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Docs: https://docs.gitlawb.com"
|
||||
Reference in new issue
Block a user