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