mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1ba9ee505a | ||
|
|
e1e6e1cf83 | ||
|
|
82fec97a97 | ||
|
|
9be5eac3a2 | ||
|
|
6b2ec93de1 |
No files matched your search
@@ -6,6 +6,13 @@ on:
|
||||
# result before it lands. Guards against semantic merge races where two
|
||||
# individually-green PRs break main in combination (#113 + #145 → #229).
|
||||
merge_group:
|
||||
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
|
||||
# and the lock-sync job), and GitHub's recursion guard means those pushes
|
||||
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
|
||||
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
|
||||
# the release branch to attach check runs to its head. Read-only workflow
|
||||
# (contents: read), so the dispatch surface adds no privilege.
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -46,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 }})
|
||||
@@ -94,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
|
||||
@@ -117,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
|
||||
@@ -199,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
|
||||
@@ -246,7 +253,7 @@ jobs:
|
||||
key: windows
|
||||
|
||||
- name: cargo test (shipped Windows crates)
|
||||
run: cargo test -p gl -p git-remote-gitlawb
|
||||
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
|
||||
|
||||
@@ -41,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
|
||||
@@ -48,6 +49,143 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# The release PR bumps the workspace crate versions in the Cargo.tomls but
|
||||
# release-please cannot update Cargo.lock, so without this job every release
|
||||
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
|
||||
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
|
||||
# the release branch so the tagged tree is internally consistent.
|
||||
#
|
||||
# Runs on every push-triggered release run, not only when release-please
|
||||
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
|
||||
# the release PR, so gating on it would leave an already-open release PR
|
||||
# (opened before this job existed, or untouched by a non-releasable push)
|
||||
# permanently without the sync and without check runs. The resolve step
|
||||
# discovers any open release-please PR itself and no-ops when there is none.
|
||||
sync-release-lock:
|
||||
name: Sync Cargo.lock on the release PR
|
||||
needs: release-please
|
||||
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
# actions: write lets the final step dispatch pr-checks on the release
|
||||
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
|
||||
# scope it to one workflow); acceptable here because reaching this job's
|
||||
# steps already requires write to main. Needed because every push to the
|
||||
# release branch (release-please's and the lock sync below) is made with
|
||||
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
|
||||
# dispatch the release PR's head carries no check runs and the --locked
|
||||
# gate never validates it.
|
||||
actions: write
|
||||
steps:
|
||||
# Prefer the fresh `pr` output when this run just created/updated the
|
||||
# release PR; otherwise fall back to querying for an open release-please
|
||||
# branch (prefix match: the branch carries `--components--` suffixes).
|
||||
- name: Resolve the open release PR branch
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
|
||||
run: |
|
||||
branch="$BRANCH_FROM_OUTPUT"
|
||||
if [ -z "$branch" ]; then
|
||||
# The branch name alone is not a trust signal. `gh pr list` includes
|
||||
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
|
||||
# prefix match by itself lets any fork nominate the branch this job
|
||||
# checks out, commits to, and dispatches checks on. Require the head
|
||||
# repo to be THIS repository and the author to be the release-please
|
||||
# app, so neither a fork nor a same-repo human branch named
|
||||
# `release-please--*` can be selected. Then take the highest PR
|
||||
# number: `[0]` depended on gh's default ordering, which is not a
|
||||
# documented guarantee, and picking a stable one keeps a second
|
||||
# (component) release PR from making the choice flap between runs.
|
||||
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--json headRefName,headRepositoryOwner,number,author \
|
||||
--jq '[ .[]
|
||||
| select(.headRepositoryOwner.login == env.OWNER)
|
||||
| select(.author.login == "app/github-actions")
|
||||
| select(.headRefName | startswith("release-please--"))
|
||||
] | max_by(.number).headRefName // empty')
|
||||
fi
|
||||
if [ -z "$branch" ]; then
|
||||
echo "no open release PR; nothing to sync"
|
||||
fi
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release branch
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.branch }}
|
||||
|
||||
# Resolve on a declared toolchain instead of whatever Rust the runner
|
||||
# image happens to ship, so the lock this job writes comes off the same
|
||||
# stable channel the other cargo jobs use. Not a claim of identical
|
||||
# resolution: `stable` still moves between runs, and the MSRV gate
|
||||
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
|
||||
# runner image as an undeclared input.
|
||||
- name: Install Rust toolchain
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Sync workspace crate versions into Cargo.lock
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
run: cargo update --workspace
|
||||
|
||||
- name: Commit and push when the lock changed
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
run: |
|
||||
if git diff --quiet Cargo.lock; then
|
||||
echo "Cargo.lock already in sync"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Cargo.lock
|
||||
git commit -m "chore: sync Cargo.lock with the release version bump"
|
||||
git push
|
||||
|
||||
# Every push to the release branch is made with GITHUB_TOKEN (release-
|
||||
# please's own pushes and the lock sync above), and events created by
|
||||
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
|
||||
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
|
||||
# branch to attach check runs to its current head. Unconditional (also on
|
||||
# the already-in-sync path) because the branch's ORIGINAL head has the
|
||||
# same no-runs problem. Loud on failure by design: a silent fallback
|
||||
# would recreate the checkless-head problem, and a job rerun is safe
|
||||
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
|
||||
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
|
||||
# is the sync commit. Merge-queue note: this validates the branch tip;
|
||||
# the enforced merge queue's merge_group run still re-validates the true
|
||||
# merged result before landing.
|
||||
- name: Dispatch PR checks on the release branch
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ steps.resolve.outputs.branch }}
|
||||
run: |
|
||||
# Every dispatch failure fails the job, including the 422 a release
|
||||
# branch cut before pr-checks gained its workflow_dispatch trigger
|
||||
# returns ("does not have a workflow_dispatch trigger", reported
|
||||
# against the dispatched ref's copy of the file). An earlier revision
|
||||
# tolerated exactly that 422 on the theory that release-please would
|
||||
# rewrite the branch and heal it, but a rewrite only happens when a
|
||||
# later releasable commit lands: an open release PR nobody touches is
|
||||
# never rewritten. Tolerating it therefore left the lock-sync commit
|
||||
# this job had just pushed sitting on the release head with no
|
||||
# --locked validation, which is the precise state this workflow exists
|
||||
# to prevent. The tolerance is also no longer needed: once this lands,
|
||||
# every release branch is cut from a main that already carries the
|
||||
# trigger, so the only branch that could 422 is one open at merge time
|
||||
# (there is none), and a job rerun after a manual rebase is safe (the
|
||||
# sync no-ops, the dispatch retries).
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
|
||||
-f "ref=$BRANCH"
|
||||
|
||||
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
|
||||
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
|
||||
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
|
||||
@@ -308,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,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.0"
|
||||
".": "0.7.1"
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
|
||||
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
|
||||
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
|
||||
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
|
||||
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
|
||||
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
|
||||
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
|
||||
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
|
||||
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
|
||||
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
|
||||
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
|
||||
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
|
||||
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
|
||||
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
|
||||
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
|
||||
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
|
||||
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
|
||||
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
|
||||
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
|
||||
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
|
||||
|
||||
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
|
||||
|
||||
|
||||
|
||||
Generated
+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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.7.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.7.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.7.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.7.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -883,6 +883,24 @@ const MIGRATIONS: &[Migration] = &[
|
||||
"ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT",
|
||||
],
|
||||
},
|
||||
// Reservation: v17, deliberately not main's current_max + 1 (which is 12).
|
||||
// The runner keys the applied set on the integer alone, so a version another
|
||||
// in-flight branch also claims is skipped in full on whichever side merges
|
||||
// second — no error, no warning, and schema_migrations still reads healthy
|
||||
// while the column is simply absent. Two open branches already claim into
|
||||
// this range: #135/#173 holds through 14 (15 once it rebases past v11), and
|
||||
// #253 took 16. 17 clears both. Gaps are harmless: the runner iterates the
|
||||
// array and never requires contiguity.
|
||||
Migration {
|
||||
version: 17,
|
||||
name: "sync_queue_attempted_at",
|
||||
stmts: &[
|
||||
// Scheduling key for dequeue_pending_syncs: when the row was last
|
||||
// handed to a worker. Null until first dequeued, which is why the
|
||||
// ordering coalesces onto enqueued_at.
|
||||
"ALTER TABLE sync_queue ADD COLUMN IF NOT EXISTS attempted_at TEXT",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Repos ─────────────────────────────────────────────────────────────────────
|
||||
@@ -899,6 +917,10 @@ pub(crate) fn normalize_owner_key(did: &str) -> &str {
|
||||
/// drift apart. If you change `normalize_owner_key`, update this const too.
|
||||
const OWNER_KEY_CASE_SQL: &str = "CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END";
|
||||
|
||||
/// SQL CASE expression byte-identical to `normalize_owner_key`, but for columns
|
||||
/// named `did` (like in agent_profiles) instead of `owner_did`.
|
||||
const PROFILE_DID_CASE_SQL: &str = "CASE WHEN did LIKE 'did:key:%' AND position(':' in substr(did, 9)) = 0 THEN substr(did, 9) ELSE did END";
|
||||
|
||||
#[cfg(test)]
|
||||
mod normalize_owner_key_tests {
|
||||
use super::normalize_owner_key;
|
||||
@@ -1637,13 +1659,46 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take up to `limit` pending syncs — the least recently attempted ones —
|
||||
/// and stamp each with the time it was handed out.
|
||||
///
|
||||
/// Selecting and stamping in one statement is deliberate. A row the worker
|
||||
/// cannot make progress on stays `pending` so it is retried, and if its
|
||||
/// ordering key never moved it would remain among the oldest rows forever,
|
||||
/// holding a fixed-size window against every healthy repo behind it.
|
||||
/// Stamping on the way out makes the key "least recently handed out", so a
|
||||
/// stuck row rotates to the back instead. Doing it here rather than at each
|
||||
/// deferral branch in the worker is what makes that hold by construction:
|
||||
/// no call site can forget it, and a batch that dies mid-loop still leaves
|
||||
/// its rows stamped. `enqueued_at` is left alone so backlog age stays
|
||||
/// measurable.
|
||||
///
|
||||
/// Two things this deliberately does not promise. The returned rows are the
|
||||
/// right *set*, in no particular order — `RETURNING` does not sort, and
|
||||
/// nothing in `process_batch` depends on the order within a batch. And this
|
||||
/// is not a claim: the rows stay `pending` with no row lock held past the
|
||||
/// statement, so two workers against one database can still be handed the
|
||||
/// same batch. Single-worker deployment is the existing assumption;
|
||||
/// `FOR UPDATE SKIP LOCKED` is what would change that, and it is not here.
|
||||
///
|
||||
/// Errors surface to the caller, which logs and skips the poll. That is
|
||||
/// worth knowing now that this writes: it can fail for reasons a plain
|
||||
/// SELECT could not, such as a read-only transaction or a lock timeout.
|
||||
pub async fn dequeue_pending_syncs(&self, limit: i64) -> Result<Vec<SyncQueueItem>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, repo, node_did, ref_name, new_sha, cid, status, enqueued_at
|
||||
FROM sync_queue WHERE status = 'pending'
|
||||
ORDER BY enqueued_at ASC LIMIT $1",
|
||||
// The outer `status = 'pending'` is not redundant with the
|
||||
// subquery's: between the two, a concurrent worker can settle a row,
|
||||
// and without it the UPDATE would still stamp and return a row that
|
||||
// had already left the pending set.
|
||||
"UPDATE sync_queue SET attempted_at = $2
|
||||
WHERE status = 'pending' AND id IN (
|
||||
SELECT id FROM sync_queue WHERE status = 'pending'
|
||||
ORDER BY COALESCE(attempted_at, enqueued_at) ASC LIMIT $1
|
||||
)
|
||||
RETURNING id, repo, node_did, ref_name, new_sha, cid, status, enqueued_at",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
@@ -2089,10 +2144,46 @@ impl Db {
|
||||
anyhow::bail!("refusing to register non-public peer http_url: {http_url}");
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
// A changed URL drops the reachability gate, so a repointed peer does
|
||||
// not inherit a probe the previous host earned. In the DO UPDATE branch
|
||||
// `peers.http_url` is the existing pre-update row (the proposed value is
|
||||
// $2), and the comparison runs under the conflict row lock, so
|
||||
// concurrent announces for one DID serialize instead of racing a
|
||||
// read-then-write. That orders announces against each other and nothing
|
||||
// more: mark_peer_ping writes by DID with no http_url predicate, so a
|
||||
// probe of the previous URL that lands after a reset can still re-grant
|
||||
// the flag until the next round.
|
||||
//
|
||||
// Comparison is exact. http_url is stored as announced, so a cosmetic
|
||||
// difference such as a trailing slash also clears the flag; the peer
|
||||
// re-earns it on a later gossip round, provided no further announce
|
||||
// lands first. Normalizing instead would mean canonicalizing the stored
|
||||
// value, this comparison, and the existing trim_end_matches call sites.
|
||||
//
|
||||
// last_ping_ok is NOT a trust signal. An unauthenticated caller can
|
||||
// clear it by announcing a different URL, and until #248 lands can also
|
||||
// set it through the unauthenticated GET /api/v1/peers/{did}/ping, which
|
||||
// writes mark_peer_ping from the stored URL's own probe response. Do not
|
||||
// build a new consumer on this flag as if it were attacker-resistant.
|
||||
//
|
||||
// Only the federated repo fan-out in api/repos.rs gates on this flag.
|
||||
// Four consumers act on a repointed http_url regardless of it (sync.rs's
|
||||
// origin resolve, the post-receive notify fan-out, trigger_sync, and the
|
||||
// public resolve route), and two read surfaces republish it as
|
||||
// `reachable` (api/resolve.rs, api/peers.rs), which is where a reset
|
||||
// becomes externally visible. So this bounds the automatic inheritance
|
||||
// rather than closing the rewrite. Binding a DID to its first-seen
|
||||
// announcing key is what closes it: #273.
|
||||
sqlx::query(
|
||||
"INSERT INTO peers (did, http_url, last_seen, last_ping_ok, announced_at)
|
||||
VALUES ($1, $2, $3, FALSE, $3)
|
||||
ON CONFLICT(did) DO UPDATE SET http_url = $2, last_seen = $3",
|
||||
ON CONFLICT(did) DO UPDATE SET
|
||||
http_url = $2,
|
||||
last_seen = $3,
|
||||
last_ping_ok = CASE
|
||||
WHEN peers.http_url IS DISTINCT FROM $2 THEN FALSE
|
||||
ELSE peers.last_ping_ok
|
||||
END",
|
||||
)
|
||||
.bind(did)
|
||||
.bind(http_url)
|
||||
@@ -3367,6 +3458,9 @@ impl Db {
|
||||
let new_website = website.or(existing.website.as_deref());
|
||||
let new_socials = socials.or(existing.socials.as_deref());
|
||||
|
||||
// get_profile equates bare short ids with did:key:<id>. UPDATE must target
|
||||
// the stored row identity (existing.did), not the caller's raw input form,
|
||||
// or a did:key: alias against a bare-stored profile updates zero rows.
|
||||
sqlx::query(
|
||||
"UPDATE agent_profiles
|
||||
SET display_name=$1, bio=$2, avatar_url=$3, website=$4, socials=$5, updated_at=$6
|
||||
@@ -3378,12 +3472,12 @@ impl Db {
|
||||
.bind(new_website)
|
||||
.bind(new_socials)
|
||||
.bind(&now)
|
||||
.bind(did)
|
||||
.bind(&existing.did)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(ProfileRecord {
|
||||
did: did.to_string(),
|
||||
did: existing.did,
|
||||
display_name: new_name.map(String::from),
|
||||
bio: new_bio.map(String::from),
|
||||
avatar_url: new_avatar.map(String::from),
|
||||
@@ -3424,14 +3518,20 @@ impl Db {
|
||||
}
|
||||
|
||||
pub async fn get_profile(&self, did: &str) -> Result<Option<ProfileRecord>> {
|
||||
let row = sqlx::query(
|
||||
// Same owner-key contract as get_repo: strip `did:key:` only when the
|
||||
// remainder is a bare key id. The old `LIKE '%:' || $1` matched any DID
|
||||
// method that shared a suffix and could resolve the wrong profile.
|
||||
let did_key = normalize_owner_key(did);
|
||||
let sql = format!(
|
||||
"SELECT did, display_name, bio, avatar_url, website, socials, profile_cid, created_at, updated_at
|
||||
FROM agent_profiles
|
||||
WHERE did = $1 OR did LIKE '%:' || $1",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
WHERE ({key}) = $1",
|
||||
key = PROFILE_DID_CASE_SQL
|
||||
);
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(did_key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| ProfileRecord {
|
||||
did: r.get("did"),
|
||||
@@ -3447,10 +3547,17 @@ impl Db {
|
||||
}
|
||||
|
||||
pub async fn set_profile_cid(&self, did: &str, cid: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE agent_profiles SET profile_cid = $1, updated_at = $2 WHERE did = $3")
|
||||
// Same did:key / bare equivalence as get_profile so a full did:key: form
|
||||
// updates a profile stored under the bare short id.
|
||||
let did_key = normalize_owner_key(did);
|
||||
let sql = format!(
|
||||
"UPDATE agent_profiles SET profile_cid = $1, updated_at = $2 WHERE ({key}) = $3",
|
||||
key = PROFILE_DID_CASE_SQL
|
||||
);
|
||||
sqlx::query(&sql)
|
||||
.bind(cid)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(did)
|
||||
.bind(did_key)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -3654,6 +3761,186 @@ mod migration_tests {
|
||||
// (d) Re-run: idempotent — ADD COLUMN IF NOT EXISTS must not error.
|
||||
db.migrate().await.unwrap();
|
||||
}
|
||||
|
||||
// ── sync_queue scheduling (attempted_at, v17) ────────────────────────────
|
||||
|
||||
async fn enqueue_one(db: &super::Db, repo: &str) {
|
||||
db.enqueue_sync(
|
||||
repo,
|
||||
"did:key:zPEER",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn attempted_at_of(db: &super::Db, repo: &str) -> Option<String> {
|
||||
sqlx::query_scalar("SELECT attempted_at FROM sync_queue WHERE repo = $1")
|
||||
.bind(repo)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Upgrade-path test: simulate a node already at v11 and let the real
|
||||
/// migration entry point apply v17, rather than hand-copying its SQL.
|
||||
///
|
||||
/// This is the test that catches the column being added to the v1
|
||||
/// statement array instead of a new migration. v1 never re-runs on an
|
||||
/// existing install, so that mistake breaks every deployed node's dequeue
|
||||
/// while staying invisible to every other test here, since `#[sqlx::test]`
|
||||
/// hands out a fresh database that runs the whole chain.
|
||||
#[sqlx::test]
|
||||
async fn migration_v17_adds_sync_queue_attempted_at(pool: sqlx::PgPool) {
|
||||
let db = super::Db::for_testing(pool);
|
||||
db.migrate().await.unwrap();
|
||||
|
||||
// Roll back to v11: drop the column and forget the version.
|
||||
sqlx::query("ALTER TABLE sync_queue DROP COLUMN attempted_at")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM schema_migrations WHERE version = 17")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A row written by the old node, before the column existed.
|
||||
enqueue_one(&db, "z6Mkfoo/legacy").await;
|
||||
|
||||
db.migrate().await.unwrap();
|
||||
|
||||
let col: (String, String) = sqlx::query_as(
|
||||
"SELECT data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'sync_queue' AND column_name = 'attempted_at'",
|
||||
)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(col.0, "text");
|
||||
assert_eq!(col.1, "YES", "attempted_at must be nullable");
|
||||
|
||||
let recorded: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM schema_migrations WHERE version = 17")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recorded.0, 1, "v17 must be recorded as applied");
|
||||
|
||||
// The pre-existing row survives with a null key and is still dequeued.
|
||||
assert_eq!(attempted_at_of(&db, "z6Mkfoo/legacy").await, None);
|
||||
let items = db.dequeue_pending_syncs(10).await.unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].repo, "z6Mkfoo/legacy");
|
||||
|
||||
// Idempotent re-run.
|
||||
db.migrate().await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn dequeue_stamps_attempted_at_on_every_row_it_hands_out(pool: sqlx::PgPool) {
|
||||
// The stamp is what stops a deferred row from holding the window, and
|
||||
// it happens here rather than at the deferral branches so no call site
|
||||
// can forget it.
|
||||
let db = super::Db::for_testing(pool);
|
||||
db.migrate().await.unwrap();
|
||||
enqueue_one(&db, "z6Mkfoo/a").await;
|
||||
assert_eq!(
|
||||
attempted_at_of(&db, "z6Mkfoo/a").await,
|
||||
None,
|
||||
"a freshly enqueued row has no attempt yet"
|
||||
);
|
||||
|
||||
let items = db.dequeue_pending_syncs(10).await.unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(
|
||||
attempted_at_of(&db, "z6Mkfoo/a").await.is_some(),
|
||||
"dequeue must stamp the row it returns, whatever the worker does next"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn dequeue_orders_by_last_attempt_then_enqueue_time(pool: sqlx::PgPool) {
|
||||
let db = super::Db::for_testing(pool);
|
||||
db.migrate().await.unwrap();
|
||||
enqueue_one(&db, "z6Mkfoo/older").await;
|
||||
enqueue_one(&db, "z6Mkfoo/newer").await;
|
||||
sqlx::query("UPDATE sync_queue SET enqueued_at = $1 WHERE repo = $2")
|
||||
.bind("2026-07-29T00:00:00Z")
|
||||
.bind("z6Mkfoo/older")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE sync_queue SET enqueued_at = $1 WHERE repo = $2")
|
||||
.bind("2026-07-29T00:00:01Z")
|
||||
.bind("z6Mkfoo/newer")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Never-attempted rows fall back to enqueue order.
|
||||
let first = db.dequeue_pending_syncs(1).await.unwrap();
|
||||
assert_eq!(first[0].repo, "z6Mkfoo/older");
|
||||
|
||||
// Having been attempted, it now sorts behind the untried row.
|
||||
let second = db.dequeue_pending_syncs(1).await.unwrap();
|
||||
assert_eq!(
|
||||
second[0].repo, "z6Mkfoo/newer",
|
||||
"an attempted row must yield to one that has never been tried"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn dequeue_leaves_enqueued_at_untouched(pool: sqlx::PgPool) {
|
||||
// enqueued_at keeps meaning enqueue time, so backlog age stays
|
||||
// measurable; that is the reason attempted_at is a separate column.
|
||||
let db = super::Db::for_testing(pool);
|
||||
db.migrate().await.unwrap();
|
||||
enqueue_one(&db, "z6Mkfoo/a").await;
|
||||
let before: String =
|
||||
sqlx::query_scalar("SELECT enqueued_at FROM sync_queue WHERE repo = 'z6Mkfoo/a'")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.dequeue_pending_syncs(10).await.unwrap();
|
||||
|
||||
let after: String =
|
||||
sqlx::query_scalar("SELECT enqueued_at FROM sync_queue WHERE repo = 'z6Mkfoo/a'")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn dequeue_does_not_touch_settled_rows(pool: sqlx::PgPool) {
|
||||
// The stamping UPDATE must not reach a row that already left the
|
||||
// pending set, or a terminal row could be dragged back into rotation.
|
||||
let db = super::Db::for_testing(pool);
|
||||
db.migrate().await.unwrap();
|
||||
enqueue_one(&db, "z6Mkfoo/failed").await;
|
||||
enqueue_one(&db, "z6Mkfoo/done").await;
|
||||
let ids: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT repo, id FROM sync_queue ORDER BY repo")
|
||||
.fetch_all(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for (repo, id) in &ids {
|
||||
if repo.ends_with("failed") {
|
||||
db.mark_sync_failed(id).await.unwrap();
|
||||
} else {
|
||||
db.mark_sync_done(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
assert!(db.dequeue_pending_syncs(10).await.unwrap().is_empty());
|
||||
assert_eq!(attempted_at_of(&db, "z6Mkfoo/failed").await, None);
|
||||
assert_eq!(attempted_at_of(&db, "z6Mkfoo/done").await, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4404,6 +4691,103 @@ mod dedup_db_tests {
|
||||
assert!(!got.is_public, "non-key row's is_public must be preserved");
|
||||
}
|
||||
|
||||
/// get_profile must not resolve a non-key DID (e.g. did:gitlawb:) when
|
||||
/// queried with the bare short id. The old `LIKE '%:' || $1` clause was too
|
||||
/// broad and could return the wrong profile row.
|
||||
#[sqlx::test]
|
||||
async fn get_profile_does_not_match_non_key_did(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let short = "z6Mkprof1";
|
||||
|
||||
// Seed only a non-key DID row first. When queried with the bare short ID,
|
||||
// get_profile must return None (lone non-key fixture test).
|
||||
db.upsert_profile(
|
||||
&format!("did:gitlawb:{short}"),
|
||||
Some("other-method"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = db.get_profile(short).await.unwrap();
|
||||
assert!(
|
||||
got.is_none(),
|
||||
"bare short id must not resolve a lone non-key DID profile"
|
||||
);
|
||||
|
||||
// Now seed the canonical bare key profile row as well.
|
||||
db.upsert_profile(short, Some("canonical"), None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = db
|
||||
.get_profile(short)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("bare short id should resolve the key-form profile");
|
||||
assert_eq!(got.did, short);
|
||||
assert_eq!(got.display_name.as_deref(), Some("canonical"));
|
||||
|
||||
let got = db
|
||||
.get_profile(&format!("did:key:{short}"))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("did:key form should also resolve the key-form profile");
|
||||
assert_eq!(got.did, short);
|
||||
|
||||
let got = db
|
||||
.get_profile(&format!("did:gitlawb:{short}"))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("full non-key DID should resolve its own profile");
|
||||
assert_eq!(got.did, format!("did:gitlawb:{short}"));
|
||||
assert_eq!(got.display_name.as_deref(), Some("other-method"));
|
||||
}
|
||||
|
||||
/// upsert_profile must update a bare-stored profile when called with the
|
||||
/// full did:key: form. get_profile equates the two, so the UPDATE has to
|
||||
/// target existing.did rather than the raw input.
|
||||
#[sqlx::test]
|
||||
async fn upsert_profile_updates_via_did_key_alias(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let short = "z6Mkprof2";
|
||||
|
||||
db.upsert_profile(short, Some("before"), None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = db
|
||||
.upsert_profile(
|
||||
&format!("did:key:{short}"),
|
||||
Some("after"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.did, short, "preserve the stored did form on update");
|
||||
assert_eq!(updated.display_name.as_deref(), Some("after"));
|
||||
|
||||
let got = db
|
||||
.get_profile(short)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("profile should still resolve by bare short id");
|
||||
assert_eq!(got.did, short);
|
||||
assert_eq!(got.display_name.as_deref(), Some("after"));
|
||||
|
||||
db.set_profile_cid(&format!("did:key:{short}"), "bafytestcid")
|
||||
.await
|
||||
.unwrap();
|
||||
let got = db.get_profile(short).await.unwrap().unwrap();
|
||||
assert_eq!(got.profile_cid.as_deref(), Some("bafytestcid"));
|
||||
}
|
||||
|
||||
/// Verify that the Rust `normalize_owner_key` and the `OWNER_KEY_CASE_SQL`
|
||||
/// expression agree on every boundary value in the owner-key normalization
|
||||
/// set. A mismatch would let the Rust code bind a different key than the SQL
|
||||
@@ -4453,6 +4837,49 @@ mod dedup_db_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `PROFILE_DID_CASE_SQL` (which aliases the column `did`) also
|
||||
/// agrees with Rust `normalize_owner_key` across the full boundary matrix.
|
||||
#[sqlx::test]
|
||||
async fn profile_did_case_sql_matches_normalize_owner_key(pool: PgPool) {
|
||||
let boundary_values = [
|
||||
"did:key:z6Mkfoo",
|
||||
"z6Mkfoo",
|
||||
"did:gitlawb:z6Mkfoo",
|
||||
"did:web:example.com:alice",
|
||||
"did:key:did:gitlawb:z6Mkfoo",
|
||||
"",
|
||||
"did:key:",
|
||||
"DID:KEY:z6Mkfoo",
|
||||
];
|
||||
|
||||
let values_sql: String = boundary_values
|
||||
.iter()
|
||||
.map(|v| format!("('{}'::text)", v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let sql = format!(
|
||||
"WITH data(did) AS (VALUES {values_sql})
|
||||
SELECT did, ({key}) AS normalized FROM data ORDER BY did",
|
||||
key = super::PROFILE_DID_CASE_SQL
|
||||
);
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(&sql).fetch_all(&pool).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
boundary_values.len(),
|
||||
"every boundary value must produce a row"
|
||||
);
|
||||
|
||||
for (val, sql_result) in &rows {
|
||||
let rust_result = super::normalize_owner_key(val);
|
||||
assert_eq!(
|
||||
sql_result, rust_result,
|
||||
"PROFILE_DID_CASE_SQL(\"{val}\") mismatch: Rust = \"{rust_result}\", SQL CASE = \"{sql_result}\""
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises the iCaptcha single-use proof ledger (`icaptcha_consumed_proofs`),
|
||||
@@ -5681,3 +6108,170 @@ mod ref_update_db_tests {
|
||||
assert_eq!(all[0].new_sha, "gggg");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod peer_reachability_tests {
|
||||
use super::Db;
|
||||
use sqlx::PgPool;
|
||||
|
||||
const VICTIM_DID: &str = "did:key:z6MkvictimPeerFixture";
|
||||
const HONEST_URL: &str = "https://honest-peer.example.com";
|
||||
const ATTACKER_URL: &str = "https://attacker.example.com";
|
||||
|
||||
async fn db(pool: PgPool) -> Db {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
/// Read the row back through `list_peers`, the same surface the federated
|
||||
/// fan-out filters on, rather than issuing raw SQL from the test.
|
||||
async fn peer(db: &Db, did: &str) -> (String, bool) {
|
||||
let row = db
|
||||
.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.did == did)
|
||||
.expect("seeded peer row is missing");
|
||||
(row.http_url, row.last_ping_ok)
|
||||
}
|
||||
|
||||
/// Parsed rather than string-compared, so the ordering assertion does not
|
||||
/// depend on the stored timestamp's textual precision.
|
||||
async fn last_seen(db: &Db, did: &str) -> chrono::DateTime<chrono::Utc> {
|
||||
let row = db
|
||||
.list_peers()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.did == did)
|
||||
.expect("seeded peer row is missing");
|
||||
chrono::DateTime::parse_from_rfc3339(&row.last_seen.expect("last_seen is set by upsert"))
|
||||
.expect("last_seen is rfc3339")
|
||||
.with_timezone(&chrono::Utc)
|
||||
}
|
||||
|
||||
/// Seed a peer that has earned reachability, asserting the seed took so a
|
||||
/// later case cannot pass vacuously on a row that was never written.
|
||||
async fn seed_reachable(db: &Db) {
|
||||
db.upsert_peer(VICTIM_DID, HONEST_URL).await.unwrap();
|
||||
db.mark_peer_ping(VICTIM_DID, true).await.unwrap();
|
||||
assert_eq!(
|
||||
peer(db, VICTIM_DID).await,
|
||||
(HONEST_URL.to_string(), true),
|
||||
"seed did not take"
|
||||
);
|
||||
}
|
||||
|
||||
/// Repointing an existing peer's URL must drop the reachability gate: the
|
||||
/// new host has not been probed, so it cannot inherit the old host's
|
||||
/// earned `last_ping_ok`.
|
||||
#[sqlx::test]
|
||||
async fn url_change_clears_reachability(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_reachable(&db).await;
|
||||
|
||||
db.upsert_peer(VICTIM_DID, ATTACKER_URL).await.unwrap();
|
||||
|
||||
let (url, reachable) = peer(&db, VICTIM_DID).await;
|
||||
assert_eq!(url, ATTACKER_URL, "the URL should still be rewritten");
|
||||
assert!(
|
||||
!reachable,
|
||||
"a repointed peer must re-earn reachability, not inherit it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A plain liveness re-announce carries the same URL and must not cost an
|
||||
/// honest peer its place in the federated fan-out. Guards against a fix
|
||||
/// that clears the flag on every conflict instead of only on a change.
|
||||
#[sqlx::test]
|
||||
async fn same_url_reannounce_keeps_reachability(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_reachable(&db).await;
|
||||
|
||||
db.upsert_peer(VICTIM_DID, HONEST_URL).await.unwrap();
|
||||
|
||||
let (url, reachable) = peer(&db, VICTIM_DID).await;
|
||||
assert_eq!(url, HONEST_URL);
|
||||
assert!(
|
||||
reachable,
|
||||
"an unchanged-URL re-announce must not drop the gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The must-not-grant direction. An unchanged URL preserves the flag as it
|
||||
/// stands, which means FALSE stays FALSE: reachability is earned by a probe,
|
||||
/// never by announcing. This is the only case that fails if the conditional
|
||||
/// is flattened to `last_ping_ok = (peers.http_url IS NOT DISTINCT FROM $2)`,
|
||||
/// which would let any unsigned same-URL re-announce set the flag TRUE.
|
||||
#[sqlx::test]
|
||||
async fn same_url_reannounce_does_not_grant_reachability(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.upsert_peer(VICTIM_DID, HONEST_URL).await.unwrap();
|
||||
assert_eq!(peer(&db, VICTIM_DID).await, (HONEST_URL.to_string(), false));
|
||||
|
||||
db.upsert_peer(VICTIM_DID, HONEST_URL).await.unwrap();
|
||||
|
||||
let (_, reachable) = peer(&db, VICTIM_DID).await;
|
||||
assert!(
|
||||
!reachable,
|
||||
"announcing must never grant reachability without a probe"
|
||||
);
|
||||
}
|
||||
|
||||
/// A first insert stays out of the fan-out until a probe confirms it. Guards
|
||||
/// against the conditional leaking into the INSERT branch.
|
||||
#[sqlx::test]
|
||||
async fn fresh_peer_inserts_unreachable(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
|
||||
db.upsert_peer("did:key:z6MkfreshPeerFixture", HONEST_URL)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (_, reachable) = peer(&db, "did:key:z6MkfreshPeerFixture").await;
|
||||
assert!(!reachable, "a never-probed peer must insert unreachable");
|
||||
}
|
||||
|
||||
/// Comparison is exact, by decision: http_url is stored as announced and
|
||||
/// nothing normalizes it, so a trailing slash is a different remote as far
|
||||
/// as this row is concerned and clears the gate. Pins that decision against
|
||||
/// a future normalizing comparison, which every other case here would pass
|
||||
/// because they only ever compare identical or wholly different hosts.
|
||||
#[sqlx::test]
|
||||
async fn cosmetic_url_difference_counts_as_a_change(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
seed_reachable(&db).await;
|
||||
|
||||
let with_slash = format!("{HONEST_URL}/");
|
||||
db.upsert_peer(VICTIM_DID, &with_slash).await.unwrap();
|
||||
|
||||
let (url, reachable) = peer(&db, VICTIM_DID).await;
|
||||
assert_eq!(url, with_slash);
|
||||
assert!(
|
||||
!reachable,
|
||||
"comparison is exact, so a cosmetic difference clears the gate too"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reset must ride the existing UPDATE, not gate it. Hoisting the
|
||||
/// condition to a statement-level WHERE would leave every case above green
|
||||
/// while silently skipping the whole update on a same-URL re-announce, so
|
||||
/// liveness would stop advancing and the peer would age out on last_seen.
|
||||
#[sqlx::test]
|
||||
async fn same_url_reannounce_still_advances_last_seen(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.upsert_peer(VICTIM_DID, HONEST_URL).await.unwrap();
|
||||
let first = last_seen(&db, VICTIM_DID).await;
|
||||
|
||||
db.upsert_peer(VICTIM_DID, HONEST_URL).await.unwrap();
|
||||
|
||||
let second = last_seen(&db, VICTIM_DID).await;
|
||||
assert!(
|
||||
second > first,
|
||||
"a same-URL re-announce is a liveness signal and must still \
|
||||
advance last_seen: {first} then {second}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.7.0" # x-release-please-version
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
@@ -23,11 +23,15 @@ if [ ! -f "$ALLOW" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `cargo tree` resolves like the build/test jobs (no --locked): the committed
|
||||
# Cargo.lock can lag the manifests, and --locked would red this gate for
|
||||
# lock-staleness reasons unrelated to gitlawb-core's dependencies. Resolving can
|
||||
# refresh Cargo.lock as a side effect, so snapshot and restore it — this check
|
||||
# must never leave the working tree dirty when run locally.
|
||||
# `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"; }
|
||||
|
||||
Reference in new issue
Block a user