mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a46e8cbb7 | ||
|
|
e4f5e87e0b | ||
|
|
38e654a289 | ||
|
|
4f814459cc | ||
|
|
d45318281c |
No files matched your search
@@ -72,12 +72,6 @@ GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
|
||||
# rolling upgrades so existing live nodes can still communicate.
|
||||
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
|
||||
|
||||
# Require the authenticated pusher to be the repo owner on git-receive-pack.
|
||||
# A valid did:key signature is authentication, not authorization: anyone can
|
||||
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
|
||||
# Keep false until the repo owner is ready for owner-only writes.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=false
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
|
||||
GITLAWB_P2P_BOOTSTRAP=
|
||||
@@ -93,21 +87,3 @@ GITLAWB_MAX_PACK_BYTES=2147483648
|
||||
# ── Sync ─────────────────────────────────────────────────────────────────
|
||||
# Enable automatic background sync from known peers
|
||||
GITLAWB_AUTO_SYNC=false
|
||||
|
||||
# ── iCaptcha proof-of-intelligence gate ───────────────────────────────────
|
||||
# Optional gate on create_repo + register: require callers to present an
|
||||
# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com.
|
||||
# Default off = fully inert (no behavior change).
|
||||
# off - gate disabled (default)
|
||||
# shadow - verify + log would-be rejections, but always allow
|
||||
# enforce - reject requests without a valid, strong-enough proof
|
||||
ICAPTCHA_MODE=off
|
||||
# Optional base64url Ed25519 public key override; if unset it is fetched from
|
||||
# ICAPTCHA_URL/v1/pubkey at startup. Recommended in production: pinning the key
|
||||
# removes the startup dependency on the iCaptcha service being reachable and
|
||||
# avoids silently running inert if that one-time fetch fails.
|
||||
ICAPTCHA_PUBKEY=
|
||||
# Minimum proof difficulty level required to pass.
|
||||
ICAPTCHA_REQUIRED_LEVEL=3
|
||||
# Base URL of the iCaptcha service.
|
||||
ICAPTCHA_URL=https://icaptcha.gitlawb.com
|
||||
@@ -54,23 +54,6 @@ jobs:
|
||||
toolchain: [stable, beta]
|
||||
# Beta is informational: an upstream beta regression should warn, not block merges.
|
||||
continue-on-error: ${{ matrix.toolchain == 'beta' }}
|
||||
# Postgres for the DB-backed integration tests (#[sqlx::test] provisions an
|
||||
# isolated database per test off this connection). The health-check gate keeps
|
||||
# `cargo test` from racing a not-yet-ready server.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: gitlawb_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -88,8 +71,6 @@ jobs:
|
||||
key: ${{ matrix.toolchain }}
|
||||
|
||||
- name: cargo test
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
|
||||
run: cargo test --workspace
|
||||
|
||||
build-release:
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
name: PR Triage
|
||||
|
||||
# Runs on pull_request_target, not pull_request: fork PRs get a read-only
|
||||
# GITHUB_TOKEN under pull_request regardless of the permissions below, so the
|
||||
# label/comment writes 403. This job never checks out or runs PR head code (it
|
||||
# only reads context.payload and the files API), so pull_request_target is safe.
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
@@ -68,8 +64,8 @@ jobs:
|
||||
if (isFork && touchesWorkflows) want.add("workflow-change");
|
||||
|
||||
const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs"));
|
||||
// A test can live in a tests/ dir, a *_test.rs file, or (the common Rust
|
||||
// pattern) an inline #[test] / #[cfg(test)] block added inside the source file.
|
||||
// A test can live in a tests/ dir, a *_test.rs file, or — the common Rust
|
||||
// pattern — an inline #[test] / #[cfg(test)] block added inside the source file.
|
||||
const addsInlineTest = files.some(f =>
|
||||
f.filename.endsWith(".rs") && f.patch &&
|
||||
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
|
||||
@@ -108,7 +104,7 @@ jobs:
|
||||
|
||||
const reasons = {
|
||||
"needs-issue": "Link the issue this addresses (`Closes #123`). For protocol changes, open an issue first.",
|
||||
"needs-description": "Add a short summary of what changes and why: the template's Summary and Motivation sections.",
|
||||
"needs-description": "Add a short summary of what changes and why — the template's Summary and Motivation sections.",
|
||||
"needs-tests": "This changes Rust source but no tests changed. Tests are required for fixes and strongly encouraged for features.",
|
||||
};
|
||||
const lines = advisory.map(l => `- ${reasons[l]}`).join("\n");
|
||||
|
||||
+15
-384
@@ -9,9 +9,6 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Least privilege by default; every job opts in to exactly what it needs.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.repository == 'Gitlawb/node' }}
|
||||
@@ -27,7 +24,7 @@ jobs:
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
||||
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.3.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -44,7 +41,6 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
@@ -105,9 +101,6 @@ jobs:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Windows is best-effort: a failure there must not fail the job or block the
|
||||
# downstream npm/Homebrew jobs that only consume the unix artifacts.
|
||||
continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -122,14 +115,11 @@ jobs:
|
||||
os: macos-13
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
@@ -153,22 +143,8 @@ jobs:
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
# The gitlawb-node daemon is a Linux/macOS service; on Windows we ship only
|
||||
# the two CLI binaries. A Windows-only failure must not block the release.
|
||||
- name: Determine binaries
|
||||
shell: bash
|
||||
run: |
|
||||
BINS="gl git-remote-gitlawb gitlawb-node"
|
||||
case "${{ matrix.target }}" in *windows*) BINS="gl git-remote-gitlawb" ;; esac
|
||||
echo "BINS=$BINS" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=""
|
||||
for b in $BINS; do args="$args -p $b"; done
|
||||
cargo build --release --target ${{ matrix.target }} $args
|
||||
run: cargo build --release --target ${{ matrix.target }} -p gl -p git-remote-gitlawb -p gitlawb-node
|
||||
|
||||
# Run each packaged binary's --version so a broken release artifact fails the
|
||||
# build instead of shipping. Skipped for cross-compiled targets that can't run
|
||||
@@ -180,11 +156,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.release-please.outputs.version }}"
|
||||
BIN_DIR="target/${{ matrix.target }}/release"
|
||||
EXE=""
|
||||
case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac
|
||||
for bin in $BINS; do
|
||||
for bin in gl git-remote-gitlawb gitlawb-node; do
|
||||
echo "== $bin --version =="
|
||||
out="$("$BIN_DIR/$bin$EXE" --version)"
|
||||
out="$("$BIN_DIR/$bin" --version)"
|
||||
echo "$out"
|
||||
# Each binary prints "<name> <version>"; assert the released version is present.
|
||||
grep -qF "$VERSION" <<<"$out" || {
|
||||
@@ -194,369 +168,26 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Package
|
||||
id: pkg
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ matrix.target }}"
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}"
|
||||
BIN_DIR="target/${TARGET}/release"
|
||||
EXE=""
|
||||
case "$TARGET" in *windows*) EXE=".exe" ;; esac
|
||||
|
||||
# Portable sha256 → "<hash> <file>" so install scripts and brew can parse it.
|
||||
sha256_file() {
|
||||
local f="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$f"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$f"
|
||||
else
|
||||
local h
|
||||
h=$(certutil -hashfile "$f" SHA256 | sed -n 2p | tr -d ' \r')
|
||||
printf '%s %s\n' "$h" "$f"
|
||||
fi
|
||||
}
|
||||
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${{ matrix.target }}"
|
||||
mkdir -p "dist/$NAME"
|
||||
for bin in $BINS; do
|
||||
cp "$BIN_DIR/$bin$EXE" "dist/$NAME/"
|
||||
done
|
||||
cp "target/${{ matrix.target }}/release/gl" "dist/$NAME/"
|
||||
cp "target/${{ matrix.target }}/release/git-remote-gitlawb" "dist/$NAME/"
|
||||
cp "target/${{ matrix.target }}/release/gitlawb-node" "dist/$NAME/"
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "dist/$NAME/"
|
||||
|
||||
cd dist
|
||||
case "$TARGET" in
|
||||
*windows*)
|
||||
# 7z is preinstalled on windows-latest runners.
|
||||
7z a -tzip "$NAME.zip" "$NAME" >/dev/null
|
||||
sha256_file "$NAME.zip" > "$NAME.zip.sha256"
|
||||
;;
|
||||
*)
|
||||
tar czf "$NAME.tar.gz" "$NAME"
|
||||
sha256_file "$NAME.tar.gz" > "$NAME.tar.gz.sha256"
|
||||
;;
|
||||
esac
|
||||
tar czf "$NAME.tar.gz" "$NAME"
|
||||
shasum -a 256 "$NAME.tar.gz" > "$NAME.tar.gz.sha256"
|
||||
echo "asset=dist/$NAME.tar.gz" >> "$GITHUB_OUTPUT"
|
||||
echo "checksum=dist/$NAME.tar.gz.sha256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: ${{ needs.release-please.outputs.tag_name }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/*.sha256
|
||||
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
needs: [release-please, release-binaries]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${NPM_TOKEN:-}" ]; then
|
||||
echo "::warning::NPM_TOKEN is not set — skipping npm publish."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Lay in release binaries
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm platform package -> Rust target triple (unix only; Windows is not
|
||||
# published to npm).
|
||||
MAP="
|
||||
gl-darwin-arm64:aarch64-apple-darwin
|
||||
gl-darwin-x64:x86_64-apple-darwin
|
||||
gl-linux-arm64:aarch64-unknown-linux-musl
|
||||
gl-linux-x64:x86_64-unknown-linux-musl
|
||||
"
|
||||
mkdir -p _dl
|
||||
for entry in $MAP; do
|
||||
pkg="${entry%%:*}"
|
||||
target="${entry#*:}"
|
||||
archive="gitlawb-node-${VERSION}-${target}.tar.gz"
|
||||
echo "==> $pkg <- $archive"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$archive" --dir _dl --clobber
|
||||
tar -xzf "_dl/$archive" -C _dl
|
||||
src="_dl/gitlawb-node-${VERSION}-${target}"
|
||||
cp "$src/gl" "npm/packages/$pkg/gl"
|
||||
cp "$src/git-remote-gitlawb" "npm/packages/$pkg/git-remote-gitlawb"
|
||||
chmod +x "npm/packages/$pkg/gl" "npm/packages/$pkg/git-remote-gitlawb"
|
||||
done
|
||||
|
||||
- name: Set versions
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const v = process.env.VERSION;
|
||||
for (const p of fs.readdirSync("npm/packages")) {
|
||||
const f = `npm/packages/${p}/package.json`;
|
||||
const pkg = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||
pkg.version = v;
|
||||
if (pkg.optionalDependencies) {
|
||||
for (const k of Object.keys(pkg.optionalDependencies)) {
|
||||
pkg.optionalDependencies[k] = v;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(f, JSON.stringify(pkg, null, 2) + "\n");
|
||||
}
|
||||
'
|
||||
|
||||
- name: Publish
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
|
||||
# Skip versions already on the registry so a rerun after a partial publish
|
||||
# is idempotent instead of erroring on the first existing package.
|
||||
for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do
|
||||
name="@gitlawb/$pkg"
|
||||
if npm view "$name@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "==> $name@$VERSION already published, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "==> npm publish $name@$VERSION"
|
||||
npm publish "npm/packages/$pkg" --provenance --access public
|
||||
done
|
||||
|
||||
homebrew-bump:
|
||||
name: Bump Homebrew tap
|
||||
needs: [release-please, release-binaries]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
env:
|
||||
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
run: |
|
||||
if [ -z "${HOMEBREW_TAP_PAT:-}" ]; then
|
||||
echo "::warning::HOMEBREW_TAP_PAT is not set — skipping Homebrew bump."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout tap repo
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Gitlawb/homebrew-tap
|
||||
token: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
path: tap
|
||||
persist-credentials: false
|
||||
|
||||
- name: Regenerate formula
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
mkdir -p _sums
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern '*.tar.gz.sha256' --dir _sums --clobber
|
||||
sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; }
|
||||
SHA_MAC_ARM="$(sha aarch64-apple-darwin)"
|
||||
SHA_MAC_X64="$(sha x86_64-apple-darwin)"
|
||||
SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)"
|
||||
SHA_LNX_X64="$(sha x86_64-unknown-linux-musl)"
|
||||
|
||||
mkdir -p tap/Formula
|
||||
cat > tap/Formula/gl.rb <<EOF
|
||||
class Gl < Formula
|
||||
desc "Gitlawb CLI — decentralized git for AI agents and developers"
|
||||
homepage "https://gitlawb.com"
|
||||
version "${VERSION}"
|
||||
license "MIT OR Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "${base}/gitlawb-node-${VERSION}-aarch64-apple-darwin.tar.gz"
|
||||
sha256 "${SHA_MAC_ARM}"
|
||||
end
|
||||
on_intel do
|
||||
url "${base}/gitlawb-node-${VERSION}-x86_64-apple-darwin.tar.gz"
|
||||
sha256 "${SHA_MAC_X64}"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "${base}/gitlawb-node-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
|
||||
sha256 "${SHA_LNX_ARM}"
|
||||
end
|
||||
on_intel do
|
||||
url "${base}/gitlawb-node-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
sha256 "${SHA_LNX_X64}"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "gl"
|
||||
bin.install "git-remote-gitlawb"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/gl --version")
|
||||
end
|
||||
end
|
||||
EOF
|
||||
|
||||
- name: Commit and push
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
working-directory: tap
|
||||
env:
|
||||
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
# Stage first so a brand-new (untracked) formula is detected by the no-op check.
|
||||
git add Formula/gl.rb
|
||||
if git diff --cached --quiet; then
|
||||
echo "Formula already up to date."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "gl ${VERSION}"
|
||||
# Credentials are not persisted in .git/config; supply the token only for the push.
|
||||
git push "https://x-access-token:${HOMEBREW_TAP_PAT}@github.com/Gitlawb/homebrew-tap.git" HEAD:main
|
||||
|
||||
web-sync:
|
||||
name: Sync web (install scripts + version)
|
||||
needs: [release-please, release-binaries]
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
env:
|
||||
WEB_SYNC_PAT: ${{ secrets.WEB_SYNC_PAT }}
|
||||
run: |
|
||||
if [ -z "${WEB_SYNC_PAT:-}" ]; then
|
||||
echo "::warning::WEB_SYNC_PAT is not set — skipping web sync."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout node (release tag)
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
path: node
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout web
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: Gitlawb/web
|
||||
token: ${{ secrets.WEB_SYNC_PAT }}
|
||||
path: web
|
||||
persist-credentials: false
|
||||
|
||||
# web-sync publishes install.ps1 + version.json, so the Windows ZIP must exist.
|
||||
# Windows is best-effort for npm/Homebrew (continue-on-error), so verify the
|
||||
# asset is present before advertising a release the site can't actually serve.
|
||||
- name: Verify website release assets
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
|
||||
--jq '.assets[].name' > /tmp/release-assets
|
||||
for asset in \
|
||||
"gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip" \
|
||||
"gitlawb-node-${VERSION}-x86_64-pc-windows-msvc.zip.sha256"; do
|
||||
grep -Fxq "$asset" /tmp/release-assets || {
|
||||
echo "::error::release asset missing: $asset (Windows build likely failed); not syncing web"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Sync and open PR
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Canonical install scripts (source of truth lives in the node repo).
|
||||
cp node/install.sh web/public/install.sh
|
||||
cp node/install.ps1 web/public/install.ps1
|
||||
# Single version source the site can read.
|
||||
printf '{\n "version": "%s",\n "tag": "%s"\n}\n' "$VERSION" "$TAG" > web/public/version.json
|
||||
# One-time, idempotent drift fixes: drop orphaned binaries, fix stale link.
|
||||
rm -rf web/public/bin
|
||||
if [ -f web/public/skill.md ]; then
|
||||
sed -i 's#github.com/gitlawb/releases#github.com/Gitlawb/node/releases#g' web/public/skill.md
|
||||
fi
|
||||
|
||||
cd web
|
||||
# Stage first so brand-new (untracked) files are caught by the no-op check.
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "web already up to date."
|
||||
exit 0
|
||||
fi
|
||||
branch="release-sync/${TAG}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$branch"
|
||||
git commit -m "chore: sync gitlawb ${TAG} (install scripts + version)"
|
||||
# Credentials are not persisted in .git/config; supply the token only for the push.
|
||||
git push --force "https://x-access-token:${GH_TOKEN}@github.com/Gitlawb/web.git" "HEAD:$branch"
|
||||
gh pr create --repo Gitlawb/web --head "$branch" \
|
||||
--title "Sync gitlawb ${TAG}" \
|
||||
--body "Automated sync from Gitlawb/node ${TAG}: install.sh, install.ps1, public/version.json, and one-time drift fixes (removed orphaned public/bin, fixed skill.md releases link). Review and merge to deploy via Vercel." \
|
||||
|| {
|
||||
# Only swallow the "PR already exists" case; surface auth/API failures
|
||||
# (a failed gh pr list must not be misread as "PR exists").
|
||||
pr_count="$(gh pr list --repo Gitlawb/web --head "$branch" --state open --json number --jq 'length')" || exit 1
|
||||
if [ "$pr_count" != "0" ]; then
|
||||
echo "PR for $branch already exists; branch was updated."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
${{ steps.pkg.outputs.asset }}
|
||||
${{ steps.pkg.outputs.checksum }}
|
||||
@@ -23,18 +23,17 @@ jobs:
|
||||
# Act on PRs only; never on issues.
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
# Only PRs the triage step flagged as missing a real description.
|
||||
# needs-issue is advisory-only (linking an issue is a SHOULD, and many
|
||||
# legitimate small PRs have none) so it does not drive auto-close.
|
||||
only-pr-labels: "needs-description"
|
||||
# Only PRs the triage step flagged as needing an author response.
|
||||
only-pr-labels: "needs-issue,needs-description"
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "workflow-change,security,pinned"
|
||||
remove-stale-when-updated: true
|
||||
stale-pr-message: >
|
||||
This PR has been inactive for 14 days and is still missing a description.
|
||||
It will be closed in 7 days if there's no update. Push a change or leave a
|
||||
comment to keep it open. No hard feelings, you can reopen anytime.
|
||||
This PR has been inactive for 14 days and is still missing a linked issue
|
||||
or a description. It will be closed in 7 days if there's no update. Push a
|
||||
change or leave a comment to keep it open — no hard feelings, you can reopen
|
||||
anytime.
|
||||
close-pr-message: >
|
||||
Closing due to inactivity. Reopen whenever you're ready to update it. See
|
||||
Closing due to inactivity. Reopen whenever you're ready to update it — see
|
||||
CONTRIBUTING.md for what helps us review quickly.
|
||||
ascending: true
|
||||
@@ -23,6 +23,3 @@ keys/
|
||||
# Logs
|
||||
*.log
|
||||
.openclaude-profile.json
|
||||
|
||||
# Per-operator Fly config (node1 prod config is pulled from Fly, not tracked)
|
||||
infra/fly/node1.fly.toml
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.4.0"
|
||||
".": "0.3.8"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/node/compare/v0.3.9...v0.4.0) (2026-06-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* agent profiles (display name, bio, avatar, social links) ([#23](https://github.com/Gitlawb/node/issues/23)) ([09a3397](https://github.com/Gitlawb/node/commit/09a339745eca40a2567d911e947e3fc7426fc621))
|
||||
* **db:** versioned schema migrations with idempotent backfill ([#21](https://github.com/Gitlawb/node/issues/21)) ([927e4d0](https://github.com/Gitlawb/node/commit/927e4d0cfb4ea11dca6930780939afaa067797ba))
|
||||
* encrypted replication for private subtrees (B1/B2/B3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#36](https://github.com/Gitlawb/node/issues/36)) ([5ff7af8](https://github.com/Gitlawb/node/commit/5ff7af84fa21fab53a12dfa04a0e7fb7e7d672e6))
|
||||
* **git-remote-gitlawb:** add --version and --help flags ([#30](https://github.com/Gitlawb/node/issues/30)) ([3a401eb](https://github.com/Gitlawb/node/commit/3a401eb0792f9c5f5a10de6c16861655ab3836e0))
|
||||
* **gitlawb-attest:** External Attestation v1 for ref-update certs ([#20](https://github.com/Gitlawb/node/issues/20)) ([924bccd](https://github.com/Gitlawb/node/commit/924bccd8e53e9be2dc9d6d4e4f1376952d6462bb))
|
||||
* **node:** blind recipient identities at rest and gate B1 by repo readability ([#40](https://github.com/Gitlawb/node/issues/40)) ([abdc775](https://github.com/Gitlawb/node/commit/abdc7757708d2bbb2bfda99bb65d50756142a42e))
|
||||
* **node:** enforce per-route authorization across the REST and GraphQL surface ([#87](https://github.com/Gitlawb/node/issues/87)) ([2202b00](https://github.com/Gitlawb/node/commit/2202b0097fab6976ab366a2cdc385f1146a72f86))
|
||||
* **node:** graceful shutdown + Prometheus metrics endpoint ([#22](https://github.com/Gitlawb/node/issues/22)) ([2ce4da9](https://github.com/Gitlawb/node/commit/2ce4da9cc5a4791e8ae5a1cd90270b67f60d9ec3))
|
||||
* **node:** iCaptcha proof-of-intelligence gate on create_repo + register ([#108](https://github.com/Gitlawb/node/issues/108)) ([adc20f9](https://github.com/Gitlawb/node/commit/adc20f9effad7b42fab55002875d209c4ed79518))
|
||||
* **node:** iCaptcha-aware repo propagation gate with quarantine ([#125](https://github.com/Gitlawb/node/issues/125)) ([8b9ceec](https://github.com/Gitlawb/node/commit/8b9ceec25ef338965d1db72d39a7f2adb5300cc9))
|
||||
* **node:** owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH ([#31](https://github.com/Gitlawb/node/issues/31)) ([#68](https://github.com/Gitlawb/node/issues/68)) ([0a15e76](https://github.com/Gitlawb/node/commit/0a15e763d2ab46737a3831715facbe51045b33ba))
|
||||
* **node:** peer partial-mirrors for repos with private subtrees ([#35](https://github.com/Gitlawb/node/issues/35)) ([e365a57](https://github.com/Gitlawb/node/commit/e365a57b51c167abf06e87bfeb0565c80ed1b849))
|
||||
* **node:** pin the per-push object delta instead of re-enumerating the whole repo ([#90](https://github.com/Gitlawb/node/issues/90)) ([1af4fdf](https://github.com/Gitlawb/node/commit/1af4fdf485c7084cd160551b757b1ad1eed65cc6))
|
||||
* **node:** replication enforcement (Phase 2) for [#18](https://github.com/Gitlawb/node/issues/18) ([#34](https://github.com/Gitlawb/node/issues/34)) ([8680d0f](https://github.com/Gitlawb/node/commit/8680d0f9d6600bba1a52d15624f8a2802a169511))
|
||||
* **node:** signature-gated agent self-deregister ([#29](https://github.com/Gitlawb/node/issues/29)) ([#63](https://github.com/Gitlawb/node/issues/63)) ([ff492b4](https://github.com/Gitlawb/node/commit/ff492b452126f5568dac4286a7249f0cadb8b380))
|
||||
* **node:** subtree content withholding (Phase 3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#28](https://github.com/Gitlawb/node/issues/28)) ([61b3830](https://github.com/Gitlawb/node/commit/61b383019fd895a1a6adfad934ad6c626e0f095e))
|
||||
* path-scoped repository visibility (Phase 1) for [#18](https://github.com/Gitlawb/node/issues/18) ([#25](https://github.com/Gitlawb/node/issues/25)) ([6abaf1d](https://github.com/Gitlawb/node/commit/6abaf1d7ed8fc55c6547568ae7247131311bde98))
|
||||
* per-DID rate limiting on creation endpoints (10/hour) ([#13](https://github.com/Gitlawb/node/issues/13)) ([b12c6bc](https://github.com/Gitlawb/node/commit/b12c6bc3283c2647224a62fb520a3cb7acf4a747))
|
||||
* **sync:** auto-register as replica with origin after successful mirror ([#56](https://github.com/Gitlawb/node/issues/56)) ([c03c9af](https://github.com/Gitlawb/node/commit/c03c9af8fadafe262a2bf3cf25e19edf7160d376))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** blob endpoint returns 400/404 instead of 500 on bad paths ([#37](https://github.com/Gitlawb/node/issues/37)) ([b61a1bd](https://github.com/Gitlawb/node/commit/b61a1bd46ffe78b39c0967e97b0ad349eba0b046))
|
||||
* **core:** route seed access through the zeroizing wrapper ([#41](https://github.com/Gitlawb/node/issues/41)) ([#64](https://github.com/Gitlawb/node/issues/64)) ([c9f43b0](https://github.com/Gitlawb/node/commit/c9f43b010576edb3c92a3be3d935cad232250344))
|
||||
* **core:** zeroize the derived X25519 secret ([#65](https://github.com/Gitlawb/node/issues/65)) ([#91](https://github.com/Gitlawb/node/issues/91)) ([2f6611a](https://github.com/Gitlawb/node/commit/2f6611a30fbb66cef9991cf4c6e507548acc5038))
|
||||
* **gl:** paginate gl-clone Arweave recovery and make /encrypted-blobs parsing schema-strict ([#49](https://github.com/Gitlawb/node/issues/49)) ([#70](https://github.com/Gitlawb/node/issues/70)) ([2153b0b](https://github.com/Gitlawb/node/commit/2153b0b7a67a48c29a33cd70b80cfbb69760805d))
|
||||
* **infra:** drop fly idle_timeout 600 -> 120 ([#38](https://github.com/Gitlawb/node/issues/38)) ([a2217bf](https://github.com/Gitlawb/node/commit/a2217bff27ff3d6cae02bd8a12f8a0af7ce2b0a1))
|
||||
* **node:** anchor the real old_sha and issue a per-ref certificate ([#72](https://github.com/Gitlawb/node/issues/72)) ([6809201](https://github.com/Gitlawb/node/commit/6809201daa223d6f833be6e95876a7b4e1f2b0b5))
|
||||
* **node:** close under-withholding via full ref scope and full-history classification ([#42](https://github.com/Gitlawb/node/issues/42)) ([#84](https://github.com/Gitlawb/node/issues/84)) ([3e1e904](https://github.com/Gitlawb/node/commit/3e1e9045e4e3aa5ea0aee69767ceb01637920d2a))
|
||||
* **node:** dedupe mirror and canonical repo rows on list surfaces ([#6](https://github.com/Gitlawb/node/issues/6)) ([#73](https://github.com/Gitlawb/node/issues/73)) ([3e8e333](https://github.com/Gitlawb/node/commit/3e8e333aa03d7a2fe455d5d83f23089d58feb8c9))
|
||||
* **node:** enforce path-scoped visibility on the REST read API ([#52](https://github.com/Gitlawb/node/issues/52)) ([e37ea7f](https://github.com/Gitlawb/node/commit/e37ea7fec6d5a3171b526c84f884670bbbd258fb))
|
||||
* **node:** fail closed when a recipient DID can't be resolved ([#47](https://github.com/Gitlawb/node/issues/47)) ([#67](https://github.com/Gitlawb/node/issues/67)) ([abc9ad0](https://github.com/Gitlawb/node/commit/abc9ad03acb48e608234650a25049622673fa53a))
|
||||
* **node:** gate fork_repo on per-caller path-scoped visibility ([#98](https://github.com/Gitlawb/node/issues/98)) ([#109](https://github.com/Gitlawb/node/issues/109)) ([6ae316c](https://github.com/Gitlawb/node/commit/6ae316cc88521747cbacb2a612b9433897d2e490))
|
||||
* **node:** gate GET /ipfs/{cid} on per-caller path-scoped visibility ([#110](https://github.com/Gitlawb/node/issues/110)) ([#128](https://github.com/Gitlawb/node/issues/128)) ([174f25a](https://github.com/Gitlawb/node/commit/174f25a206380b26796b8782e1bd860b0a409fc9))
|
||||
* **node:** gate repo-listing and stats surfaces on visibility ([#97](https://github.com/Gitlawb/node/issues/97), [#99](https://github.com/Gitlawb/node/issues/99), [#101](https://github.com/Gitlawb/node/issues/101), [#104](https://github.com/Gitlawb/node/issues/104)) ([#111](https://github.com/Gitlawb/node/issues/111)) ([828dd27](https://github.com/Gitlawb/node/commit/828dd279a286f58bbb3c73627b2a1e23778b25cf))
|
||||
* **node:** make Tigris repo hydration resilient to corrupt archives & failed writes ([#54](https://github.com/Gitlawb/node/issues/54)) ([7a99d0f](https://github.com/Gitlawb/node/commit/7a99d0f27cfa869e9f8a803f16cff30191745a51))
|
||||
* **node:** preserve promisor mirror mode on unknown withheld-paths lookup ([#48](https://github.com/Gitlawb/node/issues/48)) ([#69](https://github.com/Gitlawb/node/issues/69)) ([96fcfdb](https://github.com/Gitlawb/node/commit/96fcfdb1ca6d4cd27226670068702c9f177e283a))
|
||||
* **node:** reap leaked git child processes ([#53](https://github.com/Gitlawb/node/issues/53)) ([#61](https://github.com/Gitlawb/node/issues/61)) ([803d83e](https://github.com/Gitlawb/node/commit/803d83efdbcfa9d98affac1c5aaf8aacc511ae64))
|
||||
* **node:** reject malformed path globs with non-trailing or empty-segment wildcards ([#74](https://github.com/Gitlawb/node/issues/74)) ([#75](https://github.com/Gitlawb/node/issues/75)) ([3d880cd](https://github.com/Gitlawb/node/commit/3d880cd56fed10e4c5ae787b52ce411384950f5e))
|
||||
* **node:** skip withheld-walk when no path-scoped rule can withhold ([#60](https://github.com/Gitlawb/node/issues/60)) ([338ff83](https://github.com/Gitlawb/node/commit/338ff83584f2ab960be7e42ecec64191f5aaeb95))
|
||||
* **release:** give crates explicit versions for release-please ([#129](https://github.com/Gitlawb/node/issues/129)) ([788a868](https://github.com/Gitlawb/node/commit/788a8686c1deafe03573703d72eb927cde81f54d))
|
||||
* **release:** use simple release-type + generic Cargo.toml updaters ([#130](https://github.com/Gitlawb/node/issues/130)) ([12bfb1b](https://github.com/Gitlawb/node/commit/12bfb1b3c86b38f0b22d488cd6e85dfd96e0c37f))
|
||||
* **security:** gate webhook creation through the public-host validator ([#81](https://github.com/Gitlawb/node/issues/81)) ([#92](https://github.com/Gitlawb/node/issues/92)) ([f28fa02](https://github.com/Gitlawb/node/commit/f28fa02236bd65c6a4fb690131ab14640cc53a12))
|
||||
* **security:** reject non-public peer URLs + prune poisoned peers ([#78](https://github.com/Gitlawb/node/issues/78)) ([a8cc33a](https://github.com/Gitlawb/node/commit/a8cc33a185f2649d3fe100ec271ee5739a55eba7))
|
||||
+3
-4
@@ -69,10 +69,9 @@ expected to clear a basic quality bar:
|
||||
- **A real description.** Say what changes and why. "Update code" is not a description.
|
||||
|
||||
A triage bot labels PRs that are missing these and leaves a short note. Nothing is closed
|
||||
automatically while you're engaging. A flagged PR still missing a description after 14 days of
|
||||
inactivity gets a stale warning, and is closed 7 days later if still untouched. A missing issue
|
||||
link alone never triggers closure; that label stays advisory. Closed PRs can be reopened at any
|
||||
time once updated.
|
||||
automatically while you're engaging. A flagged PR that goes 14 days with no linked issue or
|
||||
description gets a stale warning, and is closed 7 days later if still untouched. Closed PRs can
|
||||
be reopened at any time once updated.
|
||||
|
||||
## Development environment
|
||||
|
||||
|
||||
Generated
+24
-27
@@ -329,7 +329,7 @@ dependencies = [
|
||||
"keccak-asm",
|
||||
"paste",
|
||||
"proptest",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rapidhash",
|
||||
"ruint",
|
||||
"rustc-hash",
|
||||
@@ -2107,7 +2107,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2262,7 +2262,7 @@ checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"digest 0.10.7",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"rustversion",
|
||||
]
|
||||
@@ -2928,7 +2928,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 +3300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "git-remote-gitlawb"
|
||||
version = "0.4.0"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gitlawb-core",
|
||||
@@ -3311,7 +3311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-attest"
|
||||
version = "0.4.0"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
@@ -3328,7 +3328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-core"
|
||||
version = "0.4.0"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3355,7 +3355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gitlawb-node"
|
||||
version = "0.4.0"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3365,7 +3365,6 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -3377,7 +3376,6 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"libc",
|
||||
"libp2p-core",
|
||||
"libp2p-dns",
|
||||
"libp2p-gossipsub",
|
||||
@@ -3404,14 +3402,13 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.4.0"
|
||||
version = "0.3.9"
|
||||
dependencies = [
|
||||
"alloy",
|
||||
"anyhow",
|
||||
@@ -3601,7 +3598,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
@@ -3623,7 +3620,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4538,7 +4535,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -5285,7 +5282,7 @@ dependencies = [
|
||||
"bit-vec",
|
||||
"bitflags 2.11.0",
|
||||
"num-traits",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
@@ -5345,14 +5342,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -5419,9 +5416,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.3"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
@@ -5741,7 +5738,7 @@ dependencies = [
|
||||
"primitive-types",
|
||||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"rlp",
|
||||
"ruint-macro",
|
||||
"serde_core",
|
||||
@@ -5804,7 +5801,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6679,9 +6676,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6698,7 +6695,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7061,7 +7058,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.3",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
|
||||
+1
-11
@@ -9,13 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
# Per-crate versions live in each crate's [package] so release-please can manage
|
||||
# them (it cannot rewrite an inherited `version.workspace = true`). They are kept
|
||||
# in lockstep by release-please's generic updater: each crate's `version` line
|
||||
# carries an `# x-release-please-version` annotation and is listed under
|
||||
# `extra-files` in release-please-config.json. The release type is `simple`
|
||||
# (not `rust`) because the rust strategy unconditionally tries to rewrite this
|
||||
# virtual workspace root as a package manifest and errors out.
|
||||
version = "0.3.9"
|
||||
edition = "2021"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -54,7 +48,3 @@ uuid = { version = "1", features = ["v4"] }
|
||||
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
+1
-9
@@ -44,7 +44,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime
|
||||
@@ -73,11 +72,4 @@ VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:${GITLAWB_PORT}/health || exit 1
|
||||
|
||||
# Run under tini so the node is never PID 1. As PID 1 a process must reap
|
||||
# reparented orphans itself; the node does not, so git's reparented grandchildren
|
||||
# (e.g. pack-objects orphaned when a served upload-pack dies on a client
|
||||
# disconnect) would accumulate as zombies until fork() fails with EAGAIN (#53).
|
||||
# tini reaps them and forwards SIGTERM/SIGINT to the node, which runs its own
|
||||
# graceful shutdown. (No `-g`: the node manages its own children, so group-wide
|
||||
# signalling would only disturb in-flight git helpers during shutdown.)
|
||||
ENTRYPOINT ["tini", "--", "gitlawb-node"]
|
||||
ENTRYPOINT ["gitlawb-node"]
|
||||
@@ -119,18 +119,10 @@ docker compose down
|
||||
|
||||
## Install the CLI
|
||||
|
||||
Use the installer once releases are available for your platform:
|
||||
|
||||
```bash
|
||||
# npm (macOS / Linux)
|
||||
npm install -g @gitlawb/gl
|
||||
|
||||
# Homebrew (macOS / Linux)
|
||||
brew install gitlawb/tap/gl
|
||||
|
||||
# curl (macOS / Linux)
|
||||
curl -fsSL https://gitlawb.com/install.sh | sh
|
||||
|
||||
# PowerShell (Windows)
|
||||
irm https://gitlawb.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
+2
-26
@@ -1,40 +1,16 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-06-30",
|
||||
"updated": "2026-05-16",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"operator": "Gitlawb",
|
||||
"operator": "Gitlawb (Kevin)",
|
||||
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
|
||||
"http_url": "https://node.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-04-29"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node2",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node2.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-node3",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://node3.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "gitlawb-manila",
|
||||
"operator": "Gitlawb",
|
||||
"did": null,
|
||||
"http_url": "https://manila.gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"name": "rapybus-gitlawb",
|
||||
"operator": "RapyBus S.A.C.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -272,7 +272,8 @@ fn handle_connect(
|
||||
let mut req = client
|
||||
.post(&post_url)
|
||||
.header("Content-Type", format!("application/x-{}-request", service))
|
||||
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0");
|
||||
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
|
||||
.body(request_body.clone());
|
||||
|
||||
// Add RFC 9421 HTTP Signature auth on push operations
|
||||
if service == "git-receive-pack" {
|
||||
@@ -290,12 +291,7 @@ fn handle_connect(
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the body after signing so the pack bytes are moved, not cloned —
|
||||
// packs can be large and the clone doubled peak memory on push.
|
||||
let pack_resp = req
|
||||
.body(request_body)
|
||||
.send()
|
||||
.with_context(|| format!("POST {post_url}"))?;
|
||||
let pack_resp = req.send().with_context(|| format!("POST {post_url}"))?;
|
||||
|
||||
if !pack_resp.status().is_success() {
|
||||
bail!("POST /{} returned {}", service, pack_resp.status());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -127,7 +127,7 @@ impl Attestation {
|
||||
self.type_
|
||||
)));
|
||||
}
|
||||
Ok(P::deserialize(&self.payload)?)
|
||||
Ok(serde_json::from_value(self.payload.clone())?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.4.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -129,7 +129,7 @@ impl HttpSignature {
|
||||
pub fn missing_components(&self) -> Vec<&str> {
|
||||
COVERED_COMPONENTS
|
||||
.iter()
|
||||
.filter(|c| !self.components.iter().any(|s| s.as_str() == **c))
|
||||
.filter(|c| !self.components.contains(&c.to_string()))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
ed25519-dalek = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -29,10 +28,9 @@ async-graphql-axum = "7"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "limit"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "macros", "migrate"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
bytes = "1"
|
||||
libc = "0.2"
|
||||
cid = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -40,9 +38,6 @@ hmac = { workspace = true }
|
||||
http-body-util = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
dirs-next = "2"
|
||||
# NFC-normalize visibility globs/paths at the matcher seam so a deny rule
|
||||
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
|
||||
unicode-normalization = "0.1"
|
||||
reqwest = { workspace = true }
|
||||
libp2p-core = "0.43"
|
||||
libp2p-gossipsub = "0.49.4"
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Admin subcommands (one-off maintenance), invoked as `gitlawb-node <cmd>`.
|
||||
//! These run instead of the daemon and exit.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::{Config, PurgeSpamArgs};
|
||||
use crate::db::Db;
|
||||
use crate::git::tigris::TigrisClient;
|
||||
|
||||
/// How many repos to delete per query/commit batch.
|
||||
const BATCH: i64 = 500;
|
||||
|
||||
/// Bulk-delete repos whose name matches a Postgres regex, removing DB rows,
|
||||
/// the on-disk bare repo, and (unless skipped) the Tigris archive.
|
||||
///
|
||||
/// Defaults to a dry run: it reports the match count and a sample and deletes
|
||||
/// nothing. Pass `--execute` to actually delete.
|
||||
pub async fn purge_spam(config: &Config, args: &PurgeSpamArgs) -> Result<()> {
|
||||
let db = Db::connect(&config.database_url).await?;
|
||||
|
||||
let total = db.count_repos_by_name_regex(&args.regex).await?;
|
||||
println!("repos matching /{}/ : {total}", args.regex);
|
||||
|
||||
let sample = db.list_repos_by_name_regex(&args.regex, 20).await?;
|
||||
println!("sample (up to 20):");
|
||||
for (id, name, owner, _disk) in &sample {
|
||||
println!(" {name} owner={owner} id={id}");
|
||||
}
|
||||
|
||||
if !args.execute {
|
||||
println!(
|
||||
"\nDRY RUN — nothing deleted. Re-run with --execute to delete these {total} repos \
|
||||
({}).",
|
||||
if args.bulk {
|
||||
"bulk set-based DB delete + on-disk .git"
|
||||
} else if args.skip_tigris {
|
||||
"DB rows + on-disk .git"
|
||||
} else {
|
||||
"DB rows + on-disk .git + Tigris archive"
|
||||
}
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Bulk mode: set-based DB delete (a few statements) + on-disk removal. Far
|
||||
// faster on large match sets / small DB instances than per-repo cascade.
|
||||
if args.bulk {
|
||||
let disk_paths = db.list_disk_paths_by_name_regex(&args.regex).await?;
|
||||
println!(
|
||||
"collected {} on-disk paths; running bulk DB delete…",
|
||||
disk_paths.len()
|
||||
);
|
||||
let deleted = db.bulk_delete_by_name_regex(&args.regex).await?;
|
||||
println!("bulk DB delete done: {deleted} repos removed; removing on-disk repos…");
|
||||
let mut disk_removed = 0u64;
|
||||
for p in &disk_paths {
|
||||
if std::path::Path::new(p).exists() {
|
||||
match std::fs::remove_dir_all(p) {
|
||||
Ok(()) => disk_removed += 1,
|
||||
Err(e) => eprintln!("warning: rm {p} failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("done: {deleted} repos purged (bulk), {disk_removed} on-disk dirs removed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Tigris client (best-effort; archive deletion failures are logged, not fatal).
|
||||
let tigris = if !args.skip_tigris && !config.tigris_bucket.is_empty() {
|
||||
match TigrisClient::new(&config.tigris_bucket).await {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
eprintln!("warning: could not init Tigris ({e}); skipping archive deletion");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut deleted = 0i64;
|
||||
let mut disk_removed = 0i64;
|
||||
let mut tigris_removed = 0i64;
|
||||
|
||||
loop {
|
||||
let take = match args.limit {
|
||||
n if n > 0 => (n - deleted).min(BATCH),
|
||||
_ => BATCH,
|
||||
};
|
||||
if take <= 0 {
|
||||
break;
|
||||
}
|
||||
let batch = db.list_repos_by_name_regex(&args.regex, take).await?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for (id, name, owner_did, disk_path) in &batch {
|
||||
let short = owner_did.rsplit(':').next().unwrap_or(owner_did);
|
||||
let slug = format!("{short}/{name}");
|
||||
|
||||
// DB first: once the row is gone the repo is invisible to all serve/
|
||||
// list paths even if disk/Tigris cleanup lags.
|
||||
db.delete_repo_cascade(id, &slug).await?;
|
||||
deleted += 1;
|
||||
|
||||
if !disk_path.is_empty() && std::path::Path::new(disk_path).exists() {
|
||||
match std::fs::remove_dir_all(disk_path) {
|
||||
Ok(()) => disk_removed += 1,
|
||||
Err(e) => eprintln!("warning: rm {disk_path} failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(t) = &tigris {
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
match t.delete(&owner_slug, name).await {
|
||||
Ok(()) => tigris_removed += 1,
|
||||
Err(e) => eprintln!("warning: tigris delete {owner_slug}/{name} failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("… deleted {deleted}/{total} (disk {disk_removed}, tigris {tigris_removed})");
|
||||
}
|
||||
|
||||
println!(
|
||||
"done: {deleted} repos purged (disk dirs removed: {disk_removed}, tigris archives removed: {tigris_removed})"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::{Extension, Json};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -21,14 +20,6 @@ fn agent_key_segment(did: &str) -> &str {
|
||||
did.split(':').next_back().unwrap_or(did)
|
||||
}
|
||||
|
||||
/// Whether `caller` (the verified `AuthenticatedDid`) is the same identity as
|
||||
/// `target` (the DID being acted on), tolerant of full `did:key:...` vs short
|
||||
/// key-only forms on either side. Used to gate self-deregistration so a DID
|
||||
/// can only retire itself.
|
||||
fn caller_matches_did(caller: &str, target: &str) -> bool {
|
||||
agent_key_segment(caller) == agent_key_segment(target)
|
||||
}
|
||||
|
||||
async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
|
||||
let normalized_did = normalize_agent_did(did);
|
||||
if state.db.get_agent(&normalized_did).await?.is_some() {
|
||||
@@ -80,8 +71,6 @@ pub struct AgentResponse {
|
||||
pub capabilities: Vec<String>,
|
||||
pub registered_at: String,
|
||||
pub last_seen: Option<String>,
|
||||
/// Lifecycle status: `active` or `revoked`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// GET /api/v1/agents
|
||||
@@ -98,7 +87,6 @@ pub async fn list_agents(
|
||||
capabilities: a.capabilities,
|
||||
registered_at: a.registered_at,
|
||||
last_seen: a.last_seen,
|
||||
status: a.status,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "agents": list })))
|
||||
@@ -123,7 +111,6 @@ pub async fn show_agent(
|
||||
capabilities: agent.capabilities,
|
||||
registered_at: agent.registered_at,
|
||||
last_seen: agent.last_seen,
|
||||
status: agent.status,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -146,97 +133,9 @@ pub async fn get_trust(
|
||||
}))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/agents/{did}
|
||||
///
|
||||
/// Self-deregistration: the holder of a DID's key marks their own agent
|
||||
/// `revoked`, removing it from discovery (issue #29). Authenticated by the
|
||||
/// rfc9421 signature middleware; a caller may only retire its own DID.
|
||||
pub async fn deregister_agent(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
// Authorize on the verified identity, and act on it too. The path DID is
|
||||
// only an intent check (it must name the caller); the row we revoke is the
|
||||
// authenticated DID itself, never a value derived from the untrusted path.
|
||||
// Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix
|
||||
// resolver act on a different identity than the one just authorized.
|
||||
if !caller_matches_did(&auth.0, &did) {
|
||||
return Err(AppError::BadRequest(
|
||||
"an agent can only deregister itself".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if state.db.revoke_agent(&auth.0).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(AppError::NotFound(format!("agent {} not found", auth.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse};
|
||||
|
||||
#[test]
|
||||
fn caller_matches_own_did_full_form() {
|
||||
assert!(caller_matches_did(
|
||||
"did:key:z6MkExample",
|
||||
"did:key:z6MkExample"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_matches_own_did_short_form() {
|
||||
// Authenticated full DID vs short path form, and vice versa.
|
||||
assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample"));
|
||||
assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_cannot_revoke_a_different_did() {
|
||||
// The core authorization property for issue #29.
|
||||
assert!(!caller_matches_did(
|
||||
"did:key:z6MkAttacker",
|
||||
"did:key:z6MkVictim"
|
||||
));
|
||||
assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_response_surfaces_status() {
|
||||
// A revoked DID's response must carry its status so callers can see it
|
||||
// is retired (issue #29).
|
||||
let resp = AgentResponse {
|
||||
did: "did:key:orphan".to_string(),
|
||||
trust_score: 0.1,
|
||||
capabilities: vec!["reputation:score".to_string()],
|
||||
registered_at: "2026-06-19T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
status: "revoked".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
|
||||
assert_eq!(json["status"], "revoked");
|
||||
assert!(json.get("replaced_by").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_response_active_status() {
|
||||
let resp = AgentResponse {
|
||||
did: "did:key:active".to_string(),
|
||||
trust_score: 0.5,
|
||||
capabilities: vec![],
|
||||
registered_at: "2026-06-19T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
status: "active".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
|
||||
assert_eq!(json["status"], "active");
|
||||
}
|
||||
use super::{agent_key_segment, normalize_agent_did};
|
||||
|
||||
#[test]
|
||||
fn normalize_agent_did_preserves_full_did() {
|
||||
|
||||
@@ -79,9 +79,12 @@ pub async fn create_bounty(
|
||||
return Err(AppError::BadRequest("amount must be positive".into()));
|
||||
}
|
||||
|
||||
// Read-gate: anyone who can read the repo may fund a bounty on it (this closes
|
||||
// the private-repo bounty leak); ownership is not required.
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
// Verify repo exists
|
||||
let _ = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let bounty = BountyRecord {
|
||||
@@ -210,12 +213,8 @@ pub async fn submit_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
let is_claimant = bounty
|
||||
.claimant_did
|
||||
.as_deref()
|
||||
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
|
||||
if !is_claimant {
|
||||
return Err(AppError::Forbidden("only the claimant can submit".into()));
|
||||
if bounty.claimant_did.as_deref() != Some(&auth.0) {
|
||||
return Err(AppError::BadRequest("only the claimant can submit".into()));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -250,8 +249,8 @@ pub async fn approve_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the bounty creator can approve".into(),
|
||||
));
|
||||
}
|
||||
@@ -297,8 +296,8 @@ pub async fn cancel_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the bounty creator can cancel".into(),
|
||||
));
|
||||
}
|
||||
@@ -333,19 +332,6 @@ pub async fn dispute_bounty(
|
||||
)));
|
||||
}
|
||||
|
||||
// Only the bounty creator or the current claimant may dispute (N19 — this
|
||||
// endpoint had no identity check at all).
|
||||
let is_creator = crate::api::did_matches(&auth.0, &bounty.creator_did);
|
||||
let is_claimant = bounty
|
||||
.claimant_did
|
||||
.as_deref()
|
||||
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
|
||||
if !is_creator && !is_claimant {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the bounty creator or claimant can dispute this bounty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if deadline exceeded
|
||||
if let Some(ref claimed_at) = bounty.claimed_at {
|
||||
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
|
||||
|
||||
@@ -9,47 +9,28 @@
|
||||
//! `git cat-file <type> <sha256>` (i.e. without the git framing header).
|
||||
//! This is consistent with how `gitlawb_core::cid::Cid::from_git_object_bytes`
|
||||
//! computes CIDs when objects are pushed.
|
||||
//!
|
||||
//! Serving is access-controlled: an object is returned only from a repo row the
|
||||
//! requesting caller is permitted to read (per-caller path-scoped visibility,
|
||||
//! see `get_by_cid`).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Extension, Json,
|
||||
Json,
|
||||
};
|
||||
use cid::CidGeneric;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::git::store;
|
||||
use crate::git::visibility_pack::{has_path_scoped_rule, withheld_blob_oids};
|
||||
use crate::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
/// GET /ipfs/{cid}
|
||||
///
|
||||
/// Search all repos on the node for a git object whose SHA-256 hash matches
|
||||
/// the given CIDv1, returning its raw content if the caller may read it.
|
||||
///
|
||||
/// Visibility (#110): the object is served only from a repo row the caller
|
||||
/// passes. For each iterated row we gate against that row's OWN rules
|
||||
/// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read`
|
||||
/// — `get_repo`'s fuzzy match could otherwise authorize a different physical
|
||||
/// row than the one read (KTD2a). When the row carries path-scoped rules, a
|
||||
/// blob withheld from the caller (`withheld_blob_oids`) is skipped. Denial and
|
||||
/// genuine not-found both fall through to an opaque 404.
|
||||
///
|
||||
/// Scope: this closes the direct unauthenticated scan. A stale-public mirror
|
||||
/// row still serves withheld content (tracked separately, #124).
|
||||
/// the given CIDv1. Returns the raw object content bytes with appropriate
|
||||
/// headers if found, or 404 if not found.
|
||||
pub async fn get_by_cid(
|
||||
Path(cid_str): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Response> {
|
||||
// 1. Decode the CID and extract the SHA-256 digest
|
||||
let cid = CidGeneric::<64>::from_str(&cid_str)
|
||||
@@ -65,8 +46,6 @@ pub async fn get_by_cid(
|
||||
}
|
||||
|
||||
let sha256_hex = hex::encode(mh.digest());
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let caller_owned = caller.map(|c| c.to_string());
|
||||
|
||||
// 2. Search all repos for an object with this SHA-256
|
||||
let repos = state
|
||||
@@ -75,74 +54,12 @@ pub async fn get_by_cid(
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Fetch every repo's visibility rules in one query rather than one per row
|
||||
// (the gate runs each row against its OWN rules — KTD2a). A row absent from
|
||||
// the map has no rules.
|
||||
let repo_ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state
|
||||
.db
|
||||
.list_visibility_rules_for_repos(&repo_ids)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Request-scoped memo of the per-repo withheld set (KTD1). The caller is
|
||||
// constant for one request, so `repo.id` alone is a safe, sufficient key —
|
||||
// never a coarse caller "class", which `visibility_check`'s exact full-DID
|
||||
// reader match would make unsafe.
|
||||
let mut withheld_memo: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
|
||||
for repo in &repos {
|
||||
// Repo-level read gate against THIS row's own rules (KTD2a).
|
||||
let rules: &[crate::db::VisibilityRule] = rules_by_repo
|
||||
.get(&repo.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
if visibility_check(rules, repo.is_public, &repo.owner_did, caller, "/") == Decision::Deny {
|
||||
continue;
|
||||
}
|
||||
|
||||
let repo_path = match state.repo_store.acquire(&repo.owner_did, &repo.name).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Per-blob withholding only applies when a path-scoped rule exists (KTD4).
|
||||
if has_path_scoped_rule(rules) {
|
||||
if !withheld_memo.contains_key(&repo.id) {
|
||||
let rp = repo_path.clone();
|
||||
let r = rules.to_vec();
|
||||
let is_public = repo.is_public;
|
||||
let owner = repo.owner_did.clone();
|
||||
let caller_for_walk = caller_owned.clone();
|
||||
// Full-history walk shells out to git — keep it off the async runtime.
|
||||
let walk = tokio::task::spawn_blocking(move || {
|
||||
withheld_blob_oids(&rp, &r, is_public, &owner, caller_for_walk.as_deref())
|
||||
})
|
||||
.await;
|
||||
// Fail closed on EITHER a task panic (JoinError) or a walk error:
|
||||
// we cannot prove the caller may read here, so skip this repo and
|
||||
// let a public copy (if any) serve. Never serve on an unproven gate.
|
||||
let set = match walk {
|
||||
Ok(Ok(set)) => set,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "withheld walk failed; skipping repo");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "withheld walk task panicked; skipping repo");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
withheld_memo.insert(repo.id.clone(), set);
|
||||
}
|
||||
if withheld_memo
|
||||
.get(&repo.id)
|
||||
.is_some_and(|set| set.contains(&sha256_hex))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match store::read_object(&repo_path, &sha256_hex) {
|
||||
Ok(Some((_obj_type, content))) => {
|
||||
// 3. Return the content with IPFS-style headers
|
||||
|
||||
@@ -39,11 +39,11 @@ pub async fn create_issue(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<CreateIssueRequest>,
|
||||
) -> Result<(StatusCode, Json<IssueRecord>)> {
|
||||
// Authorize the caller as a reader before accepting an issue: a non-reader
|
||||
// must not be able to file an issue against a private repo they cannot read.
|
||||
// Mirrors create_issue_comment / create_review / create_bounty.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let issue_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -161,9 +161,11 @@ pub async fn create_issue_comment(
|
||||
));
|
||||
}
|
||||
|
||||
// Read-gate: a commenter must be able to read the repo, but need not own it.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
@@ -205,7 +207,7 @@ pub async fn list_issue_comments(
|
||||
/// POST /api/v1/repos/{owner}/{repo}/issues/{id}/close
|
||||
pub async fn close_issue(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Extension(_auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -221,33 +223,6 @@ pub async fn close_issue(
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
// Owner OR issue author may close. The author lives in the issue's git-JSON
|
||||
// blob (not a DB column); a None author (legacy issues) falls back to
|
||||
// owner-only. Read it under the write guard, before mutating.
|
||||
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
|
||||
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
Ok(None) => {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
|
||||
}
|
||||
Err(e) => {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Git(e.to_string()));
|
||||
}
|
||||
};
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
let is_author = author_did
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let close_result = git_issues::close_issue(&disk_path, &issue_id);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct LabelRequest {
|
||||
/// POST /api/v1/repos/:owner/:repo/labels
|
||||
pub async fn add_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Extension(_auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<LabelRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
@@ -39,7 +39,6 @@ pub async fn add_label(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let added = state.db.add_label(&record.id, &label).await?;
|
||||
let status = if added {
|
||||
@@ -56,7 +55,7 @@ pub async fn add_label(
|
||||
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
|
||||
pub async fn remove_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Extension(_auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, label)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -64,7 +63,6 @@ pub async fn remove_label(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
state.db.remove_label(&record.id, &label).await?;
|
||||
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
|
||||
|
||||
@@ -47,13 +47,6 @@ pub(crate) async fn authorize_repo_read(
|
||||
.get_repo(owner, name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// A quarantined mirror (admitted by the iCaptcha propagation gate but not
|
||||
// validated) is hidden from every reader — serve/clone and fork alike — as if
|
||||
// it did not exist, until an operator releases it. Checked before the
|
||||
// visibility gate so its existence is never disclosed.
|
||||
if state.db.is_repo_quarantined(&record.id).await? {
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
|
||||
}
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, path) == Decision::Deny
|
||||
{
|
||||
@@ -61,511 +54,3 @@ pub(crate) async fn authorize_repo_read(
|
||||
}
|
||||
Ok((record, rules))
|
||||
}
|
||||
|
||||
/// Match a presented DID against a stored DID that may be the full `did:key:<id>`
|
||||
/// form or the bare `<id>` short form (mirror rows store the bare key). Collapse
|
||||
/// representation only within `did:key`; never let a bare id match across methods —
|
||||
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
|
||||
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
|
||||
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
fn key_id(d: &str) -> &str {
|
||||
d.strip_prefix("did:key:").unwrap_or(d)
|
||||
}
|
||||
let (ka, kb) = (key_id(a), key_id(b));
|
||||
// After stripping `did:key:`, a value still containing ':' is a non-key full
|
||||
// DID — do not let it match a bare `did:key` id.
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
/// and the author check (close policy) share one normalization.
|
||||
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
|
||||
if did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
"only the repo owner can perform this action".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod did_tests {
|
||||
use super::did_matches;
|
||||
|
||||
#[test]
|
||||
fn full_matches_bare_same_key() {
|
||||
assert!(did_matches("did:key:zABC", "zABC"));
|
||||
assert!(did_matches("zABC", "did:key:zABC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_method_collision() {
|
||||
assert!(!did_matches("did:key:zABC", "did:gitlawb:zABC"));
|
||||
assert!(!did_matches("did:key:zABC", "did:web:zABC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_match_and_distinct_keys() {
|
||||
assert!(did_matches("did:key:zABC", "did:key:zABC"));
|
||||
assert!(!did_matches("did:key:zABC", "did:key:zXYZ"));
|
||||
assert!(!did_matches("zABC", "zXYZ"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drift guard (plan 002 §Gate-type table, Step 5). Every in-scope mutation
|
||||
/// handler must contain its expected gate marker in its own body; removing a
|
||||
/// gate fails this test. Source-level (no DB), so it runs everywhere. When a new
|
||||
/// route is added to an in-scope group, add its row here with a deliberate gate
|
||||
/// type — that forced decision is the point.
|
||||
///
|
||||
/// Markers are gate-SHAPED — a call (`require_repo_owner(`, `did_matches(`) or a
|
||||
/// binding/comparison expression (`caller != &record.owner_did`,
|
||||
/// `let owner_did = auth.0`) — never a bare identifier that could also appear in
|
||||
/// a log line. Full-line comments are stripped before matching, so a marker that
|
||||
/// survives only as a comment above a deleted gate does NOT satisfy a row.
|
||||
#[cfg(test)]
|
||||
mod authz_guard {
|
||||
/// The body of `func` with full-line comments removed. Bounds the slice at the
|
||||
/// next top-level fn item so a marker in a later handler can't leak in,
|
||||
/// tolerating `pub async`, `pub(crate) async`, `async`, `pub`, and bare `fn`
|
||||
/// declarations (the old single-`pub async fn` delimiter over-ran on any other
|
||||
/// form).
|
||||
fn fn_body(src: &str, func: &str) -> String {
|
||||
let needle = format!("fn {func}(");
|
||||
let start = src
|
||||
.find(&needle)
|
||||
.unwrap_or_else(|| panic!("handler `{func}` not found (renamed or removed?)"));
|
||||
let rest = &src[start..];
|
||||
let end = [
|
||||
"\npub async fn ",
|
||||
"\npub(crate) async fn ",
|
||||
"\nasync fn ",
|
||||
"\npub fn ",
|
||||
"\nfn ",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
|
||||
.min()
|
||||
.unwrap_or(rest.len());
|
||||
rest[..end]
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("//"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_in_scope_mutation_has_its_gate() {
|
||||
let pulls = include_str!("pulls.rs");
|
||||
let webhooks = include_str!("webhooks.rs");
|
||||
let labels = include_str!("labels.rs");
|
||||
let issues = include_str!("issues.rs");
|
||||
let bounties = include_str!("bounties.rs");
|
||||
let replicas = include_str!("replicas.rs");
|
||||
let tasks = include_str!("tasks.rs");
|
||||
let stars = include_str!("stars.rs");
|
||||
let protect = include_str!("protect.rs");
|
||||
let visibility = include_str!("visibility.rs");
|
||||
let profiles = include_str!("profiles.rs");
|
||||
let repos = include_str!("repos.rs");
|
||||
let register = include_str!("register.rs");
|
||||
let ipfs = include_str!("ipfs.rs");
|
||||
|
||||
// (source, handler, expected gate marker)
|
||||
let rows: &[(&str, &str, &str)] = &[
|
||||
// Bucket A — owner-gate (require_repo_owner -> 403)
|
||||
(pulls, "merge_pr", "require_repo_owner("),
|
||||
(webhooks, "create_webhook", "require_repo_owner("),
|
||||
(webhooks, "delete_webhook", "require_repo_owner("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
// Bucket A' — owner OR author (did_matches against the author)
|
||||
(pulls, "close_pr", "did_matches("),
|
||||
(issues, "close_issue", "did_matches("),
|
||||
// Bucket B — read-gate (authorize_repo_read)
|
||||
(pulls, "create_review", "authorize_repo_read("),
|
||||
(pulls, "create_comment", "authorize_repo_read("),
|
||||
(pulls, "create_pr", "authorize_repo_read("),
|
||||
(issues, "create_issue_comment", "authorize_repo_read("),
|
||||
(issues, "create_issue", "authorize_repo_read("),
|
||||
(bounties, "create_bounty", "authorize_repo_read("),
|
||||
(repos, "fork_repo", "authorize_repo_read("),
|
||||
// get_by_cid gates each iterated repo row directly via visibility_check
|
||||
// (KTD2a: it must NOT route through authorize_repo_read's fuzzy re-resolve).
|
||||
(ipfs, "get_by_cid", "visibility_check("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(tasks, "complete_task", "did_matches("),
|
||||
(tasks, "fail_task", "did_matches("),
|
||||
(repos, "create_repo", "let owner_did = auth.0"),
|
||||
(profiles, "set_profile", "let did = auth.0"),
|
||||
(register, "register", "did_matches("),
|
||||
(stars, "star_repo", "caller = &auth.0"),
|
||||
(stars, "unstar_repo", "caller = &auth.0"),
|
||||
// Bucket D — non-owner-by-design, positive per-route marker
|
||||
(bounties, "claim_bounty", "claim_bounty(&id, &auth.0"),
|
||||
(bounties, "submit_bounty", "did_matches("),
|
||||
(bounties, "approve_bounty", "did_matches("),
|
||||
(bounties, "cancel_bounty", "did_matches("),
|
||||
(bounties, "dispute_bounty", "did_matches("),
|
||||
(replicas, "register_replica", "did_matches("),
|
||||
(replicas, "unregister_replica", "replica_did = &auth.0"),
|
||||
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
|
||||
(protect, "protect_branch", "did_matches("),
|
||||
(protect, "unprotect_branch", "did_matches("),
|
||||
(visibility, "set_visibility", "require_owner("),
|
||||
(visibility, "remove_visibility", "require_owner("),
|
||||
(visibility, "list_visibility", "require_owner("),
|
||||
];
|
||||
|
||||
// The visibility rows prove require_owner is CALLED; this proves the helper
|
||||
// itself does DID-safe matching, not a raw/trailing-segment compare.
|
||||
assert!(
|
||||
fn_body(visibility, "require_owner").contains("did_matches("),
|
||||
"visibility::require_owner must use did_matches for DID-safe owner matching"
|
||||
);
|
||||
|
||||
for (src, func, marker) in rows {
|
||||
let body = fn_body(src, func);
|
||||
assert!(
|
||||
body.contains(marker),
|
||||
"handler `{func}` is missing its gate marker `{marker}` — gate removed or route reclassified"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves the comment-stripping that GUARD-1 added: a marker that appears only
|
||||
/// in a full-line comment (the real `replicas.rs` false-pass shape) must NOT
|
||||
/// satisfy a row.
|
||||
#[test]
|
||||
fn comment_only_marker_does_not_satisfy_a_row() {
|
||||
let src = "pub async fn demo() {\n // did_matches( handles the owner form\n do_thing();\n}\n";
|
||||
assert!(
|
||||
!fn_body(src, "demo").contains("did_matches("),
|
||||
"a marker present only in a comment must not count as an enforced gate"
|
||||
);
|
||||
}
|
||||
|
||||
/// All `pub async fn` / `pub(crate) async fn` handler names declared in `src`.
|
||||
/// Verb-agnostic on purpose: a repo-scoped read of any name is in scope, so a
|
||||
/// handler named `fetch_*` / `replicate_*` / `info_refs` cannot escape the gate
|
||||
/// check by not being called `list_*` / `get_*`.
|
||||
fn handler_names(src: &str) -> Vec<String> {
|
||||
["pub async fn ", "pub(crate) async fn "]
|
||||
.iter()
|
||||
.flat_map(|decl| {
|
||||
src.match_indices(decl).map(move |(i, _)| {
|
||||
src[i + decl.len()..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect::<String>()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True when the handler's signature takes an owner+repo path
|
||||
/// (`Path<(String, String...)>`), i.e. it is a repo-scoped read rather than a
|
||||
/// node-wide aggregate (`/stats`, `/ipfs/pins`, the global feeds).
|
||||
fn is_repo_scoped(body: &str) -> bool {
|
||||
let header = &body[..body.find('{').unwrap_or(body.len())];
|
||||
header.contains("Path<(String, String")
|
||||
}
|
||||
|
||||
/// True when at least one gate marker runs for EVERY service — i.e. some
|
||||
/// marker sits outside any `if service == ...` discriminator block. A gate
|
||||
/// that appears ONLY inside such a block (as the info/refs advertisement gate
|
||||
/// did before #119: `visibility_check` ran under `if service ==
|
||||
/// "git-upload-pack"`, leaving `git-receive-pack` ungated) covers a subset of
|
||||
/// services and must NOT count as a full gate. Other handlers carry no
|
||||
/// `service ==` discriminator, so for them this matches the plain
|
||||
/// substring check. NOTE: only `if service ==` is detected — a
|
||||
/// `match service { .. }` discriminator is NOT tracked and a gate inside one
|
||||
/// arm would pass as full; avoid that shape, or extend the span loop below.
|
||||
fn gate_runs_unconditionally(body: &str, markers: &[&str]) -> bool {
|
||||
// Brace-matched spans of each `if service == ...` block.
|
||||
let mut cond_spans: Vec<(usize, usize)> = Vec::new();
|
||||
let mut search = 0;
|
||||
while let Some(rel) = body[search..].find("if service ==") {
|
||||
let cond_start = search + rel;
|
||||
let Some(brace_rel) = body[cond_start..].find('{') else {
|
||||
break;
|
||||
};
|
||||
let open = cond_start + brace_rel;
|
||||
let mut depth = 0i32;
|
||||
let mut end = body.len();
|
||||
for (i, c) in body[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = open + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cond_spans.push((open, end));
|
||||
// On an unclosed block `end` stays at body.len() (the fail-safe
|
||||
// direction: treat the rest as conditional rather than mask a gate);
|
||||
// clamp so the next slice can't index past the end and panic.
|
||||
search = (end + 1).min(body.len());
|
||||
}
|
||||
markers.iter().any(|m| {
|
||||
body.match_indices(m)
|
||||
.any(|(pos, _)| !cond_spans.iter().any(|(s, e)| pos >= *s && pos <= *e))
|
||||
})
|
||||
}
|
||||
|
||||
/// Egress gate guard: every repo-scoped handler (`Path<(String, String..)>`)
|
||||
/// must carry an authz marker — a read gate (`authorize_repo_read` /
|
||||
/// `visibility_check`), or a write gate (`require_repo_owner` / `require_owner`
|
||||
/// / `did_matches` / a `&auth.0` self-binding) — or be listed in KNOWN_UNGATED
|
||||
/// (the tracked, ungated reads). A new ungated handler of ANY verb trips this,
|
||||
/// and a completeness scan over `src/api/` trips it for a whole new module that
|
||||
/// adds a repo-scoped handler without being wired into `sources`. Mutations are
|
||||
/// also checked precisely, per handler, by the mutation guard above; here they
|
||||
/// only need *some* binding so the net stays verb-agnostic.
|
||||
///
|
||||
/// Scope and limits (this is a source scrape; the runtime route guard covers
|
||||
/// behaviour):
|
||||
/// - It proves a gate is CALLED, not that it runs on the requested path: a
|
||||
/// content handler gating `"/"` instead of the subtree still passes here.
|
||||
/// - It sees handlers taking the owner+repo tuple `Path<(String, String..)>`; a
|
||||
/// repo identified by a struct/custom extractor would be missed (the helper
|
||||
/// unit tests pin these boundaries).
|
||||
/// - KNOWN_UNGATED entries need a real open issue and review; the staleness
|
||||
/// assert removes one only once its gate lands.
|
||||
#[test]
|
||||
fn every_repo_scoped_handler_is_gated() {
|
||||
let sources: &[(&str, &str)] = &[
|
||||
(include_str!("bounties.rs"), "bounties.rs"),
|
||||
(include_str!("certs.rs"), "certs.rs"),
|
||||
(include_str!("changelog.rs"), "changelog.rs"),
|
||||
(include_str!("encrypted.rs"), "encrypted.rs"),
|
||||
(include_str!("events.rs"), "events.rs"),
|
||||
(include_str!("issues.rs"), "issues.rs"),
|
||||
(include_str!("labels.rs"), "labels.rs"),
|
||||
(include_str!("protect.rs"), "protect.rs"),
|
||||
(include_str!("pulls.rs"), "pulls.rs"),
|
||||
(include_str!("replicas.rs"), "replicas.rs"),
|
||||
(include_str!("repos.rs"), "repos.rs"),
|
||||
(include_str!("stars.rs"), "stars.rs"),
|
||||
(include_str!("visibility.rs"), "visibility.rs"),
|
||||
(include_str!("webhooks.rs"), "webhooks.rs"),
|
||||
];
|
||||
let listed: std::collections::HashSet<&str> = sources.iter().map(|(_, f)| *f).collect();
|
||||
assert!(!listed.is_empty(), "read-guard `sources` is empty");
|
||||
|
||||
// Completeness: every api/*.rs NOT already in `sources` must declare no
|
||||
// repo-scoped handler, so a brand-new module cannot add an ungated handler
|
||||
// the scrape never looks at. Reads the directory at test time (so the list
|
||||
// cannot silently drift from the filesystem) and only inspects unlisted
|
||||
// files — listed files are covered by the per-handler loop below.
|
||||
let api_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/api");
|
||||
for (_, f) in sources {
|
||||
assert!(
|
||||
std::path::Path::new(api_dir).join(f).exists(),
|
||||
"read-guard `sources` lists {f} but the file does not exist"
|
||||
);
|
||||
}
|
||||
for entry in std::fs::read_dir(api_dir).expect("read src/api") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
let fname = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
if !fname.ends_with(".rs") || fname == "mod.rs" || listed.contains(fname.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let src = std::fs::read_to_string(&path).expect("read api file");
|
||||
let has_repo_handler = handler_names(&src)
|
||||
.iter()
|
||||
.any(|n| is_repo_scoped(&fn_body(&src, n)));
|
||||
assert!(
|
||||
!has_repo_handler,
|
||||
"api/{fname} declares a repo-scoped handler but is not in the egress \
|
||||
guard `sources` list — add it so its handlers are gate-checked"
|
||||
);
|
||||
}
|
||||
|
||||
// Repo-scoped reads known to be ungated today, each tracked by an issue.
|
||||
// Remove an entry the moment its gate lands (the staleness assert enforces it).
|
||||
let known_ungated: &[(&str, &str)] = &[
|
||||
// info/refs gates only git-upload-pack today; git-receive-pack
|
||||
// advertisement is ungated until #119 makes the gate unconditional.
|
||||
("git_info_refs", "#119"),
|
||||
("list_certs", "#120"),
|
||||
("get_cert", "#120"),
|
||||
("list_issues", "#120"),
|
||||
("get_issue", "#120"),
|
||||
("list_issue_comments", "#120"),
|
||||
("list_labels", "#120"),
|
||||
("list_repo_bounties", "#120"),
|
||||
("get_star_status", "#120"),
|
||||
("list_repo_events", "#94 (PR #113)"),
|
||||
("list_webhooks", "#94 (PR #113)"),
|
||||
("list_replicas", "PR #113"),
|
||||
("list_protected_branches", "PR #113"),
|
||||
];
|
||||
let is_known = |n: &str| known_ungated.iter().any(|(k, _)| *k == n);
|
||||
// Any one of these = the handler binds the caller to an authz decision: the
|
||||
// first two are read gates, the rest are the write/owner/self-binding forms.
|
||||
// A repo-scoped mutation passes here on its write gate; the mutation guard
|
||||
// above additionally verifies the exact gate type per handler. NOTE: a local
|
||||
// rename of `caller`/`replica_did` must be paired with a marker update here.
|
||||
let markers = [
|
||||
"authorize_repo_read(",
|
||||
"visibility_check(",
|
||||
"require_repo_owner(",
|
||||
"require_owner(",
|
||||
"did_matches(",
|
||||
"caller = &auth.0",
|
||||
"replica_did = &auth.0",
|
||||
];
|
||||
|
||||
// Every KNOWN_UNGATED name must be a real handler (catch typos / renames).
|
||||
let all: std::collections::HashSet<String> =
|
||||
sources.iter().flat_map(|(s, _)| handler_names(s)).collect();
|
||||
for (n, _) in known_ungated {
|
||||
assert!(
|
||||
all.contains(*n),
|
||||
"KNOWN_UNGATED lists `{n}`, which is not a real handler (renamed or removed?)"
|
||||
);
|
||||
}
|
||||
|
||||
let mut checked = 0usize;
|
||||
for (src, file) in sources {
|
||||
for name in handler_names(src) {
|
||||
let body = fn_body(src, &name);
|
||||
if !is_repo_scoped(&body) {
|
||||
continue; // node-wide aggregate, not a repo-scoped surface
|
||||
}
|
||||
checked += 1;
|
||||
let gated = gate_runs_unconditionally(&body, &markers);
|
||||
assert!(
|
||||
gated || is_known(&name),
|
||||
"repo-scoped handler `{name}` ({file}) has no authz gate and is \
|
||||
not in KNOWN_UNGATED — add the visibility/owner gate with the \
|
||||
caller, or track it there"
|
||||
);
|
||||
assert!(
|
||||
!(gated && is_known(&name)),
|
||||
"handler `{name}` ({file}) is now gated — remove it from \
|
||||
KNOWN_UNGATED so the allowlist stays an accurate gap list"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Tripwire: if the scrape silently stopped finding handlers (e.g. a parser
|
||||
// regression in handler_names/is_repo_scoped), this floor fails loudly
|
||||
// instead of the guard passing vacuously. Current count is ~54 repo-scoped
|
||||
// handlers; 20 is a deliberate floor that trips only on a gross regression.
|
||||
assert!(
|
||||
checked >= 20,
|
||||
"egress guard only checked {checked} repo-scoped handlers — the scrape likely broke"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins the `handler_names` boundary: it collects every `pub`/`pub(crate)
|
||||
/// async fn` regardless of verb (so a `fetch_*` read cannot escape), and skips
|
||||
/// private `async fn` and sync `fn` helpers.
|
||||
#[test]
|
||||
fn handler_names_finds_all_pub_async_and_skips_others() {
|
||||
let src = "pub async fn list_things() {}\n\
|
||||
pub async fn fetch_thing() {}\n\
|
||||
pub(crate) async fn get_crate_thing() {}\n\
|
||||
async fn private_helper() {}\n\
|
||||
fn sync_helper() {}\n";
|
||||
let names = handler_names(src);
|
||||
// Verb-agnostic: a non-list/get read verb is still seen.
|
||||
assert!(names.contains(&"list_things".to_string()));
|
||||
assert!(names.contains(&"fetch_thing".to_string()));
|
||||
// pub(crate) routed handlers are in scope too.
|
||||
assert!(names.contains(&"get_crate_thing".to_string()));
|
||||
// Private/sync helpers are not routed handlers and are skipped.
|
||||
assert!(!names
|
||||
.iter()
|
||||
.any(|n| n == "private_helper" || n == "sync_helper"));
|
||||
}
|
||||
|
||||
/// Pins the `is_repo_scoped` boundary: an owner+repo tuple Path is repo-scoped;
|
||||
/// a single-segment or absent Path is a node-wide aggregate.
|
||||
#[test]
|
||||
fn is_repo_scoped_requires_owner_repo_path() {
|
||||
let repo =
|
||||
"fn get_x(s: State, Path((owner, name)): Path<(String, String)>) {\n body();\n}";
|
||||
let three = "fn get_y(Path((o, n, id)): Path<(String, String, String)>) {\n body();\n}";
|
||||
let node_wide = "fn list_z(s: State<AppState>) {\n body();\n}";
|
||||
let single = "fn get_w(Path(cid): Path<String>) {\n body();\n}";
|
||||
assert!(is_repo_scoped(repo), "owner+repo tuple is repo-scoped");
|
||||
assert!(is_repo_scoped(three), "owner+repo+id tuple is repo-scoped");
|
||||
assert!(!is_repo_scoped(node_wide), "no Path is node-wide");
|
||||
assert!(
|
||||
!is_repo_scoped(single),
|
||||
"single-segment Path is not repo-scoped"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins `gate_runs_unconditionally`: a gate nested only inside an
|
||||
/// `if service == ...` block is conditional (does NOT count), while the same
|
||||
/// gate at the top level — or an additional unconditional one — does.
|
||||
#[test]
|
||||
fn conditional_service_gate_is_not_a_full_gate() {
|
||||
let markers = ["visibility_check("];
|
||||
// Gate runs only for one service: not a full gate.
|
||||
let conditional = "fn f() {\n \
|
||||
if service == \"git-upload-pack\" {\n \
|
||||
visibility_check(rules, caller);\n \
|
||||
}\n \
|
||||
if service == \"git-receive-pack\" { acquire_fresh(); }\n}";
|
||||
assert!(
|
||||
!gate_runs_unconditionally(conditional, &markers),
|
||||
"a gate only inside `if service ==` covers a subset of services"
|
||||
);
|
||||
// Same gate at top level: full gate.
|
||||
let unconditional = "fn f() {\n \
|
||||
visibility_check(rules, caller);\n \
|
||||
if service == \"git-receive-pack\" { acquire_fresh(); }\n}";
|
||||
assert!(
|
||||
gate_runs_unconditionally(unconditional, &markers),
|
||||
"an unconditional gate runs for every service"
|
||||
);
|
||||
// A gate inside EACH of two service blocks, none outside: still a
|
||||
// subset (no service clears it unconditionally), so not a full gate.
|
||||
let both_conditional = "fn f() {\n \
|
||||
if service == \"git-upload-pack\" { visibility_check(a); }\n \
|
||||
if service == \"git-receive-pack\" { visibility_check(b); }\n}";
|
||||
assert!(
|
||||
!gate_runs_unconditionally(both_conditional, &markers),
|
||||
"a gate inside every service block is still conditional"
|
||||
);
|
||||
// A marker inside one block AND again unconditionally: the
|
||||
// unconditional occurrence makes it a full gate (exercises the
|
||||
// match_indices scan past the in-block hit).
|
||||
let inside_and_outside = "fn f() {\n \
|
||||
if service == \"git-upload-pack\" { visibility_check(a); }\n \
|
||||
visibility_check(b);\n}";
|
||||
assert!(
|
||||
gate_runs_unconditionally(inside_and_outside, &markers),
|
||||
"an unconditional occurrence counts even when another is conditional"
|
||||
);
|
||||
// No marker at all: not gated.
|
||||
assert!(!gate_runs_unconditionally(
|
||||
"fn f() { do_thing(); }",
|
||||
&markers
|
||||
));
|
||||
// An unclosed `if service ==` block (e.g. phantom brace from a string
|
||||
// literal) must not panic on the slice advance; the span runs to EOF, so
|
||||
// the in-block marker reads as conditional. Real Rust source is balanced,
|
||||
// so this only guards the scraper against a future pathological body.
|
||||
let unclosed = "fn f() { if service == \"x\" { visibility_check(a);";
|
||||
assert!(
|
||||
!gate_runs_unconditionally(unclosed, &markers),
|
||||
"an unclosed service block must not panic and stays conditional"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,123 +40,6 @@ pub struct PeerResponse {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
|
||||
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
|
||||
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
|
||||
/// (`64:ff9b::/96`). Returns `None` for native IPv6. Callers fold the result
|
||||
/// back through the v4 range checks so loopback/private addresses smuggled in
|
||||
/// via any of these encodings are rejected, not just the mapped/compatible pair.
|
||||
///
|
||||
/// INCOMPLETE-NAT64 CONTRACT: non-well-known NAT64 prefixes (e.g. the RFC 8215
|
||||
/// local-use `64:ff9b:1::/48`) are deliberately **not** decoded here — their v4
|
||||
/// sits at a prefix-length-dependent offset (RFC 6052 §2.2) — so they return
|
||||
/// `None`. Any caller that needs them blocked must reject the wider
|
||||
/// `64:ff9b::/32` itself; `is_public_http_url` does this in its native-v6 arm.
|
||||
fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
|
||||
return Some(v4);
|
||||
}
|
||||
let s = v6.segments();
|
||||
// 6to4 (2002::/16) embeds W.X.Y.Z in the next two segments.
|
||||
if s[0] == 0x2002 {
|
||||
let [a, b] = s[1].to_be_bytes();
|
||||
let [c, d] = s[2].to_be_bytes();
|
||||
return Some(std::net::Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
// NAT64 well-known prefix (64:ff9b::/96) embeds the IPv4 in the low 32 bits.
|
||||
if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
|
||||
let [a, b] = s[6].to_be_bytes();
|
||||
let [c, d] = s[7].to_be_bytes();
|
||||
return Some(std::net::Ipv4Addr::new(a, b, c, d));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
|
||||
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
|
||||
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
|
||||
/// and by the boot-time prune of already-poisoned rows.
|
||||
pub fn is_public_http_url(raw: &str) -> bool {
|
||||
let url = match reqwest::Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
let mut host = match url.host_str() {
|
||||
Some(h) => h.to_ascii_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
// Drop a single trailing dot (FQDN root): `localhost.` resolves the same as
|
||||
// `localhost`, so normalize before the suffix/equality checks below.
|
||||
if let Some(stripped) = host.strip_suffix('.') {
|
||||
host = stripped.to_string();
|
||||
}
|
||||
if host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// host_str() keeps brackets on IPv6 literals (e.g. "[::1]"); strip them
|
||||
// before parsing as an IP.
|
||||
let ip_candidate = host.trim_start_matches('[').trim_end_matches(']');
|
||||
if let Ok(ip) = ip_candidate.parse::<std::net::IpAddr>() {
|
||||
// Reject loopback/unspecified on the literal as given — catches `::1`
|
||||
// and `::` before the IPv4-folding below (`::1`.to_ipv4() would
|
||||
// otherwise map to a non-loopback `0.0.0.1`).
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
|
||||
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
|
||||
// loopback/private addresses smuggled in via an IPv6 encoding, then
|
||||
// re-check loopback/unspecified.
|
||||
let ip = match ip {
|
||||
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
|
||||
v4 => v4,
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
|
||||
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
|
||||
// 0.0.0.0 itself is already caught by the is_unspecified check).
|
||||
if v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|
||||
|| o[0] == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let s = v6.segments();
|
||||
// fc00::/7 (unique-local) or fe80::/10 (link-local)
|
||||
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
|
||||
// decodable well-known /96 — e.g. the RFC 8215 local-use
|
||||
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
|
||||
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
|
||||
// Rather than risk a wrong decode across every prefix length we
|
||||
// reject the whole NAT64 space here. The well-known /96 was
|
||||
// already folded to its v4 above and never reaches this arm.
|
||||
if s[0] == 0x0064 && s[1] == 0xff9b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// GET /api/v1/peers
|
||||
pub async fn list_peers(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
|
||||
let peers = state.db.list_peers().await?;
|
||||
@@ -200,14 +83,10 @@ pub async fn announce(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the URL is a public http(s) endpoint. The announce route is
|
||||
// reachable unauthenticated (until all peers sign), so without this an
|
||||
// attacker can register loopback/private "peers" (localhost:5432, etc.)
|
||||
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
|
||||
// the real peers under junk so node-origin repos stop replicating.
|
||||
if !is_public_http_url(&req.http_url) {
|
||||
// Validate the URL is HTTP/HTTPS
|
||||
if !req.http_url.starts_with("http://") && !req.http_url.starts_with("https://") {
|
||||
return Err(AppError::BadRequest(
|
||||
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
"http_url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -432,13 +311,7 @@ pub async fn ping_peer(
|
||||
|
||||
// Async ping
|
||||
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
|
||||
// Use the shared no-redirect client: bare `reqwest::get` follows redirects,
|
||||
// so a peer could answer with `302 -> http://127.0.0.1/` and turn the ping
|
||||
// into an SSRF probe.
|
||||
let ok = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
let ok = reqwest::get(&url)
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
@@ -451,90 +324,3 @@ pub async fn ping_peer(
|
||||
"reachable": ok,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_public_http_url;
|
||||
|
||||
#[test]
|
||||
fn accepts_public_https_and_http() {
|
||||
assert!(is_public_http_url("https://node.gitlawb.com"));
|
||||
assert!(is_public_http_url("https://manila.gitlawb.com/"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_loopback_private_and_internal() {
|
||||
for bad in [
|
||||
"http://localhost:7545",
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://localhost:22/",
|
||||
"http://0.0.0.0:7545",
|
||||
// RFC1122 "this host" block 0.0.0.0/8 beyond 0.0.0.0 itself,
|
||||
// including the upper boundary (1.0.0.0 is public, tested below).
|
||||
"http://0.0.0.1:7545",
|
||||
"http://0.1.2.3/",
|
||||
"http://0.255.255.255/",
|
||||
"http://10.0.0.5:7545",
|
||||
"http://192.168.1.10/",
|
||||
"http://172.16.0.1:7545",
|
||||
"http://169.254.1.1/",
|
||||
"http://[::1]:7545",
|
||||
"http://gitlawb-node.internal:7545",
|
||||
"http://my-node.local/",
|
||||
"ftp://node.gitlawb.com",
|
||||
"not-a-url",
|
||||
"",
|
||||
// Trailing-dot FQDN of an internal host.
|
||||
"http://localhost./",
|
||||
// CGNAT (100.64.0.0/10).
|
||||
"http://100.64.0.1:7545",
|
||||
"http://100.127.255.255/",
|
||||
// IPv4-mapped / IPv4-compatible IPv6 smuggling private/loopback v4.
|
||||
"http://[::ffff:127.0.0.1]:7545",
|
||||
"http://[::ffff:10.0.0.1]/",
|
||||
"http://[::ffff:192.168.1.1]:7545",
|
||||
"http://[::127.0.0.1]/",
|
||||
// 6to4 (2002::/16) embedding loopback/private v4.
|
||||
"http://[2002:7f00:1::]:7545",
|
||||
"http://[2002:a00:1::]/",
|
||||
"http://[2002:c0a8:101::]/",
|
||||
// NAT64 well-known prefix (64:ff9b::/96) embedding loopback/private v4.
|
||||
"http://[64:ff9b::7f00:1]/",
|
||||
"http://[64:ff9b::a00:1]:7545",
|
||||
// NAT64 local-use prefix (64:ff9b:1::/48, RFC 8215) — rejected
|
||||
// outright rather than decoded, so the whole NAT64 space is closed.
|
||||
"http://[64:ff9b:1::7f00:1]/",
|
||||
"http://[64:ff9b:1::a00:1]:7545",
|
||||
"http://[64:ff9b:1:a00::]/",
|
||||
// Local-use NAT64 wrapping a *public* v4 (203.0.113.10) is still
|
||||
// rejected: the block is prefix-based, not payload-based.
|
||||
"http://[64:ff9b:1::cb00:710a]/",
|
||||
] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_6to4_and_nat64_wrapping_public_v4() {
|
||||
// Fold-and-recheck stays consistent with the mapped/compatible handling:
|
||||
// a transition address that wraps a public v4 is allowed, only the
|
||||
// private/loopback-wrapping forms above are rejected. 203.0.113.10.
|
||||
assert!(is_public_http_url("http://[2002:cb00:710a::]/"));
|
||||
assert!(is_public_http_url("http://[64:ff9b::cb00:710a]/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_public_outside_cgnat_range() {
|
||||
// 100.0.0.0/10 boundary: .63 and .128 are public, only 100.64-127 is CGNAT.
|
||||
assert!(is_public_http_url("http://100.63.255.255:7545"));
|
||||
assert!(is_public_http_url("http://100.128.0.1/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_first_address_above_this_host_block() {
|
||||
// 1.0.0.0 is the first address above the rejected 0.0.0.0/8 block;
|
||||
// pins the `o[0] == 0` check against an off-by-one into 1.x.
|
||||
assert!(is_public_http_url("http://1.0.0.0/"));
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,15 @@ pub async fn protect_branch(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// Only the repo owner can protect branches (DID-safe match, shared idiom).
|
||||
// Only the repo owner can protect branches
|
||||
let caller = &auth.0;
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
if caller != &record.owner_did && caller != owner_short {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the repo owner can protect branches".into(),
|
||||
));
|
||||
}
|
||||
@@ -58,8 +63,13 @@ pub async fn unprotect_branch(
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let caller = &auth.0;
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
if caller != &record.owner_did && caller != owner_short {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the repo owner can unprotect branches".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -40,11 +40,11 @@ pub async fn create_pr(
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<CreatePrRequest>,
|
||||
) -> Result<(StatusCode, Json<PullRequest>)> {
|
||||
// Authorize the caller as a reader before accepting a PR: a non-reader must
|
||||
// not be able to open a PR (and fire its webhooks) against a private repo
|
||||
// they cannot read. Mirrors create_review / create_comment / create_bounty.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let author_did = auth.0;
|
||||
let target_branch = req
|
||||
@@ -194,11 +194,6 @@ pub async fn merge_pr(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
// Owner-only merge (N7). Merging writes the served tree, so this is the same
|
||||
// trust boundary as owner-only push; it subsumes branch protection (a
|
||||
// non-owner cannot merge to any branch, protected or not).
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
@@ -257,7 +252,6 @@ pub async fn merge_pr(
|
||||
/// POST /api/v1/repos/:owner/:repo/pulls/:number/close
|
||||
pub async fn close_pr(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -272,15 +266,6 @@ pub async fn close_pr(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
// Owner OR author may close (forge norm).
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
let is_author = crate::api::did_matches(&auth.0, &pr.author_did);
|
||||
if !is_owner && !is_author {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the PR author can close this PR".into(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.close_pr(&pr.id).await?;
|
||||
|
||||
webhooks::fire_event(
|
||||
@@ -305,9 +290,11 @@ pub async fn create_review(
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
Json(req): Json<CreateReviewRequest>,
|
||||
) -> Result<(StatusCode, Json<PrReview>)> {
|
||||
// Read-gate: a reviewer must be able to read the repo, but need not own it.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -382,9 +369,11 @@ pub async fn create_comment(
|
||||
));
|
||||
}
|
||||
|
||||
// Read-gate: a commenter must be able to read the repo, but need not own it.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
|
||||
@@ -35,32 +35,14 @@ pub struct RegisterResponse {
|
||||
/// Accepts only requests with a valid HTTP Signature (enforced by middleware).
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
axum::Extension(auth): axum::Extension<crate::auth::AuthenticatedDid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>)> {
|
||||
// iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify up front; spend
|
||||
// the proof only once the request is admissible, just before the write, so a
|
||||
// rejected request never burns a valid proof.
|
||||
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;
|
||||
|
||||
// Parse and validate the DID
|
||||
let agent_did: Did = req
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Bind registration to the authenticated signer: an agent may only register
|
||||
// itself, not create or refresh a trust row for a DID it does not control.
|
||||
if !crate::api::did_matches(&auth.0, agent_did.as_str()) {
|
||||
return Err(AppError::Forbidden(
|
||||
"did must be the authenticated signer".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Request is admissible — spend the proof now, immediately before the write.
|
||||
proof.consume(&state.db).await?;
|
||||
|
||||
// Store the agent in the local index
|
||||
state
|
||||
.db
|
||||
|
||||
@@ -47,9 +47,8 @@ pub async fn register_replica(
|
||||
|
||||
let replica_did = &auth.0;
|
||||
|
||||
// Don't let an owner register themselves as a replica of their own repo
|
||||
// (did_matches handles the full vs bare did:key owner form).
|
||||
if crate::api::did_matches(replica_did, &record.owner_did) {
|
||||
// Don't let an owner register themselves as a replica of their own repo.
|
||||
if replica_did == &record.owner_did {
|
||||
return Err(AppError::BadRequest(
|
||||
"the repo owner is not a replica of their own repo".into(),
|
||||
));
|
||||
|
||||
+269
-1327
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@
|
||||
//! POST /api/v1/tasks/{id}/fail — fail task
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
@@ -18,18 +18,9 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::AgentTask;
|
||||
use crate::state::{AppState, TaskEventBroadcast};
|
||||
|
||||
/// 403 in this module's error shape (`(StatusCode, Json<Value>)`, not `AppError`).
|
||||
fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": "forbidden", "message": msg })),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Request / response types ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -64,11 +55,13 @@ pub struct ClaimTaskBody {
|
||||
#[derive(Deserialize)]
|
||||
pub struct CompleteTaskBody {
|
||||
pub result: Option<String>,
|
||||
pub by_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FailTaskBody {
|
||||
pub reason: Option<String>,
|
||||
pub by_did: Option<String>,
|
||||
}
|
||||
|
||||
fn task_to_json(t: &AgentTask) -> Value {
|
||||
@@ -94,20 +87,15 @@ fn task_to_json(t: &AgentTask) -> Value {
|
||||
/// POST /api/v1/tasks
|
||||
pub async fn create_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Json(body): Json<CreateTaskBody>,
|
||||
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
||||
// Bind the delegator to the authenticated signer (N13).
|
||||
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
|
||||
return Err(forbidden("delegator_did must be the authenticated signer"));
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: body.repo_id,
|
||||
kind: body.kind,
|
||||
status: "pending".to_string(),
|
||||
delegator_did: auth.0,
|
||||
delegator_did: body.delegator_did,
|
||||
assignee_did: body.assignee_did,
|
||||
capability: body.capability,
|
||||
ucan_token: body.ucan_token,
|
||||
@@ -166,25 +154,24 @@ pub async fn get_task(
|
||||
/// POST /api/v1/tasks/{id}/claim
|
||||
pub async fn claim_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ClaimTaskBody>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Bind the assignee to the authenticated signer (N13).
|
||||
if !crate::api::did_matches(&auth.0, &body.assignee_did) {
|
||||
return Err(forbidden("assignee_did must be the authenticated signer"));
|
||||
}
|
||||
let task = state.db.claim_task(&id, &auth.0).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let task = state
|
||||
.db
|
||||
.claim_task(&id, &body.assignee_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
let _ = state.task_event_tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: auth.0,
|
||||
by_did: body.assignee_did,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(Json(task_to_json(&task)))
|
||||
@@ -193,44 +180,17 @@ pub async fn claim_task(
|
||||
/// POST /api/v1/tasks/{id}/complete
|
||||
pub async fn complete_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<CompleteTaskBody>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Authorize the actor, not just bind their identity: the N13 signer-binding
|
||||
// proved the caller was whoever they claimed, but never that they were the
|
||||
// task's assignee. Load the task and require the caller to be its assignee;
|
||||
// finish_task then transitions only a claimed task.
|
||||
let existing = state
|
||||
.db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "task not found" })),
|
||||
)
|
||||
})?;
|
||||
if !crate::api::did_matches(
|
||||
&auth.0,
|
||||
existing.assignee_did.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
return Err(forbidden("only the task assignee can complete it"));
|
||||
}
|
||||
let by_did = auth.0;
|
||||
let by_did = body.by_did.unwrap_or_default();
|
||||
let task = state
|
||||
.db
|
||||
.finish_task(&id, "completed", body.result.as_deref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
@@ -247,36 +207,10 @@ pub async fn complete_task(
|
||||
/// POST /api/v1/tasks/{id}/fail
|
||||
pub async fn fail_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<FailTaskBody>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Authorize the actor, not just bind their identity (see complete_task): only
|
||||
// the task's assignee may fail it, and finish_task transitions only a claimed
|
||||
// task.
|
||||
let existing = state
|
||||
.db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "task not found" })),
|
||||
)
|
||||
})?;
|
||||
if !crate::api::did_matches(
|
||||
&auth.0,
|
||||
existing.assignee_did.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
return Err(forbidden("only the task assignee can fail it"));
|
||||
}
|
||||
let by_did = auth.0;
|
||||
let by_did = body.by_did.unwrap_or_default();
|
||||
let reason = body.reason.unwrap_or_default();
|
||||
let task = state
|
||||
.db
|
||||
@@ -284,7 +218,7 @@ pub async fn fail_task(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -26,16 +26,17 @@ pub struct RemoveVisibilityRequest {
|
||||
}
|
||||
|
||||
fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
|
||||
// DID-safe owner match (collapses did:key full vs bare on both sides, never
|
||||
// across methods), shared with require_repo_owner — not a trailing-segment
|
||||
// compare that only normalized the owner side.
|
||||
if crate::api::did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
let owner_short = record
|
||||
.owner_did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&record.owner_did);
|
||||
if caller != record.owner_did && caller != owner_short {
|
||||
return Err(AppError::BadRequest(
|
||||
"only the repo owner can manage visibility".into(),
|
||||
))
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject malformed globs before they reach the store, where they would
|
||||
@@ -56,23 +57,9 @@ fn validate_path_glob(path_glob: &str) -> Result<()> {
|
||||
"path_glob must not end with '/'".into(),
|
||||
));
|
||||
}
|
||||
// Reject empty path segments. `glob_prefix` collapses "//**" to "/" (whole
|
||||
// repo), so without this an operator could bypass the "/**" guard above and
|
||||
// grant repo-wide read with "//**", or set a dead rule like "//secret/**"
|
||||
// whose prefix never matches a real single-slash path and silently leaves
|
||||
// the subtree readable.
|
||||
if path_glob.contains("//") {
|
||||
if path_glob.contains('*') && !path_glob.ends_with("/**") {
|
||||
return Err(AppError::BadRequest(
|
||||
"path_glob must not contain empty path segments".into(),
|
||||
));
|
||||
}
|
||||
// Strip the one permitted trailing "/**" before checking for stray
|
||||
// wildcards, otherwise a "*" elsewhere in the prefix (e.g. "/a*b/**" or
|
||||
// "/a/**/**") slips through because the string still ends with "/**".
|
||||
let prefix = path_glob.strip_suffix("/**").unwrap_or(path_glob);
|
||||
if prefix.contains('*') {
|
||||
return Err(AppError::BadRequest(
|
||||
"the only supported wildcard is a single trailing '/**'".into(),
|
||||
"the only supported wildcard is a trailing '/**'".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -253,22 +240,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_globs() {
|
||||
// empty, no leading slash, whole-repo via "/**", trailing slash,
|
||||
// non-trailing wildcards, double wildcards, and empty path segments
|
||||
// (which would collapse to whole-repo or a dead rule) are all rejected.
|
||||
for g in [
|
||||
"",
|
||||
"secret/**",
|
||||
"/**",
|
||||
"/**/**",
|
||||
"/secret/",
|
||||
"/a*b",
|
||||
"/*/x",
|
||||
"/a*b/**",
|
||||
"/a/**/**",
|
||||
"//**",
|
||||
"//secret/**",
|
||||
] {
|
||||
// empty, no leading slash, whole-repo via "/**", trailing slash, and
|
||||
// non-trailing wildcards are all rejected.
|
||||
for g in ["", "secret/**", "/**", "/secret/", "/a*b", "/*/x"] {
|
||||
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,16 +38,11 @@ pub async fn create_webhook(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Gate the target through the same hardened public-host validator the peer
|
||||
// announce path uses, so an authenticated owner cannot register a webhook
|
||||
// that makes the node POST to loopback/private/link-local/metadata
|
||||
// endpoints (SSRF). Delivery runs on the shared no-redirect client
|
||||
// (main.rs), which closes the 3xx-to-internal bounce.
|
||||
if !crate::api::peers::is_public_http_url(&req.url) {
|
||||
// Validate URL is http/https
|
||||
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
|
||||
return Err(AppError::BadRequest(
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
"webhook URL must be http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -100,7 +95,6 @@ pub async fn list_webhooks(
|
||||
/// DELETE /api/v1/repos/:owner/:repo/hooks/:id
|
||||
pub async fn delete_webhook(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
@@ -108,7 +102,6 @@ pub async fn delete_webhook(
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Verify the webhook belongs to this repo
|
||||
let hook = state
|
||||
@@ -124,35 +117,3 @@ pub async fn delete_webhook(
|
||||
state.db.delete_webhook(&id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true, "id": id })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::peers::is_public_http_url;
|
||||
|
||||
// create_webhook gates req.url through is_public_http_url. Pin the exact
|
||||
// SSRF targets from issue #81 so the webhook path can never regress to the
|
||||
// old scheme-only check, independent of the validator's own peer tests.
|
||||
#[test]
|
||||
fn webhook_url_gate_rejects_ssrf_targets() {
|
||||
for bad in [
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost/",
|
||||
"http://10.0.0.5/",
|
||||
"http://[::1]/",
|
||||
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
|
||||
"http://[2002:7f00:1::]/",
|
||||
"http://[64:ff9b::7f00:1]/",
|
||||
"ftp://example.com/",
|
||||
"not-a-url",
|
||||
] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_gate_allows_public_targets() {
|
||||
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545/"));
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ mod tests {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000000000000000000000000000".into(),
|
||||
new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
|
||||
cid: Some("bafyreib5...".into()),
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
@@ -270,7 +270,7 @@ mod tests {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
old_sha: "0".repeat(64),
|
||||
new_sha: "a1b2c3d4".repeat(8),
|
||||
cid: None,
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
@@ -286,44 +286,6 @@ mod tests {
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_anchor_body_carries_real_old_sha() {
|
||||
// The anchored body must serialize the real old→new transition the
|
||||
// node was handed, never a zero placeholder. Regression guard for the
|
||||
// push handler that used to hardcode `old_sha` to 64 zeros (#26).
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let real_old = "1111111111111111111111111111111111111111";
|
||||
let real_new = "2222222222222222222222222222222222222222";
|
||||
let _mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_body(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)),
|
||||
mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"id":"TX_REAL_OLD_SHA","timestamp":1710000000000,"version":"1.0.0"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let anchor = RefAnchor {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: real_old.into(),
|
||||
new_sha: real_new.into(),
|
||||
cid: None,
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
node_did: "did:key:z6Mknnd...".into(),
|
||||
};
|
||||
|
||||
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
|
||||
assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA");
|
||||
// The mock only matches when the posted JSON carries both real SHAs.
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arweave_url() {
|
||||
let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
|
||||
|
||||
@@ -17,18 +17,6 @@ use crate::state::AppState;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// Whether `caller` is authorized to push to `record`.
|
||||
///
|
||||
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
|
||||
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
|
||||
/// intentionally a distinct, intent-named gate rather than a bare owner check so
|
||||
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
|
||||
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
|
||||
/// call sites.
|
||||
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
|
||||
crate::api::did_matches(caller, &record.owner_did)
|
||||
}
|
||||
|
||||
use gitlawb_core::http_sig::{
|
||||
build_signing_string, compute_content_digest, HttpSignature, COVERED_COMPONENTS,
|
||||
};
|
||||
|
||||
@@ -1,48 +1,9 @@
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Admin subcommands. When absent, the binary runs the node daemon as usual.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum Command {
|
||||
/// Bulk-delete repos whose name matches a Postgres regex, removing their DB
|
||||
/// rows, on-disk bare repo, and Tigris archive. Intended for spam cleanup.
|
||||
/// Defaults to a dry run; pass --execute to actually delete.
|
||||
PurgeSpam(PurgeSpamArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct PurgeSpamArgs {
|
||||
/// Postgres regex matched against `repos.name` (e.g. '^keep[0-9]').
|
||||
#[arg(long)]
|
||||
pub regex: String,
|
||||
|
||||
/// Actually delete. Without this flag the command only reports what would
|
||||
/// be removed (dry run).
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub execute: bool,
|
||||
|
||||
/// Stop after processing at most this many repos (0 = no limit).
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub limit: i64,
|
||||
|
||||
/// Skip deleting the Tigris archive (DB + on-disk only).
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub skip_tigris: bool,
|
||||
|
||||
/// Bulk mode: delete all matching rows with set-based SQL (a handful of
|
||||
/// statements) + remove their on-disk repos, instead of one transaction per
|
||||
/// repo. Far faster on large match sets / small DB instances. Skips Tigris.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub bulk: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
|
||||
pub struct Config {
|
||||
/// Optional admin subcommand. When omitted, runs the node daemon.
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Directory where bare git repositories are stored
|
||||
#[arg(long, env = "GITLAWB_REPOS_DIR", default_value = "./data/repos")]
|
||||
pub repos_dir: PathBuf,
|
||||
@@ -88,14 +49,6 @@ pub struct Config {
|
||||
)]
|
||||
pub require_signed_peer_writes: bool,
|
||||
|
||||
/// Require the authenticated pusher to be the repo owner on `git-receive-pack`.
|
||||
/// Authentication (a valid did:key signature) is not authorization on its own:
|
||||
/// any party can sign as their own DID. When true, pushes whose authenticated
|
||||
/// DID is not the repo owner are rejected. Keep false during rolling upgrades;
|
||||
/// flip it on once owners are ready for owner-only writes.
|
||||
#[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)]
|
||||
pub enforce_owner_push: bool,
|
||||
|
||||
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
|
||||
#[arg(long, env = "GITLAWB_IPFS_API", default_value = "")]
|
||||
pub ipfs_api: String,
|
||||
|
||||
+74
-1465
File diff suppressed because it is too large.
Load diff
@@ -38,70 +38,6 @@ fn did_to_key(did: &str) -> Option<VerifyingKey> {
|
||||
Did::from_str(did).ok()?.to_verifying_key().ok()
|
||||
}
|
||||
|
||||
/// Resolve every recipient DID to its verifying key, all-or-nothing.
|
||||
///
|
||||
/// Returns `Ok(keys)` only when every DID resolves. If any DID fails, returns
|
||||
/// `Err(unresolved)` listing the unresolvable DID strings so the caller can fail
|
||||
/// closed rather than seal to a partial recipient set (#47): sealing to a subset
|
||||
/// while recording the full set as covered permanently locks out the dropped
|
||||
/// readers. Resolution is local-only, so `did:web`/`did:gitlawb` recipients (and
|
||||
/// any malformed `did:key`) land in the unresolved set until off-`did:key`
|
||||
/// resolution exists.
|
||||
fn resolve_all_recipients(dids: &BTreeSet<String>) -> Result<Vec<VerifyingKey>, Vec<String>> {
|
||||
let mut keys = Vec::with_capacity(dids.len());
|
||||
let mut unresolved = Vec::new();
|
||||
for did in dids {
|
||||
match did_to_key(did) {
|
||||
Some(k) => keys.push(k),
|
||||
None => unresolved.push(did.clone()),
|
||||
}
|
||||
}
|
||||
if unresolved.is_empty() {
|
||||
Ok(keys)
|
||||
} else {
|
||||
Err(unresolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do with a single withheld blob, decided without any DB or IO so the
|
||||
/// fail-closed invariant (#47) is unit-testable in isolation.
|
||||
#[derive(Debug)]
|
||||
enum SealPlan {
|
||||
/// An existing envelope already covers exactly this recipient set; nothing to do.
|
||||
SkipUnchanged,
|
||||
/// No recipient DID resolved to a key, so there is nothing to seal to.
|
||||
SkipNoRecipients,
|
||||
/// At least one recipient DID is unresolvable. Fail closed: never seal to a
|
||||
/// partial set. Carries the unresolvable DIDs for logging.
|
||||
SkipUnresolvable(Vec<String>),
|
||||
/// Seal to `keys` and record coverage under `tag`.
|
||||
Seal {
|
||||
keys: Vec<VerifyingKey>,
|
||||
tag: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decide what to do with one blob given its desired recipient set and the tag
|
||||
/// already stored for it (if any). Pure: no DB, no IO.
|
||||
///
|
||||
/// This is the #47 fail-closed gate in isolation: it returns `Seal` only when
|
||||
/// EVERY recipient DID resolves, so no caller can seal to a partial set. A
|
||||
/// changed recipient set (different tag) re-seals so a newly added reader can
|
||||
/// recover the blob; reader removal is not retroactive (the old envelope is
|
||||
/// already public). The comparison is on the opaque node-keyed tag, never the
|
||||
/// DID list.
|
||||
fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&str>) -> SealPlan {
|
||||
let tag = recipients_tag(node_seed, dids);
|
||||
if stored_tag == Some(tag.as_str()) {
|
||||
return SealPlan::SkipUnchanged;
|
||||
}
|
||||
match resolve_all_recipients(dids) {
|
||||
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
|
||||
Ok(keys) => SealPlan::Seal { keys, tag },
|
||||
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt and pin every withheld blob. `recipients` maps blob oid -> DID set;
|
||||
/// `node_seed` keys the opaque recipients tag. Returns `(oid, cid)` for each blob
|
||||
/// actually sealed and recorded this call (the per-push delta), used by Option B3
|
||||
@@ -115,53 +51,32 @@ pub async fn encrypt_and_pin(
|
||||
recipients: &HashMap<String, BTreeSet<String>>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut sealed = Vec::new();
|
||||
let mut skipped_unresolvable = 0usize;
|
||||
for (oid, dids) in recipients {
|
||||
// A DB read failure is not a cache miss: re-sealing here would do an
|
||||
// avoidable IPFS write during a partial outage. Skip and retry next push.
|
||||
let stored_tag = match db.encrypted_blob_recipients_tag(repo_id, oid).await {
|
||||
Ok(t) => t,
|
||||
// Skip only if an existing envelope already covers exactly these
|
||||
// recipients. If the recipient set changed (e.g. a reader was added to
|
||||
// the rule), re-seal so the new reader can recover the blob. Reader
|
||||
// removal is not retroactive: the old envelope is already public. The
|
||||
// comparison is on the opaque node-keyed tag, never the DID list.
|
||||
let tag = recipients_tag(node_seed, dids);
|
||||
match db.encrypted_blob_recipients_tag(repo_id, oid).await {
|
||||
Ok(Some(stored_tag)) if stored_tag == tag => continue,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
// A DB read failure is not a cache miss: re-sealing here would do
|
||||
// an avoidable IPFS write during a partial outage. Skip and retry
|
||||
// on the next push.
|
||||
tracing::warn!(oid = %oid, err = %e, "recipients_tag lookup failed; skipping reseal");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Fail closed: plan_seal returns Seal only when every recipient DID
|
||||
// resolves, so we never seal to a partial set and record the full set as
|
||||
// covered (which would permanently lock out the dropped readers, #47).
|
||||
let (keys, tag) = match plan_seal(node_seed, dids, stored_tag.as_deref()) {
|
||||
SealPlan::SkipUnchanged => continue,
|
||||
SealPlan::SkipNoRecipients => {
|
||||
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
SealPlan::SkipUnresolvable(unresolved) => {
|
||||
skipped_unresolvable += 1;
|
||||
// DIDs are user-controlled (rule reader_dids); log a bounded
|
||||
// sample, not an unbounded dump. Wording stays neutral about the
|
||||
// cause: a malformed did:key is not DHT-pending and never will
|
||||
// resolve, unlike a did:gitlawb awaiting anchoring.
|
||||
let sample: Vec<&String> = unresolved.iter().take(3).collect();
|
||||
tracing::warn!(
|
||||
oid = %oid,
|
||||
unresolved_count = unresolved.len(),
|
||||
unresolved_sample = ?sample,
|
||||
"unresolvable recipient DID(s); skipping encrypted pin to avoid sealing to a partial set"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
SealPlan::Seal { keys, tag } => (keys, tag),
|
||||
};
|
||||
}
|
||||
let keys: Vec<VerifyingKey> = dids.iter().filter_map(|d| did_to_key(d)).collect();
|
||||
if keys.is_empty() {
|
||||
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
let data = match crate::git::store::read_object(repo_path, oid) {
|
||||
Ok(Some((_t, bytes))) => bytes,
|
||||
Ok(None) => {
|
||||
tracing::warn!(oid = %oid, "git object not found; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(oid = %oid, err = %e, "read_object failed; skipping encrypted pin");
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let envelope = match seal_blob(&data, &keys) {
|
||||
Ok(e) => e,
|
||||
@@ -172,14 +87,7 @@ pub async fn encrypt_and_pin(
|
||||
};
|
||||
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope).await {
|
||||
Ok(c) if !c.is_empty() => c,
|
||||
Ok(_) => {
|
||||
tracing::warn!(oid = %oid, "pin_git_object returned no cid; skipping");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(oid = %oid, err = %e, "pin_git_object failed; skipping");
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if let Err(e) = db.record_encrypted_blob(repo_id, oid, &cid, &tag).await {
|
||||
tracing::warn!(oid = %oid, err = %e, "record_encrypted_blob failed");
|
||||
@@ -187,85 +95,16 @@ pub async fn encrypt_and_pin(
|
||||
}
|
||||
sealed.push((oid.clone(), cid.clone()));
|
||||
}
|
||||
// One aggregate signal so a coverage collapse is a single greppable line, not
|
||||
// a per-oid scrape. In a fully-migrated did:gitlawb org every blob skips and
|
||||
// recovery coverage silently drops to zero; this is the operator's cue that
|
||||
// the gap is the deliberate fail-closed posture, not a malfunction.
|
||||
if skipped_unresolvable > 0 {
|
||||
tracing::warn!(
|
||||
sealed = sealed.len(),
|
||||
skipped = skipped_unresolvable,
|
||||
"encrypted-pin coverage reduced: blobs skipped for unresolvable recipients"
|
||||
);
|
||||
}
|
||||
sealed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use super::recipients_tag;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn did_key(seed: u8) -> String {
|
||||
let vk = SigningKey::from_bytes(&[seed; 32]).verifying_key();
|
||||
Did::from_verifying_key(&vk).to_string()
|
||||
}
|
||||
|
||||
// Accepts both `&[String]` (resolve tests, built from `did_key`) and
|
||||
// `&[&str]` (tag tests, built from literals).
|
||||
fn set<S: AsRef<str>>(dids: &[S]) -> BTreeSet<String> {
|
||||
dids.iter().map(|s| s.as_ref().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_did_key_recipients_resolve() {
|
||||
let dids = set(&[did_key(1), did_key(2)]);
|
||||
let keys = resolve_all_recipients(&dids).expect("all did:key resolve");
|
||||
assert_eq!(keys.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_set_with_did_gitlawb_fails_closed() {
|
||||
// Core #47 regression: a resolvable subset must never yield a sealable
|
||||
// key set. Two did:key plus one did:gitlawb -> Err naming only the
|
||||
// unresolvable DID.
|
||||
let gitlawb = Did::gitlawb("zSomeDhtKey").to_string();
|
||||
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
|
||||
assert_eq!(unresolved, vec![gitlawb]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_web_recipient_fails_closed() {
|
||||
let web = Did::web("example.com").to_string();
|
||||
let dids = set(&[did_key(1), web.clone()]);
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("did:web cannot resolve locally");
|
||||
assert_eq!(unresolved, vec![web]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_did_key_fails_closed() {
|
||||
// The third state: not a method-resolution gap but a permanently broken
|
||||
// did:key (invalid multibase). Must also fail closed.
|
||||
let broken = "did:key:z!!!invalid".to_string();
|
||||
let dids = set(&[did_key(1), broken.clone()]);
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("malformed did:key fails");
|
||||
assert_eq!(unresolved, vec![broken]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_set_resolves_to_empty_keys() {
|
||||
let dids = BTreeSet::new();
|
||||
let keys = resolve_all_recipients(&dids).expect("empty set is not an error");
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_unresolvable_did_returns_that_did() {
|
||||
let gitlawb = Did::gitlawb("zOnlyOne").to_string();
|
||||
let dids: BTreeSet<String> = [gitlawb.clone()].into_iter().collect();
|
||||
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
|
||||
assert_eq!(unresolved, vec![gitlawb]);
|
||||
fn set(dids: &[&str]) -> BTreeSet<String> {
|
||||
dids.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,69 +133,4 @@ mod tests {
|
||||
"tag must depend on the node seed, not be a plain hash"
|
||||
);
|
||||
}
|
||||
|
||||
// plan_seal is the seal/skip decision `encrypt_and_pin` acts on. Testing it
|
||||
// directly pins the #47 fail-closed invariant at the function that owns it,
|
||||
// which a unit test of `resolve_all_recipients` alone cannot do (it can't
|
||||
// catch the caller falling through to a partial seal).
|
||||
const SEED: [u8; 32] = [9u8; 32];
|
||||
|
||||
#[test]
|
||||
fn plan_seal_seals_when_all_recipients_resolve() {
|
||||
let dids = set(&[did_key(1), did_key(2)]);
|
||||
match plan_seal(&SEED, &dids, None) {
|
||||
SealPlan::Seal { keys, tag } => {
|
||||
assert_eq!(keys.len(), 2, "must seal to the full recipient set");
|
||||
assert_eq!(
|
||||
tag,
|
||||
recipients_tag(&SEED, &dids),
|
||||
"records the full-set tag"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Seal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_fails_closed_on_any_unresolvable_recipient() {
|
||||
// The #47 invariant at the decision boundary: one unresolvable DID among
|
||||
// resolvable ones must NOT yield a Seal (which would seal a partial set).
|
||||
let gitlawb = Did::gitlawb("zPending").to_string();
|
||||
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
|
||||
match plan_seal(&SEED, &dids, None) {
|
||||
SealPlan::SkipUnresolvable(unresolved) => assert_eq!(unresolved, vec![gitlawb]),
|
||||
other => panic!("must fail closed, never seal a partial set; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_skips_empty_recipient_set() {
|
||||
let dids = BTreeSet::new();
|
||||
assert!(matches!(
|
||||
plan_seal(&SEED, &dids, None),
|
||||
SealPlan::SkipNoRecipients
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_skips_when_tag_unchanged() {
|
||||
let dids = set(&[did_key(1)]);
|
||||
let stored = recipients_tag(&SEED, &dids);
|
||||
assert!(matches!(
|
||||
plan_seal(&SEED, &dids, Some(&stored)),
|
||||
SealPlan::SkipUnchanged
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_seal_reseals_when_recipient_set_changed() {
|
||||
// A stored tag for a DIFFERENT set is a miss: a newly added reader must
|
||||
// trigger a re-seal, not be skipped as unchanged.
|
||||
let dids = set(&[did_key(1), did_key(2)]);
|
||||
let stale = recipients_tag(&SEED, &set(&[did_key(1)]));
|
||||
match plan_seal(&SEED, &dids, Some(&stale)) {
|
||||
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2),
|
||||
other => panic!("changed recipient set must re-seal; got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,6 @@ pub enum AppError {
|
||||
#[allow(dead_code)]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("icaptcha proof required: {0}")]
|
||||
IcaptchaProofRequired(String),
|
||||
|
||||
#[error("invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
@@ -55,15 +52,6 @@ impl IntoResponse for AppError {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
|
||||
// 403, not 401: the caller IS an authenticated agent (credentials are
|
||||
// valid) but is forbidden from this action without a valid, fresh
|
||||
// iCaptcha proof. The distinct `icaptcha_proof_required` code — which
|
||||
// clients branch on — keeps it separable from a plain `forbidden`.
|
||||
AppError::IcaptchaProofRequired(msg) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
"icaptcha_proof_required",
|
||||
msg.clone(),
|
||||
),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
|
||||
@@ -23,18 +23,13 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
|
||||
use std::io::Write;
|
||||
let mut child = hash_output;
|
||||
// Write the blob to stdin, but always reap the child afterward even if the
|
||||
// write fails, so a stdin-write error can't drop the Child unwaited and leak
|
||||
// a zombie (#53).
|
||||
let write_result = match child.stdin.take() {
|
||||
Some(mut stdin) => stdin.write_all(json.as_bytes()),
|
||||
None => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"git hash-object stdin unavailable",
|
||||
)),
|
||||
};
|
||||
if let Some(stdin) = child.stdin.take() {
|
||||
let mut stdin = stdin;
|
||||
stdin
|
||||
.write_all(json.as_bytes())
|
||||
.context("failed to write to git hash-object stdin")?;
|
||||
}
|
||||
let output = child.wait_with_output().context("git hash-object failed")?;
|
||||
write_result.context("failed to write to git hash-object stdin")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod issues;
|
||||
pub mod push_delta;
|
||||
pub mod repo_store;
|
||||
pub mod smart_http;
|
||||
pub mod store;
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
//! Push-delta object enumeration for the IPFS/Pinata pin path.
|
||||
//!
|
||||
//! The pin path needs the set of git objects to consider for pinning after a
|
||||
//! push. Historically both pin functions enumerated the *whole* repo
|
||||
//! (`git cat-file --batch-all-objects`) on every push, so cost scaled with
|
||||
//! repo size rather than push size (finding N16). This module computes the
|
||||
//! per-push *delta* instead — the objects a push introduced — and keeps the
|
||||
//! whole-repo lister for the reconciliation sweep and the fail-closed fallback.
|
||||
//!
|
||||
//! ## Correctness framing (do not confuse with the #84 withholding direction)
|
||||
//!
|
||||
//! The pin enumeration is part of the *exposure* set, not the withheld filter.
|
||||
//! On the *delta* path, narrowing per push only *shrinks* what is pinned, so it
|
||||
//! cannot create an under-withholding leak — the reachable-only withheld filter
|
||||
//! (`visibility_pack`) still subtracts from the smaller set we feed it. The only
|
||||
//! risk there is *under-pinning* (a durability gap) the sweep backstops.
|
||||
//!
|
||||
//! The *full-scan* path is different: `list_all_objects` includes dangling
|
||||
//! objects the reachable withheld set never classified, so subtracting that set
|
||||
//! is not enough — a dangling private blob would slip through (#99). The caller
|
||||
//! signals a full scan via [`PinCandidateSet::full_scan`] and must then apply
|
||||
//! the fail-closed blob filter (`visibility_pack::replicable_objects_fail_closed`)
|
||||
//! instead of the plain reachable-only subtraction.
|
||||
//!
|
||||
//! Because the pin candidate set needs only the OID *set* (never the per-path
|
||||
//! information the withheld classifier needs), `git rev-list --objects
|
||||
//! --no-object-names` is safe here. The "rev-list reports one path per object"
|
||||
//! trap from #84 applies only to `visibility_pack`'s per-path `ls-tree` walk.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// Env var that forces the push path to always full-scan, bypassing the delta
|
||||
/// optimization (KTD7 kill-switch). Reuses the already-tested fallback branch
|
||||
/// rather than introducing a second enumeration path. Does not gate the sweep.
|
||||
const FORCE_FULL_SCAN_ENV: &str = "GITLAWB_PIN_FORCE_FULLSCAN";
|
||||
|
||||
/// The objects a push introduced, or a signal that the delta could not be
|
||||
/// computed safely and the caller should fall back to a full scan.
|
||||
///
|
||||
/// Three-valued by design: `Delta(vec![])` (a no-op push — pin nothing) is a
|
||||
/// *different* fact from `FullScanRequired` (could not compute — fall back),
|
||||
/// and they drive different actions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PinCandidates {
|
||||
/// Objects reachable from the new tips but not the old tips.
|
||||
Delta(Vec<String>),
|
||||
/// The delta is not safe to use; the caller must enumerate the whole repo
|
||||
/// with [`list_all_objects`] instead. Returned on a non-commit tip, any git
|
||||
/// error, or the force-full-scan kill-switch.
|
||||
FullScanRequired,
|
||||
}
|
||||
|
||||
/// Resolve the set of objects introduced by a push from its ref updates.
|
||||
///
|
||||
/// `new_tips` / `old_tips` are the non-zero new/old SHAs of the push's ref
|
||||
/// updates (the caller strips the all-zeros create/delete sentinel). Keeping
|
||||
/// the helper on plain SHA slices (not the `api` `RefUpdate` type) keeps it
|
||||
/// unit-testable without the HTTP layer.
|
||||
///
|
||||
/// Fail-closed: any condition where the introduced set cannot be safely
|
||||
/// determined returns [`PinCandidates::FullScanRequired`] rather than a partial
|
||||
/// set, so the caller full-scans instead of silently under-pinning.
|
||||
pub fn resolve_push_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> PinCandidates {
|
||||
// KTD7 kill-switch: force the (already-tested) full-scan fallback. The env
|
||||
// read is split out from the pure logic so the resolver stays unit-testable
|
||||
// without touching process-global state.
|
||||
resolve_push_delta_inner(repo_path, new_tips, old_tips, force_full_scan())
|
||||
}
|
||||
|
||||
/// Whether the force-full-scan kill-switch env var is set.
|
||||
fn force_full_scan() -> bool {
|
||||
std::env::var_os(FORCE_FULL_SCAN_ENV).is_some()
|
||||
}
|
||||
|
||||
// Intentionally private but reachable from the test module (via `use super::*`)
|
||||
// so the kill-switch can be exercised without mutating process-global env.
|
||||
fn resolve_push_delta_inner(
|
||||
repo_path: &Path,
|
||||
new_tips: &[&str],
|
||||
old_tips: &[&str],
|
||||
force_full_scan: bool,
|
||||
) -> PinCandidates {
|
||||
if force_full_scan {
|
||||
tracing::debug!("{FORCE_FULL_SCAN_ENV} set — forcing full scan");
|
||||
return PinCandidates::FullScanRequired;
|
||||
}
|
||||
|
||||
// A push that only deleted refs introduces no objects.
|
||||
if new_tips.is_empty() {
|
||||
return PinCandidates::Delta(Vec::new());
|
||||
}
|
||||
|
||||
// Ref-type guard (the fail-closed mechanism). `git rev-list` does NOT error
|
||||
// on a non-commit tip — a blob/tree/tag-of-non-commit all exit 0 and walk
|
||||
// the object — so the rev-list exit code cannot catch this. Check each new
|
||||
// tip's *fully-peeled* type with `cat-file -t '<sha>^{}'` (recursive peel:
|
||||
// tag-of-commit -> commit, tag-of-tree -> tree, tag-of-tag-of-commit ->
|
||||
// commit). Bare `cat-file -t` returns `tag` for an annotated tag, and
|
||||
// `for-each-ref %(*objecttype)` peels only one level — neither is correct.
|
||||
for tip in new_tips {
|
||||
match peeled_object_type(repo_path, tip) {
|
||||
Some(t) if t == "commit" => {}
|
||||
other => {
|
||||
tracing::debug!(
|
||||
tip = %tip,
|
||||
peeled_type = ?other,
|
||||
"push tip is not a commit (or type lookup failed) — forcing full scan"
|
||||
);
|
||||
return PinCandidates::FullScanRequired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match rev_list_delta(repo_path, new_tips, old_tips) {
|
||||
Ok(oids) => PinCandidates::Delta(oids),
|
||||
Err(e) => {
|
||||
tracing::debug!(err = %e, "push-delta rev-list failed — forcing full scan");
|
||||
PinCandidates::FullScanRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the fully-peeled object type of `sha` (e.g. `commit`, `tree`,
|
||||
/// `blob`), or `None` if the object is missing/unpeelable or git errored.
|
||||
fn peeled_object_type(repo_path: &Path, sha: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["cat-file", "-t", &format!("{sha}^{{}}")])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Run `git rev-list --objects --no-object-names <new> --not <old>` and return
|
||||
/// the bare OID set. Decides on `status.success()` *before* parsing stdout, so
|
||||
/// a walk that prints a valid prefix then errors mid-walk is discarded.
|
||||
fn rev_list_delta(repo_path: &Path, new_tips: &[&str], old_tips: &[&str]) -> Result<Vec<String>> {
|
||||
let mut args: Vec<&str> = vec!["rev-list", "--objects", "--no-object-names"];
|
||||
args.extend_from_slice(new_tips);
|
||||
if !old_tips.is_empty() {
|
||||
args.push("--not");
|
||||
args.extend_from_slice(old_tips);
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git rev-list for push delta")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git rev-list failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List every object in the repository via
|
||||
/// `git cat-file --batch-all-objects --batch-check='%(objectname)'`.
|
||||
///
|
||||
/// This is the whole-repo enumeration the push path falls back to and the
|
||||
/// reconciliation sweep relies on. It returns *all* objects (including
|
||||
/// unreachable/dangling ones), which is what the sweep needs to catch
|
||||
/// stragglers — do not swap it for a reachability walk.
|
||||
pub fn list_all_objects(repo_path: &Path) -> Result<Vec<String>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Like [`list_all_objects`] but pairs each OID with its object type, via
|
||||
/// `--batch-check='%(objectname) %(objecttype)'`. The pin path's fail-closed
|
||||
/// filter needs to tell blobs (content, withholdable) from commits/trees
|
||||
/// (structural, never withheld) without typing the candidate list itself.
|
||||
pub fn list_all_objects_with_type(repo_path: &Path) -> Result<Vec<(String, String)>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname) %(objecttype)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.split_whitespace();
|
||||
let oid = it.next()?;
|
||||
let ty = it.next()?;
|
||||
Some((oid.to_string(), ty.to_string()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every blob OID in the repo, including unreachable/dangling ones. The
|
||||
/// fail-closed pin filter drops any candidate blob absent from the reachable,
|
||||
/// visibility-allowed set; a dangling private blob is in this set but not the
|
||||
/// allowed set, so it never replicates (#99).
|
||||
pub fn all_blob_oids(repo_path: &Path) -> Result<HashSet<String>> {
|
||||
Ok(list_all_objects_with_type(repo_path)?
|
||||
.into_iter()
|
||||
.filter(|(_, ty)| ty == "blob")
|
||||
.map(|(oid, _)| oid)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The pin candidate OIDs for a push plus whether they came from a full-repo
|
||||
/// scan. `full_scan` is true when the delta could not be used and the whole
|
||||
/// object DB (including dangling objects) was enumerated — the caller must then
|
||||
/// apply the fail-closed blob filter, because the reachable-only withheld set
|
||||
/// cannot classify dangling blobs (#99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinCandidateSet {
|
||||
pub candidates: Vec<String>,
|
||||
pub full_scan: bool,
|
||||
}
|
||||
|
||||
/// Resolve the pin candidate OID set for a push, off the async worker.
|
||||
///
|
||||
/// Runs [`resolve_push_delta`] in `spawn_blocking` (git subprocess) and applies
|
||||
/// the full-scan fallback for [`PinCandidates::FullScanRequired`]. Owns the
|
||||
/// dispatch so the push call site stays thin and this wiring is unit-testable
|
||||
/// without the HTTP layer.
|
||||
///
|
||||
/// Every degraded path is **logged**, not silent: a full-scan fallback, a
|
||||
/// failed full scan, and a panicked blocking task each emit a warning. On a
|
||||
/// failed full scan or a task panic the candidate set is empty (pin nothing
|
||||
/// this push); that is a durability gap the reconciliation sweep backstops, and
|
||||
/// it can never leak because the withheld/fail-closed filter still runs on
|
||||
/// whatever set is returned. `full_scan` rides on the returned set so the caller
|
||||
/// knows when the dangling-inclusive filter is required.
|
||||
pub async fn resolve_candidates_for_push(
|
||||
repo_path: PathBuf,
|
||||
new_tips: Vec<String>,
|
||||
old_tips: Vec<String>,
|
||||
) -> PinCandidateSet {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let new_refs: Vec<&str> = new_tips.iter().map(String::as_str).collect();
|
||||
let old_refs: Vec<&str> = old_tips.iter().map(String::as_str).collect();
|
||||
match resolve_push_delta(&repo_path, &new_refs, &old_refs) {
|
||||
PinCandidates::Delta(objs) => {
|
||||
tracing::info!(delta = objs.len(), repo = %repo_path.display(), "pin candidate set from push delta");
|
||||
PinCandidateSet { candidates: objs, full_scan: false }
|
||||
}
|
||||
PinCandidates::FullScanRequired => {
|
||||
tracing::warn!(repo = %repo_path.display(), "pin delta unavailable (non-commit tip, git error, or force-full-scan) — full-scan fallback");
|
||||
match list_all_objects(&repo_path) {
|
||||
Ok(objs) => PinCandidateSet { candidates: objs, full_scan: true },
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_path.display(), err = %e, "full-scan fallback failed; pinning nothing this push (reconciliation sweep backstops)");
|
||||
PinCandidateSet { candidates: Vec::new(), full_scan: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(err = %e, "pin candidate computation task panicked; pinning nothing this push (reconciliation sweep backstops)");
|
||||
PinCandidateSet { candidates: Vec::new(), full_scan: false }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Minimal git helper for building test repos.
|
||||
struct Repo {
|
||||
_td: TempDir,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
fn new() -> Self {
|
||||
let td = TempDir::new().unwrap();
|
||||
let path = td.path().to_path_buf();
|
||||
let r = Repo { _td: td, path };
|
||||
r.git(&["init", "-q", "-b", "main"]);
|
||||
r.git(&["config", "user.email", "t@t"]);
|
||||
r.git(&["config", "user.name", "t"]);
|
||||
r
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&self.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn commit_file(&self, name: &str, body: &str) -> String {
|
||||
std::fs::write(self.path.join(name), body).unwrap();
|
||||
self.git(&["add", name]);
|
||||
self.git(&["commit", "-qm", &format!("add {name}")]);
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn rev(&self, r: &str) -> String {
|
||||
self.git(&["rev-parse", r])
|
||||
}
|
||||
}
|
||||
|
||||
const ZERO: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
fn delta(c: PinCandidates) -> Vec<String> {
|
||||
match c {
|
||||
PinCandidates::Delta(v) => v,
|
||||
PinCandidates::FullScanRequired => panic!("expected Delta, got FullScanRequired"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_new_commit_delta_excludes_preexisting_objects() {
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c2], &[&c1]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
// The new blob b.txt and commit c2 are in the delta; the old blob a.txt
|
||||
// and commit c1 are not.
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
let old_blob = repo.rev(&format!("{c1}:a.txt"));
|
||||
assert!(got.contains(&c2), "new commit in delta");
|
||||
assert!(got.contains(&new_blob), "new blob in delta");
|
||||
assert!(!got.contains(&c1), "old commit excluded");
|
||||
assert!(!got.contains(&old_blob), "old blob excluded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_ref_is_superset_of_new_objects() {
|
||||
// A created ref (old tip all-zeros, filtered out by caller) with no old
|
||||
// tips walks everything reachable from the new tip — a superset of the
|
||||
// genuinely new objects, never fewer.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&c1], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&c1));
|
||||
assert!(got.contains(&repo.rev("HEAD:a.txt")));
|
||||
assert!(got.contains(&repo.rev("HEAD^{tree}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_push_delta_is_objects_unique_to_new_tip() {
|
||||
let repo = Repo::new();
|
||||
let base = repo.commit_file("a.txt", "one\n");
|
||||
let old_tip = repo.commit_file("b.txt", "two\n");
|
||||
// Rewrite history: reset to base, commit a different file.
|
||||
repo.git(&["reset", "-q", "--hard", &base]);
|
||||
let new_tip = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&new_tip], &[&old_tip]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&new_tip), "new tip in delta");
|
||||
assert!(got.contains(&repo.rev(&format!("{new_tip}:c.txt"))));
|
||||
// No error; force-push computes new-minus-old cleanly.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_ref_only_yields_empty_delta_without_git() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
// All updates were deletions => new_tips empty after the caller strips zeros.
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[ZERO]),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&blob], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a blob tip must force full scan (rev-list would exit 0 and walk it)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tree], &[]),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_tag_of_tree_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
repo.git(&["tag", "-a", "treetag", "-m", "x", &tree]);
|
||||
let tag = repo.rev("treetag");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&tag], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"annotated tag peeling to a tree must force full scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_of_tag_of_noncommit_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
repo.git(&["tag", "-a", "t1", "-m", "x", &blob]);
|
||||
let t1 = repo.rev("t1");
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[&t2], &[]),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_tag_of_commit_proceeds_as_delta() {
|
||||
// DISCRIMINATING positive-path regression: an annotated tag whose peeled
|
||||
// target is a commit must return Delta, not FullScanRequired. A
|
||||
// FullScanRequired here means the guard used bare `cat-file -t` (returns
|
||||
// `tag`) instead of the `^{}` peel.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
repo.git(&["tag", "-a", "rel", "-m", "release", &c1]);
|
||||
let tag = repo.rev("rel");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&tag], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
got.contains(&c1),
|
||||
"peeled commit's objects are in the delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_of_tag_of_commit_proceeds_as_delta() {
|
||||
// Deep-peel positive path: tag -> tag -> commit must recurse to `commit`
|
||||
// via `^{}` and return Delta. Complements tag_of_tag_of_noncommit (which
|
||||
// forces full scan) — guards against a regression in peel depth that
|
||||
// would silently downgrade a legitimate nested-tag push to full scan.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
repo.git(&["tag", "-a", "t1", "-m", "x", &c1]);
|
||||
let t1 = repo.rev("t1");
|
||||
repo.git(&["tag", "-a", "t2", "-m", "x", &t1]);
|
||||
let t2 = repo.rev("t2");
|
||||
let got: HashSet<String> = delta(resolve_push_delta(&repo.path, &[&t2], &[]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(
|
||||
got.contains(&c1),
|
||||
"peeled commit's objects are in the delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_for_push_returns_delta() {
|
||||
// The extracted wiring returns the per-push delta on the happy path.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let set =
|
||||
resolve_candidates_for_push(repo.path.clone(), vec![c2.clone()], vec![c1.clone()])
|
||||
.await;
|
||||
assert!(!set.full_scan, "happy-path delta is not a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
let new_blob = repo.rev("HEAD:b.txt");
|
||||
assert!(
|
||||
got.contains(&c2) && got.contains(&new_blob),
|
||||
"new objects pinned"
|
||||
);
|
||||
assert!(!got.contains(&c1), "old commit excluded from delta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_candidates_for_push_falls_back_to_full_scan_on_noncommit_tip() {
|
||||
// A non-commit tip forces FullScanRequired, and the wiring resolves that
|
||||
// to the whole-repo list (a superset), never an empty set.
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let blob = repo.rev("HEAD:a.txt");
|
||||
let all: HashSet<String> = list_all_objects(&repo.path).unwrap().into_iter().collect();
|
||||
let set = resolve_candidates_for_push(repo.path.clone(), vec![blob], vec![]).await;
|
||||
assert!(set.full_scan, "non-commit tip is signalled as a full scan");
|
||||
let got: HashSet<String> = set.candidates.into_iter().collect();
|
||||
assert_eq!(got, all, "non-commit tip falls back to full repo scan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_oid_tip_forces_full_scan() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
let bogus = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[bogus], &[]),
|
||||
PinCandidates::FullScanRequired,
|
||||
"a missing/corrupt tip OID must force full scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_noncommit_old_tip_is_safe() {
|
||||
// A present-but-non-commit old_sha (a --not arg) must not under-pin:
|
||||
// either rev-list errors (FullScanRequired) or it over-lists (Delta).
|
||||
// Either way the new tip's objects are covered; never fewer.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let c2 = repo.commit_file("b.txt", "two\n");
|
||||
let old_tree = repo.rev(&format!("{c1}^{{tree}}"));
|
||||
let result = resolve_push_delta(&repo.path, &[&c2], &[&old_tree]);
|
||||
match result {
|
||||
PinCandidates::FullScanRequired => {} // safe: caller full-scans
|
||||
PinCandidates::Delta(objs) => {
|
||||
// Safe direction: must still contain the new commit (over-list ok).
|
||||
assert!(objs.contains(&c2), "new tip objects must be covered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_ref_push_unions_ranges() {
|
||||
let repo = Repo::new();
|
||||
let base = repo.commit_file("a.txt", "one\n");
|
||||
// branch1 advances
|
||||
let b1 = repo.commit_file("b.txt", "two\n");
|
||||
// branch2 from base advances independently
|
||||
repo.git(&["checkout", "-q", "-b", "branch2", &base]);
|
||||
let b2 = repo.commit_file("c.txt", "three\n");
|
||||
let got: HashSet<String> =
|
||||
delta(resolve_push_delta(&repo.path, &[&b1, &b2], &[&base, &base]))
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert!(got.contains(&b1), "branch1 new commit");
|
||||
assert!(got.contains(&b2), "branch2 new commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_repo_no_tips_yields_empty_delta() {
|
||||
let repo = Repo::new();
|
||||
assert_eq!(
|
||||
resolve_push_delta(&repo.path, &[], &[]),
|
||||
PinCandidates::Delta(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_full_scan_flag_overrides() {
|
||||
// Test the kill-switch via the pure inner fn so we never touch
|
||||
// process-global env (which would race with parallel tests, and is
|
||||
// unsafe in Rust 2024). The public resolve_push_delta wires the env
|
||||
// read into this same flag.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
assert_eq!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], true),
|
||||
PinCandidates::FullScanRequired
|
||||
);
|
||||
// And with the flag off, the same push yields a Delta.
|
||||
assert!(matches!(
|
||||
resolve_push_delta_inner(&repo.path, &[&c1], &[], false),
|
||||
PinCandidates::Delta(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_all_objects_returns_full_repo() {
|
||||
let repo = Repo::new();
|
||||
repo.commit_file("a.txt", "one\n");
|
||||
repo.commit_file("b.txt", "two\n");
|
||||
let all = list_all_objects(&repo.path).unwrap();
|
||||
// 2 commits + 2 trees + 2 blobs = 6 objects.
|
||||
assert_eq!(all.len(), 6, "got: {all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_blob_oids_includes_dangling_and_excludes_nonblobs() {
|
||||
// The all-blob universe must include a dangling blob (the #99 hazard) and
|
||||
// exclude commits/trees, so the fail-closed filter can tell a blob from a
|
||||
// structural object without typing the candidate list.
|
||||
let repo = Repo::new();
|
||||
let c1 = repo.commit_file("a.txt", "one\n");
|
||||
let reachable_blob = repo.rev("HEAD:a.txt");
|
||||
let tree = repo.rev("HEAD^{tree}");
|
||||
// Write a blob referenced by no tree/commit — dangling.
|
||||
std::fs::write(repo.path.join("orphan.bin"), b"dangling\n").unwrap();
|
||||
let dangling = repo.git(&["hash-object", "-w", "orphan.bin"]);
|
||||
|
||||
let blobs = all_blob_oids(&repo.path).unwrap();
|
||||
assert!(blobs.contains(&reachable_blob), "reachable blob present");
|
||||
assert!(
|
||||
blobs.contains(&dangling),
|
||||
"dangling blob present in the all-blob universe"
|
||||
);
|
||||
assert!(!blobs.contains(&c1), "commit is not a blob");
|
||||
assert!(!blobs.contains(&tree), "tree is not a blob");
|
||||
|
||||
// The typed lister tags each object; spot-check the dangling blob's type.
|
||||
let typed = list_all_objects_with_type(&repo.path).unwrap();
|
||||
assert!(
|
||||
typed
|
||||
.iter()
|
||||
.any(|(oid, ty)| oid == &dangling && ty == "blob"),
|
||||
"dangling object is typed as a blob"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,83 +75,23 @@ pub async fn receive_pack(repo_path: &Path, request_body: Bytes) -> Result<Respo
|
||||
.body(Body::from(output))?)
|
||||
}
|
||||
|
||||
/// Sends SIGTERM to a child's whole process group on drop, unless disarmed first.
|
||||
///
|
||||
/// A served `git upload-pack`/`receive-pack` forks helpers such as `pack-objects`.
|
||||
/// If the request future is dropped (client disconnect) or returns early, dropping
|
||||
/// the tokio `Child` does not signal `git`; it lingers until EPIPE, and its
|
||||
/// `pack-objects` child can reparent to PID 1 and never be reaped — a zombie that
|
||||
/// accumulates until `fork()` fails with EAGAIN (#53). Spawning the child in its
|
||||
/// own process group and signalling that group here tears the whole tree down at
|
||||
/// the source. SIGTERM (not SIGKILL) lets `git` run its cleanup — notably removing
|
||||
/// `.git/*.lock` files mid-`receive-pack` — before it exits, so an aborted push
|
||||
/// can't leave a stale lock that blocks the next one. The guard is disarmed once
|
||||
/// `wait_with_output()` returns, so a request that completed cleanly never signals.
|
||||
#[cfg(unix)]
|
||||
struct KillGroupOnDrop {
|
||||
pgid: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl KillGroupOnDrop {
|
||||
fn disarm(&mut self) {
|
||||
self.pgid = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for KillGroupOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pgid) = self.pgid {
|
||||
// SAFETY: kill(2) takes only integer arguments and borrows no Rust
|
||||
// memory. Signalling a stale group just returns ESRCH, which we ignore.
|
||||
unsafe {
|
||||
libc::kill(-pgid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_git_service(service: &str, repo_path: &Path, input: Bytes) -> Result<Vec<u8>> {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
let mut child = Command::new("git")
|
||||
.arg(service_to_command(service))
|
||||
.arg("--stateless-rpc")
|
||||
.arg(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// Run git in its own process group so the whole tree (git + pack-objects)
|
||||
// can be signalled together on disconnect rather than orphaning a grandchild.
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
|
||||
let mut child = command.spawn()?;
|
||||
|
||||
// Arm the group-kill guard for the lifetime of the request. With
|
||||
// process_group(0) the child is its own group leader, so pgid == its pid.
|
||||
#[cfg(unix)]
|
||||
let mut group_guard = KillGroupOnDrop {
|
||||
pgid: child.id().map(|id| id as i32),
|
||||
};
|
||||
|
||||
// Write the request body to git's stdin, but don't early-return on a write
|
||||
// error: always reap the child first (below), so the guard only ever fires on
|
||||
// an actual future-drop (client disconnect), never on a pid we just reaped.
|
||||
let write_result: std::io::Result<()> = match child.stdin.take() {
|
||||
Some(mut stdin) => stdin.write_all(&input).await,
|
||||
None => Ok(()),
|
||||
};
|
||||
// Write request body to git's stdin
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(&input).await?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().await?;
|
||||
|
||||
// Child reaped, so its group is gone: disarm before surfacing any error.
|
||||
#[cfg(unix)]
|
||||
group_guard.disarm();
|
||||
|
||||
write_result.context("failed to write to git stdin")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("{service} failed: {stderr}");
|
||||
@@ -212,25 +152,13 @@ pub fn build_filtered_pack(repo_path: &Path, withheld: &HashSet<String>) -> Resu
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
// Feed the object ids on stdin, but always reap the child afterward even if
|
||||
// the write fails or stdin is missing, so an error can't drop the Child
|
||||
// unwaited and leak a zombie (#53).
|
||||
let write_result: std::io::Result<()> = {
|
||||
{
|
||||
use std::io::Write as _;
|
||||
match child.stdin.take() {
|
||||
Some(mut stdin) => {
|
||||
let mut data = keep.join("\n").into_bytes();
|
||||
data.push(b'\n');
|
||||
stdin.write_all(&data)
|
||||
}
|
||||
None => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"git pack-objects stdin unavailable",
|
||||
)),
|
||||
}
|
||||
};
|
||||
let mut stdin = child.stdin.take().expect("stdin");
|
||||
stdin.write_all(keep.join("\n").as_bytes())?;
|
||||
stdin.write_all(b"\n")?;
|
||||
}
|
||||
let out = child.wait_with_output()?;
|
||||
write_result.context("failed to write object ids to git pack-objects stdin")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git pack-objects failed: {}",
|
||||
@@ -760,120 +688,4 @@ mod tests {
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
// ── #53 regression: served-git process-group teardown ──────────────────
|
||||
//
|
||||
// run_git_service runs git in its own process group and SIGTERMs that group
|
||||
// when the request future is dropped (client disconnect) or returns early, so
|
||||
// git AND its pack-objects child die together instead of orphaning a zombie.
|
||||
// These exercise the KillGroupOnDrop guard that wires that up.
|
||||
|
||||
#[cfg(unix)]
|
||||
fn alive(pid: i32) -> bool {
|
||||
// kill(pid, 0) probes existence: returns 0 while the pid exists, -1 once
|
||||
// it's gone (ESRCH). Same-uid here, so EPERM never applies.
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_group_guard_terminates_child_on_drop() {
|
||||
let mut child = tokio::process::Command::new("sleep")
|
||||
.arg("300")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pgid = child.id().map(|id| id as i32);
|
||||
|
||||
{
|
||||
let _guard = KillGroupOnDrop { pgid };
|
||||
} // guard drops here -> SIGTERM to the group
|
||||
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
let status = child.wait().await.unwrap();
|
||||
assert_eq!(
|
||||
status.signal(),
|
||||
Some(libc::SIGTERM),
|
||||
"child must be terminated by SIGTERM via its process group"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_group_guard_reaps_grandchild_on_drop() {
|
||||
// The #53 scenario: git forks pack-objects. A group kill must reach the
|
||||
// grandchild, not just the direct child. sh (the group leader) backgrounds
|
||||
// a sleep (standing in for pack-objects) and prints its pid.
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("sleep 300 & echo \"$!\"; wait")
|
||||
.stdout(Stdio::piped())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pgid = child.id().map(|id| id as i32);
|
||||
|
||||
// Read the backgrounded grandchild's pid from the first stdout line.
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
let n = stdout.read(&mut byte).await.unwrap();
|
||||
if n == 0 || byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
buf.push(byte[0]);
|
||||
}
|
||||
let grandchild: i32 = String::from_utf8(buf).unwrap().trim().parse().unwrap();
|
||||
assert!(alive(grandchild), "grandchild should be running");
|
||||
|
||||
{
|
||||
let _guard = KillGroupOnDrop { pgid };
|
||||
} // group SIGTERM reaches sh AND the sleep grandchild
|
||||
|
||||
let _ = child.wait().await; // reap sh
|
||||
|
||||
// The grandchild reparents to init and is reaped; poll until it's gone.
|
||||
let mut gone = false;
|
||||
for _ in 0..200 {
|
||||
if !alive(grandchild) {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(
|
||||
gone,
|
||||
"grandchild must be terminated by the group signal (#53)"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_group_guard_disarmed_does_not_kill() {
|
||||
// A request that completed cleanly disarms the guard; dropping it must not
|
||||
// signal anything.
|
||||
let mut child = tokio::process::Command::new("sleep")
|
||||
.arg("300")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
{
|
||||
let mut guard = KillGroupOnDrop {
|
||||
pgid: child.id().map(|id| id as i32),
|
||||
};
|
||||
guard.disarm();
|
||||
} // disarmed -> no kill
|
||||
|
||||
assert!(
|
||||
child.try_wait().unwrap().is_none(),
|
||||
"disarmed guard must not kill the child"
|
||||
);
|
||||
|
||||
// Clean up the still-running child.
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
//! Repos are stored as `repos/v1/{owner_slug}/{repo_name}.tar.zst` — a
|
||||
//! zstd-compressed tar archive of the bare repo directory.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
@@ -112,7 +110,8 @@ impl TigrisClient {
|
||||
.collect()
|
||||
.await
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
.into_bytes()
|
||||
.to_vec();
|
||||
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
@@ -157,23 +156,6 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
Ok(compressed)
|
||||
}
|
||||
|
||||
/// Per-repo-path lock serializing the publish (swap-into-place) step of
|
||||
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
|
||||
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
|
||||
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
|
||||
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
|
||||
// entry accrues per distinct repo path for the process lifetime. Bounded by
|
||||
// the number of repos a node hosts, so it's negligible for normal use, but
|
||||
// high-volume/churning deployments may want LRU or weak-ref eviction here.
|
||||
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
|
||||
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut map = locks.lock().expect("publish lock map poisoned");
|
||||
map.entry(local_path.to_path_buf())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
@@ -189,12 +171,10 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
// Unique per-extraction temp dir: a fixed name would let two concurrent
|
||||
// extractions of the same repo share one dir and clobber each other's
|
||||
// in-progress unpack. A fresh UUID also means it can't collide with a
|
||||
// leftover dir from a previously-interrupted run.
|
||||
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract.{}", uuid::Uuid::new_v4()));
|
||||
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract"));
|
||||
|
||||
// Clear any leftover temp dir from a previously-interrupted extraction.
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
|
||||
|
||||
// Unpack into the temp dir; on any failure, clean up and bail without
|
||||
@@ -212,11 +192,7 @@ fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
|
||||
// Swap the freshly-extracted repo into place. rename within the same parent
|
||||
// is effectively atomic, but most platforms refuse to rename onto a
|
||||
// non-empty dir, so remove the old copy first. Serialize this per repo path:
|
||||
// concurrent extractions unpack into isolated temp dirs, but their swaps
|
||||
// must not interleave or they race to a nondeterministic overwrite/failure.
|
||||
let lock = publish_lock(local_path);
|
||||
let _publish = lock.lock().expect("publish lock poisoned");
|
||||
// non-empty dir, so remove the old copy first.
|
||||
if local_path.exists() {
|
||||
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,21 +3,11 @@ use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::{AgentTask, Db};
|
||||
use crate::state::TaskEventBroadcast;
|
||||
|
||||
use super::types::{AgentTaskType, CreateTaskInput, FinishTaskInput};
|
||||
|
||||
/// The verified signer DID for this request, or an auth error (N2). The DID is
|
||||
/// attached request-scoped by the `/graphql` `optional_signature` layer; its
|
||||
/// absence means the request was unsigned, so every mutation rejects.
|
||||
fn require_signer<'a>(ctx: &'a Context<'_>) -> Result<&'a str> {
|
||||
ctx.data::<AuthenticatedDid>()
|
||||
.map(|d| d.0.as_str())
|
||||
.map_err(|_| async_graphql::Error::new("authentication required"))
|
||||
}
|
||||
|
||||
pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
@@ -28,13 +18,6 @@ impl MutationRoot {
|
||||
delegator_did: String,
|
||||
input: CreateTaskInput,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &delegator_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"delegator_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let delegator_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
@@ -64,13 +47,6 @@ impl MutationRoot {
|
||||
id: String,
|
||||
assignee_did: String,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &assignee_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"assignee_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let assignee_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
let task = db
|
||||
@@ -94,27 +70,8 @@ impl MutationRoot {
|
||||
by_did: String,
|
||||
input: FinishTaskInput,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &by_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"by_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: binding by_did to the signer is necessary but not
|
||||
// sufficient — only the task's assignee may finish it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can complete it",
|
||||
));
|
||||
}
|
||||
let task = db
|
||||
.finish_task(&id, "completed", input.result.as_deref())
|
||||
.await
|
||||
@@ -136,26 +93,8 @@ impl MutationRoot {
|
||||
by_did: String,
|
||||
input: FinishTaskInput,
|
||||
) -> Result<AgentTaskType> {
|
||||
let caller = require_signer(ctx)?;
|
||||
if !crate::api::did_matches(caller, &by_did) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"by_did must be the authenticated signer",
|
||||
));
|
||||
}
|
||||
let by_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
// Authorize the actor: only the task's assignee may fail it.
|
||||
let existing = db
|
||||
.get_task(&id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?
|
||||
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
|
||||
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
|
||||
return Err(async_graphql::Error::new(
|
||||
"only the task assignee can fail it",
|
||||
));
|
||||
}
|
||||
let reason = input.reason.unwrap_or_default();
|
||||
let task = db
|
||||
.finish_task(&id, "failed", Some(&reason))
|
||||
@@ -171,123 +110,3 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use async_graphql::{Request, Response};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn errors(resp: &Response) -> String {
|
||||
resp.errors
|
||||
.iter()
|
||||
.map(|e| e.message.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// N2: GraphQL mutations require a verified signer and bind the acting DID to
|
||||
/// it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// rejected; the matching signer passes the auth gate.
|
||||
#[sqlx::test]
|
||||
async fn mutation_requires_and_binds_signer(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
let assignee = "did:key:zASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let q = format!(
|
||||
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{assignee}") {{ id }} }}"#
|
||||
);
|
||||
|
||||
// 1. Unsigned → rejected before any DB work.
|
||||
let resp = schema.execute(Request::new(&q)).await;
|
||||
assert!(
|
||||
errors(&resp).contains("authentication required"),
|
||||
"unsigned mutation must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// 2. Signed as someone other than the claimed assignee → rejected.
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(
|
||||
"did:key:zOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
|
||||
)))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("authenticated signer"),
|
||||
"DID mismatch must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// 3. Signed as the claimed assignee → passes the auth gate (any remaining
|
||||
// error is the missing task, not an auth error).
|
||||
let resp = schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
|
||||
"matching signer must pass the auth gate: {errs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
|
||||
/// assignee, not merely signing as the by_did you pass. A signer who is not
|
||||
/// the assignee is rejected even though the by_did binding passes; the
|
||||
/// assignee completes.
|
||||
#[sqlx::test]
|
||||
async fn complete_task_requires_assignee(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let assignee = "did:key:zGQLASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zGQLSTRANGERBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let task = crate::db::AgentTask {
|
||||
id: "task-g".into(),
|
||||
repo_id: None,
|
||||
kind: "build".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
|
||||
assignee_did: None,
|
||||
capability: "repo:write".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deadline: None,
|
||||
};
|
||||
state.db.create_task(&task).await.expect("seed task");
|
||||
state
|
||||
.db
|
||||
.claim_task("task-g", assignee)
|
||||
.await
|
||||
.expect("claim");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ completeTask(id: "task-g", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves and passes byDid=self (so the signer
|
||||
// binding passes), but is not the assignee → rejected by authorization.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).contains("assignee"),
|
||||
"a non-assignee signer must be rejected: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// The assignee completes with no error.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(assignee)).data(AuthenticatedDid(assignee.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"the assignee should complete the task: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,29 +12,11 @@ impl QueryRoot {
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.list_all_repos()
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
|
||||
// this surface does not enumerate private repos (#97). The caller DID is
|
||||
// threaded onto the context by optional_signature; absent = anonymous.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = db
|
||||
.list_visibility_rules_for_repos(&ids)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(repos
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
|
||||
})
|
||||
.map(|r| RepoType {
|
||||
name: r.name,
|
||||
owner_did: r.owner_did,
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
//! iCaptcha proof-of-intelligence gate.
|
||||
//!
|
||||
//! Spam-prone endpoints (repo creation, agent registration) can require the
|
||||
//! caller to present an iCaptcha proof: a small Ed25519-signed token minted by
|
||||
//! <https://icaptcha.gitlawb.com> after the caller solves an escalating
|
||||
//! challenge. We verify the proof OFFLINE (no per-request call to iCaptcha)
|
||||
//! using its published public key, and bind each proof to the authenticated
|
||||
//! agent DID so a proof cannot be shared between identities.
|
||||
//!
|
||||
//! Behaviour is controlled by `ICAPTCHA_MODE`:
|
||||
//! * `off` (default) — gate is inert, nothing is checked.
|
||||
//! * `shadow` — verify and log would-be rejections, but always allow.
|
||||
//! * `enforce` — reject requests without a valid, sufficiently-strong proof.
|
||||
//!
|
||||
//! Config (env):
|
||||
//! ICAPTCHA_MODE off | shadow | enforce (default off)
|
||||
//! ICAPTCHA_URL base URL (default https://icaptcha.gitlawb.com)
|
||||
//! ICAPTCHA_PUBKEY base64url Ed25519 public key (optional; else fetched from /v1/pubkey)
|
||||
//! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3)
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
const PROOF_HEADER: &str = "x-icaptcha-proof";
|
||||
|
||||
/// How long a mirror-admission `jti` stays in the replay ledger. The direct
|
||||
/// request path spends a proof against its own `exp` (it can't be presented once
|
||||
/// expired). The propagation path deliberately accepts already-expired proofs,
|
||||
/// so spending against `exp` would store an already-past expiry that the next
|
||||
/// sweep deletes — letting the same token admit another mirror minutes later.
|
||||
/// We instead retain the mirror replay record for a long, fixed window so the
|
||||
/// per-node single-use property is durable.
|
||||
const MIRROR_REPLAY_RETENTION_SECS: i64 = 365 * 24 * 60 * 60;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Mode {
|
||||
Off,
|
||||
Shadow,
|
||||
Enforce,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Off => "off",
|
||||
Mode::Shadow => "shadow",
|
||||
Mode::Enforce => "enforce",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `ICAPTCHA_MODE`. Returns `None` for unrecognized values so the caller
|
||||
/// can surface the typo instead of silently disabling the gate.
|
||||
fn parse_mode(s: &str) -> Option<Mode> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "off" => Some(Mode::Off),
|
||||
"shadow" => Some(Mode::Shadow),
|
||||
"enforce" => Some(Mode::Enforce),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `ICAPTCHA_REQUIRED_LEVEL`. Defaults to 3; warns (rather than silently
|
||||
/// lowering the threshold) when a non-empty value fails to parse.
|
||||
fn parse_required_level() -> u32 {
|
||||
const DEFAULT: u32 = 3;
|
||||
match std::env::var("ICAPTCHA_REQUIRED_LEVEL") {
|
||||
Ok(v) if !v.trim().is_empty() => v.trim().parse().unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
value = %v,
|
||||
default = DEFAULT,
|
||||
"invalid ICAPTCHA_REQUIRED_LEVEL; using default"
|
||||
);
|
||||
DEFAULT
|
||||
}),
|
||||
_ => DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
struct Verifier {
|
||||
mode: Mode,
|
||||
url: String,
|
||||
required_level: u32,
|
||||
key: Option<VerifyingKey>,
|
||||
}
|
||||
|
||||
static VERIFIER: OnceLock<Verifier> = OnceLock::new();
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ProofClaims {
|
||||
sub: String,
|
||||
level: u32,
|
||||
exp: i64,
|
||||
/// Unique proof id, consumed once so a proof cannot be replayed.
|
||||
jti: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Jwk {
|
||||
x: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Jwks {
|
||||
keys: Vec<Jwk>,
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn decode_key(b64url: &str) -> Option<VerifyingKey> {
|
||||
let bytes = URL_SAFE_NO_PAD.decode(b64url.trim()).ok()?;
|
||||
let arr: [u8; 32] = bytes.try_into().ok()?;
|
||||
VerifyingKey::from_bytes(&arr).ok()
|
||||
}
|
||||
|
||||
async fn fetch_key(url: &str) -> Option<VerifyingKey> {
|
||||
let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/'));
|
||||
// Bounded request: a hung /v1/pubkey must never block node startup. On
|
||||
// timeout/error we return None and the gate stays inert (fail safe).
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.ok()?;
|
||||
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
|
||||
decode_key(&jwks.keys.first()?.x)
|
||||
}
|
||||
|
||||
/// Initialize the gate from the environment. Call once at startup. Never panics;
|
||||
/// if the gate is active but no key can be loaded, it stays inert and warns.
|
||||
pub async fn init() {
|
||||
let raw_mode = std::env::var("ICAPTCHA_MODE").unwrap_or_default();
|
||||
let mode = parse_mode(&raw_mode).unwrap_or_else(|| {
|
||||
tracing::warn!(value = %raw_mode, "invalid ICAPTCHA_MODE; disabling iCaptcha gate");
|
||||
Mode::Off
|
||||
});
|
||||
let url = std::env::var("ICAPTCHA_URL")
|
||||
.unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string());
|
||||
let required_level = parse_required_level();
|
||||
|
||||
let key = if mode == Mode::Off {
|
||||
None
|
||||
} else {
|
||||
match std::env::var("ICAPTCHA_PUBKEY") {
|
||||
Ok(b64) if !b64.is_empty() => decode_key(&b64),
|
||||
_ => fetch_key(&url).await,
|
||||
}
|
||||
};
|
||||
|
||||
if mode != Mode::Off {
|
||||
if key.is_some() {
|
||||
tracing::info!(mode = mode.as_str(), required_level, "iCaptcha gate active");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
mode = mode.as_str(),
|
||||
"iCaptcha gate enabled but no public key could be loaded; staying inert"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = VERIFIER.set(Verifier {
|
||||
mode,
|
||||
url,
|
||||
required_level,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
/// Outcome of the synchronous, IO-free decision step.
|
||||
#[derive(Debug)]
|
||||
enum Decision {
|
||||
/// Allow the request (off, shadow, inert/no-key, or verified non-enforcing).
|
||||
Allow,
|
||||
/// Enforce mode and verification failed; reject with this reason.
|
||||
Reject(String),
|
||||
/// Enforce mode and the proof verified; the caller must consume its `jti`
|
||||
/// (and reject replays) before allowing, then may persist it.
|
||||
Consume(VerifiedProof),
|
||||
}
|
||||
|
||||
/// A verified proof: the raw token plus the claims we act on. Carried by a
|
||||
/// [`ProofGuard`] so that, once consumed, the gated handler can persist it with
|
||||
/// the created repo — letting the proof travel with the repo when it propagates
|
||||
/// to peers (see [`admit_mirror`]).
|
||||
#[derive(Debug)]
|
||||
pub struct VerifiedProof {
|
||||
token: String,
|
||||
sub: String,
|
||||
level: u32,
|
||||
jti: String,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
impl VerifiedProof {
|
||||
/// Persist this proof against a freshly-created repo so a mirroring peer can
|
||||
/// re-verify it. Best-effort callers may ignore failures; the gate's
|
||||
/// security does not depend on it (propagation just falls back to quarantine).
|
||||
pub async fn record_for_repo(&self, db: &crate::db::Db, repo_id: &str) -> Result<(), AppError> {
|
||||
db.record_repo_proof(
|
||||
repo_id,
|
||||
&self.token,
|
||||
&self.sub,
|
||||
self.level as i32,
|
||||
&self.jti,
|
||||
self.exp,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A verified proof awaiting consumption. Verification (which rejects invalid or
|
||||
/// missing proofs) is separated from consumption (which spends the single-use
|
||||
/// `jti`) so a request rejected by later validation never burns a valid proof.
|
||||
/// The caller must `consume()` this guard immediately before the gated write.
|
||||
/// For off/shadow/inert there is nothing to consume.
|
||||
#[must_use = "a verified iCaptcha proof must be consumed before the gated action"]
|
||||
pub struct ProofGuard(Option<VerifiedProof>);
|
||||
|
||||
impl ProofGuard {
|
||||
/// Spend the proof's `jti` (single-use) and return the verified proof so the
|
||||
/// caller can persist it. A replay is rejected. Returns `None` when there is
|
||||
/// nothing to consume (off/shadow/inert).
|
||||
pub async fn consume(self, db: &crate::db::Db) -> Result<Option<VerifiedProof>, AppError> {
|
||||
match self.0 {
|
||||
Some(p) => {
|
||||
if !db.consume_proof_jti(&p.jti, p.exp).await? {
|
||||
return Err(AppError::IcaptchaProofRequired(
|
||||
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(p))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the proof in `headers` belongs to `did`. Rejects missing/invalid
|
||||
/// proofs early (enforce mode); off/shadow never reject. Returns a [`ProofGuard`]
|
||||
/// the caller must `consume()` right before the gated write. Off/shadow/inert
|
||||
/// yield a no-op guard that consumes nothing.
|
||||
pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppError> {
|
||||
let v = match VERIFIER.get() {
|
||||
Some(v) => v,
|
||||
None => return Ok(ProofGuard(None)), // not initialized -> inert
|
||||
};
|
||||
match decide(v, headers, did, now_secs()) {
|
||||
Decision::Allow => Ok(ProofGuard(None)),
|
||||
Decision::Reject(reason) => Err(reject_error(v, &reason)),
|
||||
Decision::Consume(proof) => Ok(ProofGuard(Some(proof))),
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_error(v: &Verifier, reason: &str) -> AppError {
|
||||
AppError::IcaptchaProofRequired(format!(
|
||||
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
|
||||
v.url, v.required_level, PROOF_HEADER
|
||||
))
|
||||
}
|
||||
|
||||
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
|
||||
/// is fully unit-testable. The caller performs jti consumption for `Consume`.
|
||||
fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Decision {
|
||||
if v.mode == Mode::Off {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
// Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable
|
||||
// at startup), stay inert rather than rejecting every request. The operator
|
||||
// already saw a startup warning. An iCaptcha hiccup must never break repo
|
||||
// creation or registration.
|
||||
if v.key.is_none() {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
let token = headers.get(PROOF_HEADER).and_then(|h| h.to_str().ok());
|
||||
let result = match token {
|
||||
Some(t) => verify_token(v, t, did, now, true).map(|claims| (t, claims)),
|
||||
None => Err("missing proof header".to_string()),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((token, claims)) => match v.mode {
|
||||
Mode::Enforce => Decision::Consume(VerifiedProof {
|
||||
token: token.to_string(),
|
||||
sub: claims.sub,
|
||||
level: claims.level,
|
||||
jti: claims.jti,
|
||||
exp: claims.exp,
|
||||
}),
|
||||
// Shadow/Off: never reject, and do not consume (observational only).
|
||||
_ => Decision::Allow,
|
||||
},
|
||||
Err(reason) => match v.mode {
|
||||
Mode::Shadow => {
|
||||
tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject");
|
||||
Decision::Allow
|
||||
}
|
||||
Mode::Enforce => Decision::Reject(reason),
|
||||
Mode::Off => Decision::Allow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Header-extracting wrapper over [`verify_token`] (enforces expiry). The
|
||||
/// production path inlines this in [`decide`]; retained as the unit-test entry
|
||||
/// point that also exercises header extraction.
|
||||
#[cfg(test)]
|
||||
fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<ProofClaims, String> {
|
||||
let proof = headers
|
||||
.get(PROOF_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or("missing proof header")?;
|
||||
verify_token(v, proof, did, now, true)
|
||||
}
|
||||
|
||||
/// Core verification of a raw proof token, separated for testability and reuse.
|
||||
/// Checks the Ed25519 signature, the required level, and the DID binding. `exp`
|
||||
/// is only enforced when `check_exp` is true: the direct request path enforces
|
||||
/// freshness, but the propagation path ([`admit_mirror`]) does not — a proof has
|
||||
/// usually expired by the time a repo mirrors, and the origin enforced freshness
|
||||
/// at creation. `now` is unix seconds.
|
||||
fn verify_token(
|
||||
v: &Verifier,
|
||||
token: &str,
|
||||
did: &str,
|
||||
now: i64,
|
||||
check_exp: bool,
|
||||
) -> Result<ProofClaims, String> {
|
||||
let key = v.key.as_ref().ok_or("verifier has no public key")?;
|
||||
|
||||
let (payload, sig_b64) = token.split_once('.').ok_or("malformed proof")?;
|
||||
let sig_bytes = URL_SAFE_NO_PAD
|
||||
.decode(sig_b64)
|
||||
.map_err(|_| "bad signature encoding")?;
|
||||
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad signature length")?;
|
||||
key.verify_strict(payload.as_bytes(), &sig)
|
||||
.map_err(|_| "signature verification failed")?;
|
||||
|
||||
let claims_bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| "bad payload encoding")?;
|
||||
let claims: ProofClaims = serde_json::from_slice(&claims_bytes).map_err(|_| "bad claims")?;
|
||||
|
||||
if check_exp && claims.exp < now {
|
||||
return Err("proof expired".to_string());
|
||||
}
|
||||
if claims.level < v.required_level {
|
||||
return Err(format!(
|
||||
"level {} below required {}",
|
||||
claims.level, v.required_level
|
||||
));
|
||||
}
|
||||
if !crate::api::did_matches(did, &claims.sub) {
|
||||
return Err("proof subject does not match authenticated DID".to_string());
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
/// Outcome of admitting a repo mirrored from a peer.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum MirrorAdmission {
|
||||
/// Admit the mirror normally (off/shadow/inert, or a valid proof in enforce).
|
||||
Admit,
|
||||
/// Enforce mode and the mirror's proof is missing/invalid/replayed — mirror
|
||||
/// it but quarantine until an operator releases it. Carries the reason.
|
||||
Quarantine(String),
|
||||
}
|
||||
|
||||
/// Decide whether to admit a repo being mirrored from a peer, given the proof
|
||||
/// token the origin served (`None` if it had none or the fetch failed) and the
|
||||
/// mirrored repo's owner DID.
|
||||
///
|
||||
/// Off/shadow/inert always admit (shadow logs the would-be quarantine and writes
|
||||
/// nothing). In enforce mode the proof is re-verified offline against the local
|
||||
/// public key — signature, level, and owner-DID binding, but NOT expiry — and
|
||||
/// its `jti` is consumed in the local ledger, so one solved challenge admits at
|
||||
/// most one mirror per node (a malicious origin cannot reuse one proof to bless
|
||||
/// many spam repos here).
|
||||
///
|
||||
/// LIMITATION (proof is not repo-bound): an iCaptcha proof commits only to the
|
||||
/// solver's DID (`sub`), never to a repo — the proof is minted before the repo
|
||||
/// exists. So the guarantee enforced here is "a level≥N challenge was solved by
|
||||
/// this repo's owner DID", not "the owner intended THIS repo". A malicious origin
|
||||
/// that harvests a victim's public proof (served by `get_icaptcha_proof`) can use
|
||||
/// it to get ONE spam repo nominally owned by that victim admitted per node — the
|
||||
/// per-node `jti` single-use caps the amplification at one repo per distinct
|
||||
/// harvested proof, but does not eliminate it. Fully binding a proof to a repo
|
||||
/// would require the iCaptcha service to embed a repo/target identifier in the
|
||||
/// signed challenge (a protocol change, tracked as future work).
|
||||
pub async fn admit_mirror(
|
||||
db: &crate::db::Db,
|
||||
token: Option<&str>,
|
||||
owner_did: &str,
|
||||
) -> MirrorAdmission {
|
||||
let v = match VERIFIER.get() {
|
||||
Some(v) => v,
|
||||
None => return MirrorAdmission::Admit, // not initialized -> inert
|
||||
};
|
||||
// Off, or inert because no key could be loaded: admit unconditionally.
|
||||
if v.mode == Mode::Off || v.key.is_none() {
|
||||
return MirrorAdmission::Admit;
|
||||
}
|
||||
let shadow = v.mode == Mode::Shadow;
|
||||
|
||||
// Quarantine in enforce; in shadow just log and admit (observational, no IO).
|
||||
let quarantine = |reason: String| -> MirrorAdmission {
|
||||
if shadow {
|
||||
tracing::warn!(owner = %owner_did, reason, "iCaptcha (shadow) would quarantine mirror");
|
||||
MirrorAdmission::Admit
|
||||
} else {
|
||||
MirrorAdmission::Quarantine(reason)
|
||||
}
|
||||
};
|
||||
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return quarantine("origin served no iCaptcha proof".to_string()),
|
||||
};
|
||||
|
||||
match verify_token(v, token, owner_did, now_secs(), false) {
|
||||
Ok(claims) => {
|
||||
if shadow {
|
||||
// Observational: a valid proof would admit. Do not consume, so a
|
||||
// later switch to enforce still sees the jti as fresh.
|
||||
return MirrorAdmission::Admit;
|
||||
}
|
||||
// Retain the replay record on a fixed forward window, NOT the proof's
|
||||
// own (already-past) exp — otherwise the next sweep frees the jti and
|
||||
// the same token could admit another mirror here.
|
||||
let retain_until = now_secs().saturating_add(MIRROR_REPLAY_RETENTION_SECS);
|
||||
match db.consume_proof_jti(&claims.jti, retain_until).await {
|
||||
Ok(true) => MirrorAdmission::Admit,
|
||||
Ok(false) => MirrorAdmission::Quarantine(
|
||||
"iCaptcha proof already used to admit another mirror".to_string(),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(owner = %owner_did, err = %e, "iCaptcha mirror ledger error; quarantining");
|
||||
MirrorAdmission::Quarantine("iCaptcha proof ledger unavailable".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(reason) => quarantine(reason),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Real values captured from https://icaptcha.gitlawb.com (a live proof).
|
||||
const PUBKEY_X: &str = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8";
|
||||
const PROOF: &str = "eyJzdWIiOiJkaWQ6a2V5Onp0ZXN0IiwibGV2ZWwiOjMsImlzcyI6ImljYXB0Y2hhIiwiaWF0IjoxNzgyNTcyODUxLCJleHAiOjE3ODI1NzMxNTEsImp0aSI6IjRiNTIyOGE1YmVkNzEyMmRlZTlmNDdmZiJ9.5UXVPZ8Eo91VnlcvgDXtW-Fx7J2jr7h535SAstQEpigxBr7FF7V6R0XB4PBDgdoBPnhdH_kVEfRPfdHPSdB0CA";
|
||||
const SUB: &str = "did:key:ztest";
|
||||
const IAT: i64 = 1782572851; // within the proof's validity window
|
||||
|
||||
fn verifier(level: u32) -> Verifier {
|
||||
Verifier {
|
||||
mode: Mode::Enforce,
|
||||
url: "https://icaptcha.gitlawb.com".to_string(),
|
||||
required_level: level,
|
||||
key: decode_key(PUBKEY_X),
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_with(proof: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(PROOF_HEADER, proof.parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_real_proof() {
|
||||
let v = verifier(3);
|
||||
assert!(verify(&v, &headers_with(PROOF), SUB, IAT).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired_proof() {
|
||||
let v = verifier(3);
|
||||
let err = verify(&v, &headers_with(PROOF), SUB, 9_999_999_999).unwrap_err();
|
||||
assert!(err.contains("expired"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_did() {
|
||||
let v = verifier(3);
|
||||
let err = verify(&v, &headers_with(PROOF), "did:key:zsomeoneelse", IAT).unwrap_err();
|
||||
assert!(err.contains("subject"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insufficient_level() {
|
||||
let v = verifier(5); // proof is level 3
|
||||
let err = verify(&v, &headers_with(PROOF), SUB, IAT).unwrap_err();
|
||||
assert!(err.contains("below required"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_signature() {
|
||||
let v = verifier(3);
|
||||
// Flip one base64url char in the signature so it is guaranteed different.
|
||||
let (payload, sig) = PROOF.split_once('.').unwrap();
|
||||
let mut chars: Vec<char> = sig.chars().collect();
|
||||
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
|
||||
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
|
||||
assert!(verify(&v, &headers_with(&tampered), SUB, IAT).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_header() {
|
||||
let v = verifier(3);
|
||||
let err = verify(&v, &HeaderMap::new(), SUB, IAT).unwrap_err();
|
||||
assert!(err.contains("missing"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_accepts_documented_values_and_rejects_junk() {
|
||||
assert_eq!(parse_mode(""), Some(Mode::Off));
|
||||
assert_eq!(parse_mode("off"), Some(Mode::Off));
|
||||
assert_eq!(parse_mode(" Shadow "), Some(Mode::Shadow));
|
||||
assert_eq!(parse_mode("ENFORCE"), Some(Mode::Enforce));
|
||||
// Typos must NOT silently disable the gate.
|
||||
assert_eq!(parse_mode("enforced"), None);
|
||||
assert_eq!(parse_mode("on"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_mode_allows_everything() {
|
||||
let mut v = verifier(3);
|
||||
v.mode = Mode::Off;
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Allow
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_without_key_stays_inert() {
|
||||
// iCaptcha unreachable at startup -> no key -> must not reject.
|
||||
let v = Verifier {
|
||||
mode: Mode::Enforce,
|
||||
url: "https://icaptcha.gitlawb.com".to_string(),
|
||||
required_level: 3,
|
||||
key: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Allow
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_with_key_rejects_missing_proof() {
|
||||
let v = verifier(3);
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Reject(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_allows_despite_bad_proof() {
|
||||
let mut v = verifier(3);
|
||||
v.mode = Mode::Shadow;
|
||||
assert!(matches!(
|
||||
decide(&v, &HeaderMap::new(), SUB, IAT),
|
||||
Decision::Allow
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_valid_proof_requires_consuming_its_jti() {
|
||||
// A verified proof under enforce must yield Consume carrying the jti, so
|
||||
// the caller can spend it once and reject replays.
|
||||
let v = verifier(3);
|
||||
match decide(&v, &headers_with(PROOF), SUB, IAT) {
|
||||
Decision::Consume(p) => {
|
||||
assert_eq!(p.jti, "4b5228a5bed7122dee9f47ff");
|
||||
assert_eq!(p.exp, 1782573151);
|
||||
assert_eq!(p.token, PROOF, "the raw token is retained for persistence");
|
||||
assert_eq!(p.sub, SUB);
|
||||
}
|
||||
other => panic!("expected Consume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagation_accepts_an_expired_proof() {
|
||||
// The mirror-admission path verifies sig/level/DID but NOT expiry, since a
|
||||
// proof has usually expired by the time a repo propagates. The same proof
|
||||
// that the direct path rejects as expired must verify here.
|
||||
let v = verifier(3);
|
||||
let now = 9_999_999_999; // far past the proof's exp
|
||||
assert!(
|
||||
verify_token(&v, PROOF, SUB, now, true).is_err(),
|
||||
"direct path (check_exp=true) rejects the expired proof"
|
||||
);
|
||||
assert!(
|
||||
verify_token(&v, PROOF, SUB, now, false).is_ok(),
|
||||
"propagation path (check_exp=false) accepts it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagation_still_enforces_signature_level_and_did() {
|
||||
let v = verifier(3);
|
||||
// wrong owner DID
|
||||
assert!(verify_token(&v, PROOF, "did:key:zother", IAT, false).is_err());
|
||||
// insufficient level
|
||||
let v5 = verifier(5);
|
||||
assert!(verify_token(&v5, PROOF, SUB, IAT, false).is_err());
|
||||
// tampered signature
|
||||
let (payload, sig) = PROOF.split_once('.').unwrap();
|
||||
let mut chars: Vec<char> = sig.chars().collect();
|
||||
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
|
||||
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
|
||||
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
//! If `ipfs_api` is empty the functions are no-ops, so the node works fine
|
||||
//! without a local IPFS daemon.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use gitlawb_core::cid::Cid;
|
||||
|
||||
@@ -83,27 +85,31 @@ pub async fn cat(ipfs_api: &str, cid: &str) -> Result<Vec<u8>> {
|
||||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
/// Pin any of the given candidate git objects that are not yet recorded in
|
||||
/// `pinned_cids`.
|
||||
///
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
|
||||
/// applies `visibility_pack::replicable_objects` on the delta path or the
|
||||
/// `..._fail_closed` filter on the full-scan path before calling, so this
|
||||
/// function never sees a withheld blob. `repo_path` is still needed to read each
|
||||
/// object's bytes. The twin in `pinata.rs` mirrors this shape — change both in
|
||||
/// lockstep.
|
||||
/// List all git objects in the given bare repo and pin any that are not yet
|
||||
/// recorded in `pinned_cids`.
|
||||
///
|
||||
/// Returns a list of `(sha256_hex, cid)` pairs for objects pinned this call.
|
||||
pub async fn pin_new_objects(
|
||||
ipfs_api: &str,
|
||||
repo_path: &std::path::Path,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
withheld: &HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
if ipfs_api.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Enumerate all objects in the repo
|
||||
let object_list = match list_all_objects(repo_path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo_path.display(), err = %e, "failed to list git objects for IPFS pinning");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let object_list = crate::git::visibility_pack::replicable_objects(object_list, withheld);
|
||||
|
||||
let mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
@@ -144,3 +150,31 @@ pub async fn pin_new_objects(
|
||||
|
||||
pinned
|
||||
}
|
||||
|
||||
/// Run `git cat-file --batch-all-objects --batch-check='%(objectname)'`
|
||||
/// to get all object SHA-256 hashes in the repository.
|
||||
fn list_all_objects(repo_path: &std::path::Path) -> Result<Vec<String>> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("failed to run git cat-file: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("git cat-file failed: {stderr}"));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let hashes = stdout
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(hashes)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
mod admin;
|
||||
mod api;
|
||||
mod arweave;
|
||||
mod auth;
|
||||
@@ -10,7 +9,6 @@ mod encrypted_pin;
|
||||
mod error;
|
||||
mod git;
|
||||
mod graphql;
|
||||
mod icaptcha;
|
||||
mod ipfs_pin;
|
||||
mod metrics;
|
||||
mod operator;
|
||||
@@ -20,8 +18,6 @@ mod rate_limit;
|
||||
mod server;
|
||||
mod state;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod visibility;
|
||||
mod webhooks;
|
||||
|
||||
@@ -51,15 +47,6 @@ async fn main() -> Result<()> {
|
||||
|
||||
let mut config = Config::parse();
|
||||
|
||||
// Admin subcommands run instead of the daemon and exit.
|
||||
if let Some(cmd) = config.command.clone() {
|
||||
match cmd {
|
||||
config::Command::PurgeSpam(args) => {
|
||||
return admin::purge_spam(&config, &args).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the embedded seed list of public network nodes into the runtime
|
||||
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
|
||||
bootstrap::merge_seeds(&mut config);
|
||||
@@ -111,15 +98,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Prune peer rows with non-public hosts (loopback/private/internal) that
|
||||
// were injected via the unauthenticated announce route — they poison the
|
||||
// sync-notify fan-out (SSRF + crowding out real peers).
|
||||
match db.prune_non_public_peers().await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(removed = n, "pruned non-public (poisoned) peer rows"),
|
||||
Err(e) => warn!(err = %e, "prune_non_public_peers failed (non-fatal)"),
|
||||
}
|
||||
|
||||
// Ensure repos directory exists
|
||||
std::fs::create_dir_all(&config.repos_dir).context("failed to create repos directory")?;
|
||||
|
||||
@@ -155,14 +133,9 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
// Do not follow redirects: this client fans out to peer-supplied URLs
|
||||
// (sync trigger, profile/repo fetches). A peer answering `302 Location:
|
||||
// http://127.0.0.1/` would otherwise bypass the announce-time public-URL
|
||||
// check and turn the redirect into an SSRF.
|
||||
let http_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?,
|
||||
);
|
||||
|
||||
@@ -202,9 +175,6 @@ async fn main() -> Result<()> {
|
||||
|
||||
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
|
||||
|
||||
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
|
||||
icaptcha::init().await;
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(config.clone()),
|
||||
db,
|
||||
@@ -275,23 +245,15 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
// Periodic cleanup of expired rate limit entries
|
||||
{
|
||||
let rl = state.rate_limiter.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
rl.cleanup().await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if let Err(e) = db.sweep_expired_proofs(now).await {
|
||||
tracing::warn!(err = %e, "failed to sweep expired iCaptcha proofs");
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! no-op, so nodes without Pinata backing work fine.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Pin a single git object's raw bytes on Pinata (v3 API).
|
||||
///
|
||||
@@ -66,28 +67,35 @@ pub async fn pin_object(
|
||||
Ok(cid)
|
||||
}
|
||||
|
||||
/// Pin any of the given candidate git objects that haven't yet been sent to
|
||||
/// Pinata.
|
||||
/// Pin all git objects in a repo that haven't yet been sent to Pinata.
|
||||
///
|
||||
/// `object_list` is the already-withheld-filtered OID set to pin: the caller
|
||||
/// applies `visibility_pack::replicable_objects` on the delta path or the
|
||||
/// `..._fail_closed` filter on the full-scan path before calling. `repo_path` is
|
||||
/// still needed to read each object's bytes. The twin in `ipfs_pin.rs` mirrors
|
||||
/// this shape — change both in lockstep. Objects already recorded with a
|
||||
/// `pinata_cid` are skipped. Returns `(sha_hex, cid)` pairs for each newly
|
||||
/// pinned object.
|
||||
/// Objects already recorded with a `pinata_cid` in the DB are skipped.
|
||||
/// Returns `(sha_hex, cid)` pairs for each newly pinned object.
|
||||
pub async fn pin_new_objects(
|
||||
client: &reqwest::Client,
|
||||
upload_url: &str,
|
||||
jwt: &str,
|
||||
repo_path: &std::path::Path,
|
||||
object_list: Vec<String>,
|
||||
db: &crate::db::Db,
|
||||
withheld: &HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
if jwt.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let object_list = match list_all_objects(repo_path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
repo = %repo_path.display(),
|
||||
err = %e,
|
||||
"failed to list git objects for Pinata upload"
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
let object_list = crate::git::visibility_pack::replicable_objects(object_list, withheld);
|
||||
|
||||
let mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
@@ -126,6 +134,29 @@ pub async fn pin_new_objects(
|
||||
pinned
|
||||
}
|
||||
|
||||
fn list_all_objects(repo_path: &std::path::Path) -> Result<Vec<String>> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args([
|
||||
"cat-file",
|
||||
"--batch-all-objects",
|
||||
"--batch-check=%(objectname)",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("failed to run git cat-file: {e}"))?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(anyhow::anyhow!("git cat-file failed: {stderr}"));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -34,17 +34,9 @@ impl RateLimiter {
|
||||
async fn check(&self, key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
// Look up before inserting so the common case (key already tracked)
|
||||
// doesn't allocate a String per request.
|
||||
if !state.contains_key(key) {
|
||||
state.insert(
|
||||
key.to_string(),
|
||||
Window {
|
||||
timestamps: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let window = state.get_mut(key).expect("window just ensured");
|
||||
let window = state.entry(key.to_string()).or_insert_with(|| Window {
|
||||
timestamps: Vec::new(),
|
||||
});
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
|
||||
@@ -20,19 +20,8 @@ use crate::auth;
|
||||
use crate::rate_limit;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn graphql_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<axum::Extension<crate::auth::AuthenticatedDid>>,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
// `optional_signature` attaches the verified DID when a signature is present.
|
||||
// Thread it into request-scoped GraphQL data; mutations enforce its presence
|
||||
// in-resolver (N2) while queries stay open.
|
||||
let mut inner = req.into_inner();
|
||||
if let Some(axum::Extension(did)) = auth {
|
||||
inner = inner.data(did);
|
||||
}
|
||||
state.graphql_schema.execute(inner).await.into()
|
||||
async fn graphql_handler(State(state): State<AppState>, req: GraphQLRequest) -> GraphQLResponse {
|
||||
state.graphql_schema.execute(req.into_inner()).await.into()
|
||||
}
|
||||
|
||||
async fn graphql_playground() -> impl IntoResponse {
|
||||
@@ -60,10 +49,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
let schema = state.graphql_schema.as_ref().clone();
|
||||
let graphql_routes = Router::new()
|
||||
.route("/graphql", get(graphql_playground).post(graphql_handler))
|
||||
// Attach the verified DID to /graphql when a signature is present. The
|
||||
// layer covers only routes added before it, so /graphql/ws (added after,
|
||||
// read-only subscriptions) stays open.
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
|
||||
|
||||
// ── Task routes (write — require HTTP Signature) ───────────────────────
|
||||
@@ -162,10 +147,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
axum::routing::put(visibility::set_visibility)
|
||||
.delete(visibility::remove_visibility)
|
||||
.get(visibility::list_visibility),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/agents/{did}",
|
||||
axum::routing::delete(agents::deregister_agent),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
@@ -187,14 +168,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
);
|
||||
|
||||
// ── IPFS content-addressed retrieval and pin listing ──────────────────
|
||||
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
|
||||
// identity and can apply per-repo visibility (#110); anonymous callers stay
|
||||
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
|
||||
// stays unsigned — gating the pin index is tracked separately (#121).
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
|
||||
@@ -381,10 +357,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/withheld-paths",
|
||||
axum::routing::get(visibility::withheld_paths),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/icaptcha-proof",
|
||||
axum::routing::get(repos::get_icaptcha_proof),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/encrypted-blobs",
|
||||
axum::routing::get(crate::api::encrypted::list_encrypted_blobs),
|
||||
@@ -460,31 +432,13 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
// Count only the repos an anonymous caller could list, so the aggregate does
|
||||
// not leak the existence of private/mode-A repos (#104 count oracle). Mirror
|
||||
// the listing seam (api/repos.rs): over-fetch the deduped set, batch-load the
|
||||
// visibility rules, and keep rows that pass listable_at_root. The caller is
|
||||
// always None — meta_routes carries no auth layer (see the route group in this
|
||||
// file). Fail closed: any DB error collapses the whole count to 0 (an
|
||||
// under-count never leaks existence), preserving the prior `.unwrap_or(0)`.
|
||||
let repos = async {
|
||||
// stats only needs the count, so use the no-stars deduped list (same
|
||||
// DEDUP_CTE) and skip the repo_stars aggregation the listing path needs.
|
||||
let rows = state.db.list_all_repos_deduped().await?;
|
||||
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
|
||||
let count = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
|
||||
})
|
||||
.count() as i64;
|
||||
Ok::<i64, anyhow::Error>(count)
|
||||
}
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let repos = state
|
||||
.db
|
||||
.list_all_repos()
|
||||
.await
|
||||
.map(|r| r.len() as i64)
|
||||
.unwrap_or(0);
|
||||
let agents = state.db.count_agents().await.unwrap_or(0);
|
||||
let pushes = state.db.count_pushes().await.unwrap_or(0);
|
||||
Json(json!({
|
||||
|
||||
+33
-310
@@ -32,50 +32,19 @@ enum MirrorMode {
|
||||
Promisor,
|
||||
}
|
||||
|
||||
/// The on-disk promisor state of an existing mirror, read from
|
||||
/// `remote.origin.promisor`. Three-valued so a git error is not mistaken for a
|
||||
/// definitive "not a promisor": `git config --get` collapses "key absent" and
|
||||
/// "git failed" into the same non-zero exit otherwise, and treating an errored
|
||||
/// probe as `NotPromisor` would let a transient failure downgrade a still-withheld
|
||||
/// mirror (issue #48).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PromisorProbe {
|
||||
/// `remote.origin.promisor` is `"true"`.
|
||||
Promisor,
|
||||
/// The key is absent (git exit 1) or set to a non-`true` value.
|
||||
NotPromisor,
|
||||
/// The probe itself failed (git spawn error or other non-zero exit).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Decide the mirror mode from the origin's `withheld-paths` response plus, when
|
||||
/// the response is unknown, the existing mirror's on-disk promisor state.
|
||||
/// Decide the mirror mode from the origin's `withheld-paths` response.
|
||||
///
|
||||
/// `Some(non-empty)` → the repo has a private subtree → `Promisor`.
|
||||
/// `Some(empty)` → fully public → `Plain`.
|
||||
/// `None` → the lookup 404'd or failed; the answer is *unknown*, which
|
||||
/// is not the same as "public". For a fresh clone this stays
|
||||
/// `Plain` (a mode-A repo also 404s the git read endpoint, so
|
||||
/// the clone fails and nothing is mirrored — fail-closed at the
|
||||
/// git layer — while a public repo on a peer that predates the
|
||||
/// `withheld-paths` route still gets mirrored). For an existing
|
||||
/// mirror it *biases toward preserving* the promisor state: a
|
||||
/// genuine public transition returns `Some(empty)`, so on
|
||||
/// `None` we cannot distinguish "still withheld, unreachable"
|
||||
/// from "newly public, unreachable" and prefer the recoverable
|
||||
/// choice over destroying the partial-clone config. An
|
||||
/// indeterminate probe (`Unknown`) preserves for the same
|
||||
/// reason (defense-in-depth, #48).
|
||||
fn resolve_mirror_mode(
|
||||
withheld: Option<Vec<String>>,
|
||||
exists: bool,
|
||||
promisor: PromisorProbe,
|
||||
) -> MirrorMode {
|
||||
/// `None` → the lookup 404'd or failed. Attempt a `Plain` mirror; a
|
||||
/// mode-A repo also 404s the git read endpoint, so the clone
|
||||
/// fails and nothing is mirrored (fail-closed at the git
|
||||
/// layer), while a public repo on a peer that predates the
|
||||
/// `withheld-paths` route still gets mirrored.
|
||||
fn classify_mirror(withheld: Option<Vec<String>>) -> MirrorMode {
|
||||
match withheld {
|
||||
Some(globs) if !globs.is_empty() => MirrorMode::Promisor,
|
||||
Some(_) => MirrorMode::Plain,
|
||||
None if exists && promisor != PromisorProbe::NotPromisor => MirrorMode::Promisor,
|
||||
None => MirrorMode::Plain,
|
||||
_ => MirrorMode::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,17 +109,10 @@ async fn run(
|
||||
let machine_id = std::env::var("FLY_MACHINE_ID").ok();
|
||||
// Bound each peer HTTP call (withheld-paths lookup + replica registration)
|
||||
// so a stalled peer cannot hang the worker.
|
||||
// No redirects: peer URLs are attacker-influenceable, so a 3xx to a
|
||||
// loopback/private address must not be followed (SSRF guard, matching the
|
||||
// shared http_client and announce-time validation).
|
||||
// Panic rather than fall back to reqwest::Client::new(): the default
|
||||
// builder follows redirects, which would silently reintroduce the SSRF
|
||||
// vector Policy::none() is here to close.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("failed to build no-redirect sync HTTP client");
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
info!("sync worker started (auto_sync=true)");
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
@@ -168,17 +130,6 @@ async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the origin node's base HTTP URL for a sync item, trimming any trailing
|
||||
/// slash so callers can append `/{path}` cleanly. Returns `None` when no peer
|
||||
/// row matches the item's origin DID. Kept pure (no DB) so the per-batch peer
|
||||
/// resolution can be unit-tested without a database.
|
||||
fn resolve_origin_url(peers: &[crate::db::PeerRecord], node_did: &str) -> Option<String> {
|
||||
peers
|
||||
.iter()
|
||||
.find(|p| p.did == node_did)
|
||||
.map(|p| p.http_url.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
async fn process_batch(
|
||||
db: &Db,
|
||||
config: &Config,
|
||||
@@ -194,27 +145,19 @@ async fn process_batch(
|
||||
}
|
||||
};
|
||||
|
||||
// Nothing queued — the common case on the 30s idle poll. Bail before the
|
||||
// peer lookup so an empty batch costs zero extra DB round-trips.
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the peer table once per batch — every item only needs a lookup.
|
||||
let peers = match db.list_peers().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "failed to list peers for sync");
|
||||
for item in &items {
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for item in items {
|
||||
let origin_url = match resolve_origin_url(&peers, &item.node_did) {
|
||||
Some(url) => url,
|
||||
// Resolve origin node HTTP URL from peer table
|
||||
let peers = match db.list_peers().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "failed to list peers for sync");
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let origin_url = match peers.iter().find(|p| p.did == item.node_did) {
|
||||
Some(p) => p.http_url.trim_end_matches('/').to_string(),
|
||||
None => {
|
||||
warn!(node_did = %item.node_did, repo = %item.repo, "no peer URL found for sync origin — skipping");
|
||||
let _ = db.mark_sync_failed(&item.id).await;
|
||||
@@ -243,30 +186,9 @@ async fn process_batch(
|
||||
let remote_url = format!("{}/{}", origin_url, item.repo);
|
||||
|
||||
let withheld = fetch_withheld(client, &origin_url, owner_short, repo_name).await;
|
||||
let exists = local_path.exists();
|
||||
let lookup_unknown = withheld.is_none();
|
||||
// Only probe the on-disk promisor state when the lookup is unknown and the
|
||||
// repo already exists — the sole case where it changes the resolved mode.
|
||||
let promisor = if lookup_unknown && exists {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
existing_promisor_state(local_str).await
|
||||
} else {
|
||||
PromisorProbe::NotPromisor
|
||||
};
|
||||
let mode = resolve_mirror_mode(withheld, exists, promisor);
|
||||
// Surface the case where an unknown withheld-paths lookup kept (or, on an
|
||||
// indeterminate probe, defensively applied) promisor mode instead of
|
||||
// downgrading to a full clone. Derived from the resolved mode so it cannot
|
||||
// drift from resolve_mirror_mode's preserve branch.
|
||||
if lookup_unknown && mode == MirrorMode::Promisor {
|
||||
warn!(
|
||||
repo = %item.repo,
|
||||
origin = %origin_url,
|
||||
"withheld-paths lookup unavailable; using promisor mirror mode to avoid an unsafe full-clone downgrade"
|
||||
);
|
||||
}
|
||||
let mode = classify_mirror(withheld);
|
||||
|
||||
let result = if exists {
|
||||
let result = if local_path.exists() {
|
||||
fetch_repo(&local_path, &remote_url, mode).await
|
||||
} else {
|
||||
clone_repo(&remote_url, &local_path, mode).await
|
||||
@@ -275,39 +197,6 @@ async fn process_batch(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!(repo = %item.repo, origin = %origin_url, "synced repo from peer");
|
||||
// iCaptcha propagation gate: on a first-time mirror, re-verify the
|
||||
// proof the repo was created with (fetched from the origin) before
|
||||
// admitting it. A node that enforces iCaptcha quarantines a mirror
|
||||
// it cannot validate — kept on disk but hidden from serve/clone and
|
||||
// listings until an operator releases it. Re-syncs of an already
|
||||
// admitted repo keep their prior decision (upsert preserves it).
|
||||
//
|
||||
// "First-time" is keyed on DB-row absence, NOT on-disk presence: a
|
||||
// prior attempt could have cloned to disk but crashed before the
|
||||
// upsert, leaving no DB row. Disk-keying would skip admission on the
|
||||
// retry and admit it unquarantined. On a DB lookup error, default to
|
||||
// running the gate (fail toward quarantine, never silently admit).
|
||||
let is_new_in_db = db
|
||||
.get_repo(owner_short, repo_name)
|
||||
.await
|
||||
.map(|r| r.is_none())
|
||||
.unwrap_or(true);
|
||||
let quarantined = if is_new_in_db {
|
||||
let proof =
|
||||
fetch_icaptcha_proof(client, &origin_url, owner_short, repo_name).await;
|
||||
match crate::icaptcha::admit_mirror(db, proof.as_deref(), owner_short).await {
|
||||
crate::icaptcha::MirrorAdmission::Admit => false,
|
||||
crate::icaptcha::MirrorAdmission::Quarantine(reason) => {
|
||||
warn!(
|
||||
repo = %item.repo, origin = %origin_url, reason,
|
||||
"quarantining mirrored repo: failed iCaptcha propagation gate"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Register in DB so git smart HTTP can serve the mirrored repo
|
||||
let _ = db
|
||||
.upsert_mirror_repo(
|
||||
@@ -315,7 +204,6 @@ async fn process_batch(
|
||||
repo_name,
|
||||
local_path.to_str().unwrap_or(""),
|
||||
machine_id,
|
||||
quarantined,
|
||||
)
|
||||
.await;
|
||||
// Option B2: carry the encrypted withheld-blob envelopes too, so an
|
||||
@@ -359,7 +247,7 @@ async fn process_batch(
|
||||
|
||||
/// Query the origin's anonymous `withheld-paths` endpoint. Returns the withheld
|
||||
/// glob list on a 2xx, or `None` on any non-success / network / parse error
|
||||
/// (treated as "unknown" by `resolve_mirror_mode`).
|
||||
/// (treated as "unknown" by `classify_mirror`).
|
||||
async fn fetch_withheld(
|
||||
client: &reqwest::Client,
|
||||
origin_url: &str,
|
||||
@@ -381,25 +269,6 @@ async fn fetch_withheld(
|
||||
Some(globs)
|
||||
}
|
||||
|
||||
/// Fetch the iCaptcha proof token the origin recorded for this repo, used by the
|
||||
/// mirror-admission gate. Returns `None` on any non-success / network / parse
|
||||
/// error, or when the origin has no proof for the repo (treated as "no proof" by
|
||||
/// `icaptcha::admit_mirror`, which quarantines in enforce mode).
|
||||
async fn fetch_icaptcha_proof(
|
||||
client: &reqwest::Client,
|
||||
origin_url: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/icaptcha-proof");
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("proof")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
/// Signed request path for replica registration on the origin node.
|
||||
fn replica_registration_path(owner: &str, repo: &str) -> String {
|
||||
format!("/api/v1/repos/{owner}/{repo}/replicas")
|
||||
@@ -573,38 +442,6 @@ async fn git_config_get(repo: &str, key: &str) -> Option<String> {
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
/// Probe an existing mirror's `remote.origin.promisor` config as a three-valued
|
||||
/// state. Unlike [`git_config_get`], this distinguishes a definitively-absent key
|
||||
/// from a probe failure so a transient git error cannot be read as "not a
|
||||
/// promisor" and trigger a downgrade (issue #48).
|
||||
///
|
||||
/// `git config --get` exits 0 when the key is set, 1 when it is absent (or the
|
||||
/// directory is not a git repo) — both definitive `NotPromisor` — and other
|
||||
/// non-zero codes (e.g. 128 for a bad path or unreadable config) on error. A spawn
|
||||
/// failure or any non-{0,1} exit is `Unknown`.
|
||||
async fn existing_promisor_state(repo: &str) -> PromisorProbe {
|
||||
let out = match tokio::process::Command::new("git")
|
||||
.args(["-C", repo, "config", "--get", "remote.origin.promisor"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) => out,
|
||||
Err(_) => return PromisorProbe::Unknown,
|
||||
};
|
||||
match out.status.code() {
|
||||
Some(0) => {
|
||||
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if value == "true" {
|
||||
PromisorProbe::Promisor
|
||||
} else {
|
||||
PromisorProbe::NotPromisor
|
||||
}
|
||||
}
|
||||
Some(1) => PromisorProbe::NotPromisor,
|
||||
_ => PromisorProbe::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror-clone a repo from a remote URL into a local bare repo.
|
||||
/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
|
||||
/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
|
||||
@@ -695,66 +532,23 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn resolve_promisor_when_withheld_nonempty() {
|
||||
let mode = resolve_mirror_mode(
|
||||
Some(vec!["/secret/**".to_string()]),
|
||||
true,
|
||||
PromisorProbe::NotPromisor,
|
||||
);
|
||||
fn classify_promisor_when_withheld_nonempty() {
|
||||
let mode = classify_mirror(Some(vec!["/secret/**".to_string()]));
|
||||
assert!(matches!(mode, MirrorMode::Promisor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_plain_when_withheld_empty() {
|
||||
// A genuine public transition returns Some(empty) and still downgrades,
|
||||
// regardless of whether the mirror exists or was a promisor.
|
||||
for exists in [true, false] {
|
||||
for probe in [
|
||||
PromisorProbe::Promisor,
|
||||
PromisorProbe::NotPromisor,
|
||||
PromisorProbe::Unknown,
|
||||
] {
|
||||
let mode = resolve_mirror_mode(Some(vec![]), exists, probe);
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preserves_promisor_on_unknown_lookup_for_existing_mirror() {
|
||||
// Regression for #48: a transient withheld-paths outage (None) must NOT
|
||||
// downgrade a still-withheld promisor mirror to a full clone.
|
||||
let mode = resolve_mirror_mode(None, true, PromisorProbe::Promisor);
|
||||
assert!(matches!(mode, MirrorMode::Promisor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preserves_promisor_on_indeterminate_probe() {
|
||||
// Defense-in-depth (#48): if the config probe itself fails (Unknown) in the
|
||||
// same cycle as a withheld-paths outage, bias toward preserving rather than
|
||||
// firing the destructive downgrade.
|
||||
let mode = resolve_mirror_mode(None, true, PromisorProbe::Unknown);
|
||||
assert!(matches!(mode, MirrorMode::Promisor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_plain_when_unknown_lookup_for_non_promisor_mirror() {
|
||||
// An existing non-promisor mirror is unaffected by the preserve branch.
|
||||
let mode = resolve_mirror_mode(None, true, PromisorProbe::NotPromisor);
|
||||
fn classify_plain_when_withheld_empty() {
|
||||
let mode = classify_mirror(Some(vec![]));
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_plain_when_unknown_lookup_for_fresh_clone() {
|
||||
// No local mirror yet: None stays Plain (fail-closed at the git layer).
|
||||
for probe in [
|
||||
PromisorProbe::Promisor,
|
||||
PromisorProbe::NotPromisor,
|
||||
PromisorProbe::Unknown,
|
||||
] {
|
||||
let mode = resolve_mirror_mode(None, false, probe);
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
fn classify_plain_when_lookup_failed() {
|
||||
// None == 404 / network error / parse failure: attempt a plain mirror
|
||||
// and let the git read endpoint fail-close a mode-A repo.
|
||||
let mode = classify_mirror(None);
|
||||
assert!(matches!(mode, MirrorMode::Plain));
|
||||
}
|
||||
|
||||
fn rb(oid: &str, cid: &str) -> ReplicaBlob {
|
||||
@@ -896,38 +690,6 @@ mod tests {
|
||||
assert_eq!(git_config(&dest, "remote.origin.mirror"), "true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_promisor_for_promisor_mirror() {
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
|
||||
let dest = td.path().join("mirror.git");
|
||||
clone_repo(&url, &dest, MirrorMode::Promisor).await.unwrap();
|
||||
|
||||
let probe = existing_promisor_state(dest.to_str().unwrap()).await;
|
||||
assert_eq!(probe, PromisorProbe::Promisor);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_not_promisor_when_key_absent() {
|
||||
// A plain mirror never sets remote.origin.promisor, so `git config --get`
|
||||
// exits 1 (key absent) — the probe must read that as NotPromisor, never
|
||||
// Unknown (which would wrongly preserve and upgrade a plain mirror).
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
|
||||
let dest = td.path().join("mirror.git");
|
||||
clone_repo(&url, &dest, MirrorMode::Plain).await.unwrap();
|
||||
|
||||
let probe = existing_promisor_state(dest.to_str().unwrap()).await;
|
||||
assert_eq!(probe, PromisorProbe::NotPromisor);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_unknown_on_git_error() {
|
||||
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
|
||||
// probe, not a definitive "not a promisor" — it must map to Unknown so the
|
||||
// caller preserves rather than downgrades (#48 defense-in-depth).
|
||||
let probe = existing_promisor_state("/nonexistent/gitlawb-probe-xyz").await;
|
||||
assert_eq!(probe, PromisorProbe::Unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn promisor_fetch_updates_existing_mirror() {
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
|
||||
@@ -993,43 +755,4 @@ mod tests {
|
||||
register_replica_with_origin(&client, &keypair, Some(""), "http://127.0.0.1:1", "o", "r")
|
||||
.await;
|
||||
}
|
||||
|
||||
fn peer(did: &str, http_url: &str) -> crate::db::PeerRecord {
|
||||
crate::db::PeerRecord {
|
||||
did: did.to_string(),
|
||||
http_url: http_url.to_string(),
|
||||
last_seen: None,
|
||||
last_ping_ok: false,
|
||||
announced_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_origin_url_matches_and_trims_trailing_slash() {
|
||||
let peers = vec![
|
||||
peer("did:key:a", "https://a.example/"),
|
||||
peer("did:key:b", "https://b.example"),
|
||||
];
|
||||
// Trailing slash is trimmed so callers can append `/{path}` cleanly.
|
||||
assert_eq!(
|
||||
resolve_origin_url(&peers, "did:key:a").as_deref(),
|
||||
Some("https://a.example")
|
||||
);
|
||||
// Already-trimmed URLs pass through unchanged.
|
||||
assert_eq!(
|
||||
resolve_origin_url(&peers, "did:key:b").as_deref(),
|
||||
Some("https://b.example")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_origin_url_returns_none_for_unknown_did() {
|
||||
let peers = vec![peer("did:key:a", "https://a.example")];
|
||||
assert_eq!(resolve_origin_url(&peers, "did:key:unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_origin_url_returns_none_for_empty_peer_list() {
|
||||
assert_eq!(resolve_origin_url(&[], "did:key:a"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,6 @@
|
||||
//! `is_public` flag. It performs no I/O so it is exhaustively unit tested.
|
||||
|
||||
use crate::db::VisibilityRule;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
@@ -13,17 +12,6 @@ pub enum Decision {
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// NFC-normalize a glob prefix or path so the matcher compares canonically
|
||||
/// equivalent strings byte-for-byte. Without this, a deny rule authored NFC
|
||||
/// (`é` = U+00E9) byte-compares unequal to a path committed NFD (`e` + U+0301)
|
||||
/// and the blob slips past the rule on the replication path (#101). NFC, not
|
||||
/// NFKC: compatibility folding (ligatures, full-width forms) would merge paths
|
||||
/// the filesystem treats as distinct and over-withhold. Both sides of every
|
||||
/// comparison must pass through this, or the skew just moves.
|
||||
fn nfc(s: &str) -> String {
|
||||
s.nfc().collect()
|
||||
}
|
||||
|
||||
/// True if `caller` is the repo owner (matches full did:key or its short form),
|
||||
/// mirroring the owner-match idiom in `api/protect.rs`.
|
||||
fn is_owner(owner_did: &str, caller: &str) -> bool {
|
||||
@@ -48,17 +36,12 @@ fn glob_matches(glob: &str, path: &str) -> bool {
|
||||
if prefix == "/" {
|
||||
return true;
|
||||
}
|
||||
// Compare in NFC so an NFC rule matches a canonically-equivalent NFD path
|
||||
// (and vice versa). Both sides normalized here — the single matcher seam.
|
||||
let prefix = nfc(prefix);
|
||||
let path = nfc(path);
|
||||
path == prefix || path.starts_with(&format!("{prefix}/"))
|
||||
}
|
||||
|
||||
/// Specificity = length of the (normalized) match prefix; longer is more
|
||||
/// specific. Normalized so ranking stays consistent with `glob_matches`.
|
||||
/// Specificity = length of the match prefix; longer is more specific.
|
||||
fn specificity(glob: &str) -> usize {
|
||||
nfc(glob_prefix(glob)).len()
|
||||
glob_prefix(glob).len()
|
||||
}
|
||||
|
||||
/// Decide whether `caller` (None = anonymous) may read `path` in a repo.
|
||||
@@ -113,20 +96,6 @@ pub fn visibility_check(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `caller` (None = anonymous) may see a repo in a listing — the `"/"`
|
||||
/// visibility decision, shared by every repo-listing surface (REST list,
|
||||
/// federated list, GraphQL `repos`) so they enforce one rule, not three drifting
|
||||
/// copies. Not a bare `is_public` test: a repo can be `is_public=false` with a
|
||||
/// root rule granting readers, or `is_public=true` with a root deny (#97).
|
||||
pub fn listable_at_root(
|
||||
rules: &[VisibilityRule],
|
||||
is_public: bool,
|
||||
owner_did: &str,
|
||||
caller: Option<&str>,
|
||||
) -> bool {
|
||||
visibility_check(rules, is_public, owner_did, caller, "/") == Decision::Allow
|
||||
}
|
||||
|
||||
/// The subtree path globs that `caller` (None = anonymous) may NOT read, given
|
||||
/// the repo's rules. Whole-repo ("/") rules are excluded: a denied whole-repo
|
||||
/// read is handled by the 404 gate before a clone ever starts. Each remaining
|
||||
@@ -402,38 +371,4 @@ mod tests {
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
// #101: a deny rule must withhold a path that denotes the same characters in
|
||||
// a different Unicode normalization form. Without NFC normalization at the
|
||||
// matcher seam, an NFC-authored rule byte-compares unequal to an NFD-stored
|
||||
// path and the blob leaks on the replication path.
|
||||
#[test]
|
||||
fn matcher_withholds_across_nfc_nfd_normalization_skew() {
|
||||
// "é" composed (NFC, U+00E9) in the rule; decomposed (NFD, e + U+0301)
|
||||
// in the committed path.
|
||||
let nfc_rule = "/s\u{00e9}cret/**";
|
||||
let nfd_path = "/se\u{0301}cret/key.pem";
|
||||
let rules = [rule(nfc_rule, VisibilityMode::B, &["did:key:z6MkFriend"])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, nfd_path),
|
||||
Decision::Deny,
|
||||
"NFC-authored deny rule must withhold the canonically-equivalent NFD path"
|
||||
);
|
||||
|
||||
// Mirror: rule authored NFD, path committed NFC.
|
||||
let nfd_rule = "/se\u{0301}cret/**";
|
||||
let nfc_path = "/s\u{00e9}cret/key.pem";
|
||||
let rules2 = [rule(nfd_rule, VisibilityMode::B, &["did:key:z6MkFriend"])];
|
||||
assert_eq!(
|
||||
visibility_check(&rules2, true, OWNER, None, nfc_path),
|
||||
Decision::Deny,
|
||||
"NFD-authored deny rule must withhold the canonically-equivalent NFC path"
|
||||
);
|
||||
|
||||
// A genuinely different path is still allowed (no over-withholding).
|
||||
assert_eq!(
|
||||
visibility_check(&rules, true, OWNER, None, "/public/x.txt"),
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "gl"
|
||||
description = "The gitlawb CLI — identity management, node control, MCP server"
|
||||
version = "0.4.0" # x-release-please-version
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
+64
-838
@@ -234,22 +234,6 @@ struct WithheldPathsResponse {
|
||||
reinclude: Vec<String>,
|
||||
}
|
||||
|
||||
/// The node's `/encrypted-blobs` 200 body. Same rationale as `WithheldPathsResponse`:
|
||||
/// deserializing into a typed struct makes a missing or mistyped `blobs` field (or a
|
||||
/// blob entry missing its `oid`) a hard error instead of silently becoming "nothing
|
||||
/// to recover", which would mask a server schema regression behind a clone that
|
||||
/// quietly omits authorized files. Unknown server fields (e.g. `size`) are ignored
|
||||
/// by serde, so the response may carry extra keys without breaking.
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedBlobsResponse {
|
||||
blobs: Vec<EncryptedBlobEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedBlobEntry {
|
||||
oid: String,
|
||||
}
|
||||
|
||||
/// After the base clone, recover encrypted blobs the caller is authorized for
|
||||
/// that are missing locally: fetch the envelope, decrypt with the caller's key,
|
||||
/// install as a loose object. Returns the repo-relative paths recovered.
|
||||
@@ -275,8 +259,13 @@ async fn recover_encrypted_blobs(
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Ok(vec![]),
|
||||
};
|
||||
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
|
||||
if body.blobs.is_empty() {
|
||||
let body: serde_json::Value = resp.json().await.context("parsing encrypted-blobs")?;
|
||||
let blobs = body
|
||||
.get("blobs")
|
||||
.and_then(|b| b.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if blobs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
@@ -294,8 +283,10 @@ async fn recover_encrypted_blobs(
|
||||
}
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
for entry in body.blobs {
|
||||
let oid = entry.oid.as_str();
|
||||
for entry in blobs {
|
||||
let Some(oid) = entry.get("oid").and_then(|o| o.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
// Skip if already present locally.
|
||||
let present = Command::new("git")
|
||||
.args(["-C", dest_str, "cat-file", "-e", oid])
|
||||
@@ -363,151 +354,43 @@ struct Manifest {
|
||||
blobs: Vec<ManifestBlob>,
|
||||
}
|
||||
|
||||
/// One discovered manifest transaction: its Arweave id and block height. The
|
||||
/// height is `None` only when the anchor is not yet mined (no `block`), in which
|
||||
/// case it is the most recent and sorts as newest in the merge tie-break. A mined
|
||||
/// anchor is always `Some(_)` (0 if the gateway reported an unreadable height) so
|
||||
/// a malformed height can never masquerade as pending/newest.
|
||||
struct TxRef {
|
||||
id: String,
|
||||
height: Option<u64>,
|
||||
}
|
||||
|
||||
/// Edges requested per Arweave GraphQL page (`first:` in the discovery query) and
|
||||
/// the per-page bound enforced when parsing a response. The `first:N` literal in
|
||||
/// `ARWEAVE_DISCOVERY_QUERY` MUST equal this constant; `query_first_matches_page_size`
|
||||
/// pins that invariant at test time (the query keeps a literal because `format!`-ing
|
||||
/// the brace-heavy GraphQL body would be error-prone).
|
||||
const ARWEAVE_PAGE_SIZE: usize = 100;
|
||||
|
||||
/// Paginated discovery query: manifests tagged for this repo, newest first, with
|
||||
/// `pageInfo` for cursor pagination and `block{height}` for the recency tie-break.
|
||||
const ARWEAVE_DISCOVERY_QUERY: &str = r#"query($repo:String!,$cursor:String){transactions(tags:[{name:"App-Name",values:["gitlawb"]},{name:"Schema",values:["gitlawb/encrypted-manifest/v1"]},{name:"Repo",values:[$repo]}],first:100,after:$cursor){pageInfo{hasNextPage endCursor}edges{cursor node{id block{height}}}}}"#;
|
||||
|
||||
/// A parsed page of an Arweave GraphQL `transactions` response: the edge refs
|
||||
/// plus pagination state. `has_next` is `None` when the response omitted
|
||||
/// `pageInfo` entirely (an older or partial gateway), which the discovery loop
|
||||
/// treats as terminal but flags when the page was full (possible truncation).
|
||||
struct TxPage {
|
||||
refs: Vec<TxRef>,
|
||||
has_next: Option<bool>,
|
||||
end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse one Arweave GraphQL `transactions` page: each edge's tx id and block
|
||||
/// height, plus `pageInfo` for cursor pagination. Hand-walks the `Value` (the
|
||||
/// envelope nests `data.transactions.{edges,pageInfo}`) rather than deriving a
|
||||
/// wrapper struct, matching the surrounding style.
|
||||
///
|
||||
/// At most `ARWEAVE_PAGE_SIZE` edges are taken from a single page: the query asks
|
||||
/// for `first:100`, so a response with more edges is a misbehaving (or hostile)
|
||||
/// gateway. Bounding here caps the per-page `TxRef` vec and the downstream per-id
|
||||
/// fetch loop so the `MAX_TX_IDS` budget can't be defeated by one oversized page.
|
||||
/// (It does not bound the response body itself: `reqwest` buffers and deserializes
|
||||
/// the whole body before this runs — an unbounded-body concern shared by every
|
||||
/// gateway read in this file, addressed separately if it ever matters.)
|
||||
fn parse_tx_page(v: &serde_json::Value) -> TxPage {
|
||||
let txns = v.get("data").and_then(|d| d.get("transactions"));
|
||||
let refs: Vec<TxRef> = txns
|
||||
/// Extract transaction ids from an Arweave GraphQL `transactions` response.
|
||||
fn parse_tx_ids(v: &serde_json::Value) -> Vec<String> {
|
||||
v.get("data")
|
||||
.and_then(|d| d.get("transactions"))
|
||||
.and_then(|t| t.get("edges"))
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|edges| {
|
||||
edges
|
||||
.iter()
|
||||
.take(ARWEAVE_PAGE_SIZE)
|
||||
.filter_map(|edge| {
|
||||
let node = edge.get("node")?;
|
||||
let id = node.get("id").and_then(|i| i.as_str())?.to_string();
|
||||
// Distinguish "not yet mined" from "mined but height unreadable".
|
||||
// Only a genuinely absent/null `block` is pending (`None`,
|
||||
// ranked newest). A present `block` means the anchor IS mined, so
|
||||
// it must not be promoted to newest: parse the height permissively
|
||||
// (number or numeric string, guarding a gateway that stringifies
|
||||
// large ints), and an unreadable height ranks lowest (0) rather
|
||||
// than collapsing to `None`/newest.
|
||||
let height = match node.get("block") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(b) => Some(
|
||||
b.get("height")
|
||||
.and_then(|h| {
|
||||
h.as_u64()
|
||||
.or_else(|| h.as_str().and_then(|s| s.parse::<u64>().ok()))
|
||||
})
|
||||
.unwrap_or(0),
|
||||
),
|
||||
};
|
||||
Some(TxRef { id, height })
|
||||
edge.get("node")
|
||||
.and_then(|n| n.get("id"))
|
||||
.and_then(|i| i.as_str())
|
||||
.map(String::from)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let page_info = txns.and_then(|t| t.get("pageInfo"));
|
||||
let has_next = page_info
|
||||
.and_then(|p| p.get("hasNextPage"))
|
||||
.and_then(|h| h.as_bool());
|
||||
let end_cursor = page_info
|
||||
.and_then(|p| p.get("endCursor"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(String::from);
|
||||
TxPage {
|
||||
refs,
|
||||
has_next,
|
||||
end_cursor,
|
||||
}
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Order a block height so that a not-yet-mined anchor (`None`) sorts above any
|
||||
/// mined one, and mined anchors order by ascending height. Higher rank == newer.
|
||||
fn height_rank(h: Option<u64>) -> (u8, u64) {
|
||||
match h {
|
||||
None => (1, 0), // pending anchor: newest
|
||||
Some(v) => (0, v), // mined: by block height
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge per-push manifests into a single `oid -> cid` map. A later seal of an
|
||||
/// oid overrides an earlier one, ranked by the composite key
|
||||
/// `(timestamp, block height, cid)`:
|
||||
/// - manifest `timestamp` (RFC3339, lexicographic) is primary — the unchanged
|
||||
/// latest-wins behavior;
|
||||
/// - Arweave block height breaks equal-timestamp ties by anchor recency (a
|
||||
/// pending anchor counts as newest), preserving the "newest wins" that
|
||||
/// single-page `HEIGHT_DESC` discovery used to provide by insertion order.
|
||||
/// The height is gateway-reported (chain-derived but not independently
|
||||
/// verified here), so it removes order-dependence and honest-node clock skew,
|
||||
/// not trust in the gateway — discovery already assumes an honest gateway;
|
||||
/// - cid is a final lexicographic tiebreak so two anchors in the *same* block
|
||||
/// still resolve deterministically (arbitrary, not recency-ordered, within a
|
||||
/// block), independent of discovery order.
|
||||
fn merge_manifests(
|
||||
manifests: Vec<(Manifest, Option<u64>)>,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
// oid -> (cid, timestamp, height_rank)
|
||||
let mut best: std::collections::HashMap<String, (String, String, (u8, u64))> =
|
||||
std::collections::HashMap::new();
|
||||
for (m, height) in manifests {
|
||||
let cand_rank = height_rank(height);
|
||||
/// Merge per-push manifests into a single `oid -> cid` map, latest-wins by the
|
||||
/// manifest `timestamp` (RFC3339, compared lexicographically; a later push that
|
||||
/// re-sealed a blob overrides the earlier entry).
|
||||
fn merge_manifests(manifests: Vec<Manifest>) -> std::collections::HashMap<String, String> {
|
||||
let mut best: std::collections::HashMap<String, (String, String)> =
|
||||
std::collections::HashMap::new(); // oid -> (cid, timestamp)
|
||||
for m in manifests {
|
||||
for b in m.blobs {
|
||||
let keep_incumbent = match best.get(&b.oid) {
|
||||
// Incumbent wins iff its composite key is >= the candidate's. The
|
||||
// `>=` (not `>`) keeps the incumbent only on a full tie, where the
|
||||
// entries are identical anyway. A strictly-greater candidate key
|
||||
// (newer timestamp, or equal timestamp + higher block, or equal
|
||||
// both + larger cid) replaces it.
|
||||
Some((cur_cid, cur_ts, cur_rank)) => {
|
||||
(cur_ts.as_str(), cur_rank, cur_cid.as_str())
|
||||
>= (m.timestamp.as_str(), &cand_rank, b.cid.as_str())
|
||||
match best.get(&b.oid) {
|
||||
Some((_, ts)) if ts.as_str() >= m.timestamp.as_str() => {}
|
||||
_ => {
|
||||
best.insert(b.oid, (b.cid, m.timestamp.clone()));
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !keep_incumbent {
|
||||
best.insert(b.oid, (b.cid, m.timestamp.clone(), cand_rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.into_iter()
|
||||
.map(|(oid, (cid, _, _))| (oid, cid))
|
||||
.collect()
|
||||
best.into_iter().map(|(oid, (cid, _))| (oid, cid)).collect()
|
||||
}
|
||||
|
||||
/// Option B3 fallback recovery, with no dependency on a gitlawb node API. Query
|
||||
@@ -541,111 +424,36 @@ async fn recover_from_arweave(
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
// 1. Discover manifest transactions via paginated Arweave GraphQL. Collect
|
||||
// (tx id, block height) refs across every page, deduping by id and bounding
|
||||
// the loop so a hostile or buggy gateway cannot grow the set, the page
|
||||
// count, or the downstream per-id fetch loop without limit. Best-effort: a
|
||||
// page-1 failure (gateway down/unconfigured) stays silent as before; a
|
||||
// failure or cap reached *after* discovery began warns, since the recovery
|
||||
// is then knowingly partial.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
const MAX_TX_IDS: usize = 10_000;
|
||||
let query = ARWEAVE_DISCOVERY_QUERY;
|
||||
let mut after: Option<String> = None;
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut refs: Vec<TxRef> = Vec::new();
|
||||
let mut prev_cursor: Option<String> = None;
|
||||
let mut page_num = 0usize;
|
||||
loop {
|
||||
if page_num >= MAX_PAGES {
|
||||
eprintln!(
|
||||
"warning: Arweave manifest discovery hit the {MAX_PAGES}-page cap; \
|
||||
some authorized files may not be recovered"
|
||||
);
|
||||
break;
|
||||
}
|
||||
page_num += 1;
|
||||
let gql_body =
|
||||
serde_json::json!({ "query": query, "variables": { "repo": slug, "cursor": after } });
|
||||
// Any gateway failure (non-2xx, send error, unparseable body) ends the loop.
|
||||
let response = match client
|
||||
.post(format!("{ag}/graphql"))
|
||||
.json(&gql_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r.json::<serde_json::Value>().await.ok(),
|
||||
_ => None,
|
||||
};
|
||||
let value = match response {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// A failure *after* discovery began means recovery is knowingly
|
||||
// partial, so warn. A page-1 failure is the benign unconfigured/
|
||||
// unreachable-gateway case and stays silent, exactly as before.
|
||||
if !refs.is_empty() {
|
||||
eprintln!(
|
||||
"warning: Arweave manifest discovery was interrupted by a gateway \
|
||||
error; some authorized files may not be recovered"
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
let page = parse_tx_page(&value);
|
||||
let full = page.refs.len() >= ARWEAVE_PAGE_SIZE;
|
||||
for r in page.refs {
|
||||
if seen.insert(r.id.clone()) {
|
||||
refs.push(r);
|
||||
}
|
||||
}
|
||||
// Cap checked at the page boundary so a partial page is never half-merged.
|
||||
if refs.len() >= MAX_TX_IDS {
|
||||
eprintln!(
|
||||
"warning: Arweave manifest discovery hit the {MAX_TX_IDS}-transaction cap; \
|
||||
some authorized files may not be recovered"
|
||||
);
|
||||
break;
|
||||
}
|
||||
match page.has_next {
|
||||
Some(true) => match page.end_cursor {
|
||||
// Advance only on a fresh cursor; a missing or repeating cursor is
|
||||
// a degenerate gateway response and must not loop forever.
|
||||
Some(c) if Some(&c) != prev_cursor.as_ref() => {
|
||||
prev_cursor = Some(c.clone());
|
||||
after = Some(c);
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
Some(false) => break,
|
||||
// No pageInfo: terminal. A *full* page without it may be a silently
|
||||
// truncated gateway, so flag that; a short page is genuinely the end.
|
||||
None => {
|
||||
if full {
|
||||
eprintln!(
|
||||
"warning: Arweave gateway returned a full page without pagination \
|
||||
metadata; discovery may be incomplete and some authorized files may \
|
||||
not be recovered"
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if refs.is_empty() {
|
||||
// 1. Discover manifest transaction ids via Arweave GraphQL.
|
||||
let query = r#"query($repo:String!){transactions(tags:[{name:"App-Name",values:["gitlawb"]},{name:"Schema",values:["gitlawb/encrypted-manifest/v1"]},{name:"Repo",values:[$repo]}],first:100){edges{node{id}}}}"#;
|
||||
let gql_body = serde_json::json!({ "query": query, "variables": { "repo": slug } });
|
||||
let resp = match client
|
||||
.post(format!("{ag}/graphql"))
|
||||
.json(&gql_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Ok(vec![]),
|
||||
};
|
||||
let gql: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let tx_ids = parse_tx_ids(&gql);
|
||||
if tx_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// 2. Fetch each manifest body, pair it with its anchor's block height, then
|
||||
// merge (latest-wins by timestamp, tie-broken by block height then cid).
|
||||
let mut manifests: Vec<(Manifest, Option<u64>)> = Vec::new();
|
||||
for r in refs {
|
||||
let m = match client.get(format!("{ag}/{}", r.id)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => resp,
|
||||
// 2. Fetch and parse each manifest body, then merge latest-wins per oid.
|
||||
let mut manifests = Vec::new();
|
||||
for tx in tx_ids {
|
||||
let m = match client.get(format!("{ag}/{tx}")).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => continue,
|
||||
};
|
||||
if let Ok(parsed) = m.json::<Manifest>().await {
|
||||
manifests.push((parsed, r.height));
|
||||
manifests.push(parsed);
|
||||
}
|
||||
}
|
||||
let oid_cid = merge_manifests(manifests);
|
||||
@@ -739,14 +547,7 @@ pub async fn run(args: CloneArgs) -> Result<()> {
|
||||
// fallback for any authorized blobs the node could not supply.
|
||||
let mut paths = recover_encrypted_blobs(&args.node, &owner, &name, &dest, &keypair)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
// A node-recovery error must not abort the clone (the Arweave
|
||||
// fallback still runs), but the strict /encrypted-blobs parse now
|
||||
// fails closed on schema drift, so surface it rather than letting
|
||||
// `.unwrap_or_default()` silently swallow it into "no paths".
|
||||
eprintln!("warning: encrypted-blobs recovery failed: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
.unwrap_or_default();
|
||||
let from_arweave = recover_from_arweave(
|
||||
&args.arweave_gateway,
|
||||
&args.ipfs_gateway,
|
||||
@@ -987,113 +788,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_blobs_response_is_schema_strict() {
|
||||
// Happy path: a well-formed body parses and exposes the oids.
|
||||
let ok: EncryptedBlobsResponse =
|
||||
serde_json::from_str(r#"{"blobs":[{"oid":"abc"}]}"#).unwrap();
|
||||
assert_eq!(ok.blobs.len(), 1);
|
||||
assert_eq!(ok.blobs[0].oid, "abc");
|
||||
|
||||
// Unknown server fields on an entry are tolerated (no deny_unknown_fields).
|
||||
let extra: EncryptedBlobsResponse =
|
||||
serde_json::from_str(r#"{"blobs":[{"oid":"abc","size":42}]}"#).unwrap();
|
||||
assert_eq!(extra.blobs[0].oid, "abc");
|
||||
|
||||
// Empty list is valid and distinct from a missing field.
|
||||
let empty: EncryptedBlobsResponse = serde_json::from_str(r#"{"blobs":[]}"#).unwrap();
|
||||
assert!(empty.blobs.is_empty());
|
||||
|
||||
// Schema drift is a hard error, not a silent "nothing to recover":
|
||||
// missing `blobs`, wrong-typed `blobs`, and an entry missing `oid`.
|
||||
assert!(serde_json::from_str::<EncryptedBlobsResponse>(r#"{"items":[]}"#).is_err());
|
||||
assert!(serde_json::from_str::<EncryptedBlobsResponse>(r#"{"blobs":"nope"}"#).is_err());
|
||||
assert!(
|
||||
serde_json::from_str::<EncryptedBlobsResponse>(r#"{"blobs":[{"size":1}]}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_extracts_node_ids_and_heights() {
|
||||
fn parse_tx_ids_extracts_node_ids() {
|
||||
let v: serde_json::Value = serde_json::from_str(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TX1","block":{"height":100}}},{"node":{"id":"TX2","block":null}}]}}}"#,
|
||||
r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}},{"node":{"id":"TX2"}}]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
let ids: Vec<&str> = page.refs.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["TX1", "TX2"]);
|
||||
assert_eq!(page.refs[0].height, Some(100));
|
||||
assert_eq!(page.refs[1].height, None); // null block -> pending -> None
|
||||
assert_eq!(page.has_next, Some(true));
|
||||
assert_eq!(page.end_cursor.as_deref(), Some("C1"));
|
||||
assert_eq!(parse_tx_ids(&v), vec!["TX1".to_string(), "TX2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_empty_on_no_edges() {
|
||||
fn parse_tx_ids_empty_on_no_edges() {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(r#"{"data":{"transactions":{"edges":[]}}}"#).unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
assert!(page.refs.is_empty());
|
||||
// No pageInfo present -> has_next None (the loop treats it as terminal).
|
||||
assert_eq!(page.has_next, None);
|
||||
assert_eq!(page.end_cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_missing_block_is_none() {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(r#"{"data":{"transactions":{"edges":[{"node":{"id":"TX1"}}]}}}"#)
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
assert_eq!(page.refs[0].height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_height_parsing_is_robust() {
|
||||
// A gateway that serializes height as a JSON string (a habit to dodge
|
||||
// 53-bit float precision) must still parse to the real height, not
|
||||
// collapse to None and masquerade as a pending/newest anchor.
|
||||
let v: serde_json::Value = serde_json::from_str(
|
||||
r#"{"data":{"transactions":{"edges":[
|
||||
{"node":{"id":"TXSTR","block":{"height":"12345"}}},
|
||||
{"node":{"id":"TXBAD","block":{"height":"not-a-number"}}},
|
||||
{"node":{"id":"TXNOH","block":{}}}
|
||||
]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
// Numeric string parses to the real height.
|
||||
assert_eq!(page.refs[0].height, Some(12345));
|
||||
// A present block with an unreadable/absent height is mined-but-unknown:
|
||||
// it ranks lowest (Some(0)), NOT None/newest, so it can't steal a tie.
|
||||
assert_eq!(page.refs[1].height, Some(0));
|
||||
assert_eq!(page.refs[2].height, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tx_page_bounds_edges_per_page() {
|
||||
// A hostile gateway returning more than ARWEAVE_PAGE_SIZE edges on one
|
||||
// page must not defeat the MAX_TX_IDS budget: parse caps per-page refs so
|
||||
// the downstream fetch loop stays bounded.
|
||||
let edges: String = (0..ARWEAVE_PAGE_SIZE + 50)
|
||||
.map(|i| format!(r#"{{"node":{{"id":"TX{i}"}}}}"#))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let v: serde_json::Value = serde_json::from_str(&format!(
|
||||
r#"{{"data":{{"transactions":{{"edges":[{edges}]}}}}}}"#
|
||||
))
|
||||
.unwrap();
|
||||
let page = parse_tx_page(&v);
|
||||
assert_eq!(page.refs.len(), ARWEAVE_PAGE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_first_matches_page_size() {
|
||||
// The `first:N` the discovery query requests must equal the per-page bound
|
||||
// enforced in parse_tx_page, or full-page detection and the cap drift.
|
||||
assert!(
|
||||
ARWEAVE_DISCOVERY_QUERY.contains(&format!("first:{ARWEAVE_PAGE_SIZE}")),
|
||||
"discovery query `first:` must equal ARWEAVE_PAGE_SIZE ({ARWEAVE_PAGE_SIZE})"
|
||||
);
|
||||
assert!(parse_tx_ids(&v).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1130,7 +837,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
};
|
||||
let merged = merge_manifests(vec![(older, Some(1)), (newer, Some(2))]);
|
||||
let merged = merge_manifests(vec![older, newer]);
|
||||
assert_eq!(merged.get("o1").map(String::as_str), Some("cidNEW"));
|
||||
assert_eq!(merged.get("o2").map(String::as_str), Some("cid2"));
|
||||
}
|
||||
@@ -1152,109 +859,10 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
// Newer first, older second: newer must still win.
|
||||
let merged = merge_manifests(vec![(newer, Some(2)), (older, Some(1))]);
|
||||
let merged = merge_manifests(vec![newer, older]);
|
||||
assert_eq!(merged.get("o1").map(String::as_str), Some("cidNEW"));
|
||||
}
|
||||
|
||||
// Helper: a single-blob manifest for the merge tie-break tests.
|
||||
fn manifest_with(ts: &str, oid: &str, cid: &str) -> Manifest {
|
||||
Manifest {
|
||||
timestamp: ts.to_string(),
|
||||
blobs: vec![ManifestBlob {
|
||||
oid: oid.to_string(),
|
||||
cid: cid.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_equal_timestamp_breaks_by_block_height() {
|
||||
// Same timestamp, different block heights: the higher block (newer
|
||||
// anchor) must win, regardless of input order.
|
||||
let lo = manifest_with("2026-06-11T00:00:00Z", "o1", "cidLO");
|
||||
let hi = manifest_with("2026-06-11T00:00:00Z", "o1", "cidHI");
|
||||
let a = merge_manifests(vec![
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidLO"),
|
||||
Some(10),
|
||||
),
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidHI"),
|
||||
Some(20),
|
||||
),
|
||||
]);
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidHI"));
|
||||
// Reversed order, same result.
|
||||
let b = merge_manifests(vec![(hi, Some(20)), (lo, Some(10))]);
|
||||
assert_eq!(b.get("o1").map(String::as_str), Some("cidHI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_pending_anchor_is_newest_on_tie() {
|
||||
// Same timestamp; one mined anchor, one pending (None height). The
|
||||
// pending one is the most recent and must win, order-independently.
|
||||
let mined = manifest_with("2026-06-11T00:00:00Z", "o1", "cidMINED");
|
||||
let pending = manifest_with("2026-06-11T00:00:00Z", "o1", "cidPENDING");
|
||||
let a = merge_manifests(vec![(mined, Some(99)), (pending, None)]);
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidPENDING"));
|
||||
let mined2 = manifest_with("2026-06-11T00:00:00Z", "o1", "cidMINED");
|
||||
let pending2 = manifest_with("2026-06-11T00:00:00Z", "o1", "cidPENDING");
|
||||
let b = merge_manifests(vec![(pending2, None), (mined2, Some(99))]);
|
||||
assert_eq!(b.get("o1").map(String::as_str), Some("cidPENDING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_same_block_breaks_by_cid_deterministically() {
|
||||
// Equal timestamp AND equal height: the cid tiebreak makes the result
|
||||
// deterministic and order-independent (lexicographically-larger cid wins).
|
||||
let a = merge_manifests(vec![
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidAAA"),
|
||||
Some(5),
|
||||
),
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidBBB"),
|
||||
Some(5),
|
||||
),
|
||||
]);
|
||||
let b = merge_manifests(vec![
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidBBB"),
|
||||
Some(5),
|
||||
),
|
||||
(
|
||||
manifest_with("2026-06-11T00:00:00Z", "o1", "cidAAA"),
|
||||
Some(5),
|
||||
),
|
||||
]);
|
||||
assert_eq!(a.get("o1"), b.get("o1"));
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidBBB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_newer_timestamp_wins_regardless_of_height() {
|
||||
// Timestamp is primary: a newer timestamp wins even with a lower block
|
||||
// height than the older-timestamp manifest.
|
||||
let older_hi_block = manifest_with("2026-06-10T00:00:00Z", "o1", "cidOLD");
|
||||
let newer_lo_block = manifest_with("2026-06-11T00:00:00Z", "o1", "cidNEW");
|
||||
let merged = merge_manifests(vec![
|
||||
(older_hi_block, Some(9999)),
|
||||
(newer_lo_block, Some(1)),
|
||||
]);
|
||||
assert_eq!(merged.get("o1").map(String::as_str), Some("cidNEW"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_empty_timestamp_loses_to_timestamped() {
|
||||
// A manifest with a missing/empty timestamp (serde default) sorts lowest
|
||||
// and loses to any timestamped manifest for the same oid. Pins the
|
||||
// empty-sorts-lowest precondition as a deliberate decision.
|
||||
let untimestamped = manifest_with("", "o1", "cidEMPTY");
|
||||
let timestamped = manifest_with("2026-06-11T00:00:00Z", "o1", "cidTS");
|
||||
let a = merge_manifests(vec![(untimestamped, Some(50)), (timestamped, Some(1))]);
|
||||
assert_eq!(a.get("o1").map(String::as_str), Some("cidTS"));
|
||||
}
|
||||
|
||||
/// Read-path end-to-end over a mocked Arweave + IPFS gateway: discover the
|
||||
/// manifest via GraphQL, fetch it, fetch the envelope, decrypt with the
|
||||
/// caller's key, and install the previously-withheld blob.
|
||||
@@ -1353,388 +961,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Discovery must follow cursor pagination: a blob whose manifest is anchored
|
||||
/// only on the SECOND page is still recovered. If the loop stopped at page 1
|
||||
/// (the old `first:100` behavior), this blob would be lost.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_paginates_to_later_page() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Page 1 (cursor null): hasNextPage=true, endCursor C1, an empty manifest
|
||||
// anchor that does NOT carry the withheld blob.
|
||||
let _gql_p1 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":null}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TXP1","block":{"height":10}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
// Page 2 (cursor "C1"): hasNextPage=false, the anchor with the blob.
|
||||
let _gql_p2 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":"C1"}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":false,"endCursor":"C2"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _tx_p1 = server
|
||||
.mock("GET", "/TXP1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"timestamp":"2026-06-10T00:00:00Z","blobs":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let _tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["secret/b.txt".to_string()],
|
||||
"blob anchored only on page 2 must be recovered via pagination"
|
||||
);
|
||||
}
|
||||
|
||||
/// Best-effort on mid-pagination failure: page 1 already yielded a usable
|
||||
/// anchor; page 2 fails. Recovery still installs page 1's blob (partial Ok),
|
||||
/// never aborts the clone.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_partial_on_midpagination_failure() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Page 1 (cursor null): the blob anchor, hasNextPage=true.
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let _gql_p1 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":null}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
// Page 2 (cursor "C1"): gateway error.
|
||||
let _gql_p2 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":"C1"}}"#.into(),
|
||||
))
|
||||
.with_status(500)
|
||||
.create_async()
|
||||
.await;
|
||||
let _tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.expect("mid-pagination failure must not abort recovery");
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec!["secret/b.txt".to_string()],
|
||||
"page-1 blob must still be recovered when page 2 fails"
|
||||
);
|
||||
}
|
||||
|
||||
/// A gateway that reports `hasNextPage=true` but never advances the cursor
|
||||
/// (returns the same `endCursor` every page) must not loop forever: the
|
||||
/// degenerate-cursor guard terminates the loop and recovery returns based on
|
||||
/// what was seen. The test completing at all proves no hang.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_terminates_on_nonadvancing_cursor() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Every GraphQL POST returns hasNextPage=true with the SAME endCursor.
|
||||
// The guard fires on the second page (cursor repeats prev_cursor) and
|
||||
// breaks, so discovery makes exactly two POSTs: page 1 (cursor null) records
|
||||
// STUCK, page 2 (cursor STUCK) sees the repeat and stops. We assert that
|
||||
// exact count below — a regression in the advance logic that loops extra
|
||||
// times in the happy path fails the expect(2). (MAX_PAGES is the hard
|
||||
// infinite-loop backstop independent of this guard.)
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let gql = server
|
||||
.mock("POST", "/graphql")
|
||||
.expect(2)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"STUCK"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.expect("non-advancing cursor must terminate, not hang or error");
|
||||
assert_eq!(paths, vec!["secret/b.txt".to_string()]);
|
||||
gql.assert_async().await; // exactly two discovery POSTs, then the guard breaks
|
||||
}
|
||||
|
||||
/// A tx id repeated across pages must be fetched only once: the cross-page
|
||||
/// dedup (R8) bounds the downstream per-id manifest fetch loop.
|
||||
#[tokio::test]
|
||||
async fn recover_from_arweave_dedups_tx_ids_across_pages() {
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
let bare = url.strip_prefix("file://").unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", bare, "config", "uploadpack.allowFilter", "true"])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
|
||||
let oid = {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
dest.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"HEAD:secret/b.txt",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
std::fs::remove_dir_all(url.strip_prefix("file://").unwrap()).unwrap();
|
||||
|
||||
let reader = Keypair::generate();
|
||||
let envelope = seal_blob(b"SECRET\n", &[reader.verifying_key()]).unwrap();
|
||||
let cid = "testcid123";
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// Page 1 and page 2 both list TX1 (cursor advances); the dedup must fetch
|
||||
// the /TX1 manifest exactly once.
|
||||
let _gql_p1 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":null}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":true,"endCursor":"C1"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _gql_p2 = server
|
||||
.mock("POST", "/graphql")
|
||||
.match_body(mockito::Matcher::PartialJsonString(
|
||||
r#"{"variables":{"cursor":"C1"}}"#.into(),
|
||||
))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":{"transactions":{"pageInfo":{"hasNextPage":false,"endCursor":"C2"},"edges":[{"node":{"id":"TX1","block":{"height":20}}}]}}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let manifest_body = serde_json::json!({
|
||||
"timestamp": "2026-06-11T00:00:00Z",
|
||||
"blobs": [{ "oid": oid, "cid": cid, "recipients": [] }],
|
||||
})
|
||||
.to_string();
|
||||
let tx1 = server
|
||||
.mock("GET", "/TX1")
|
||||
.expect(1) // dedup: fetched once despite appearing on both pages
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(manifest_body)
|
||||
.create_async()
|
||||
.await;
|
||||
let _blob = server
|
||||
.mock("GET", format!("/ipfs/{cid}").as_str())
|
||||
.with_status(200)
|
||||
.with_body(envelope)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let paths = recover_from_arweave(
|
||||
&server.url(),
|
||||
&server.url(),
|
||||
"alice",
|
||||
"myrepo",
|
||||
&dest,
|
||||
&reader,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(paths, vec!["secret/b.txt".to_string()]);
|
||||
tx1.assert_async().await; // exactly one /TX1 fetch
|
||||
}
|
||||
|
||||
/// A caller who is not a recipient cannot decrypt the envelope, so nothing is
|
||||
/// recovered even though the manifest and envelope are reachable.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -19,14 +19,6 @@ services:
|
||||
|
||||
node:
|
||||
build: .
|
||||
# Reap reparented orphan processes so git's grandchildren (e.g. pack-objects
|
||||
# left behind when a served clone disconnects) can't accumulate as zombies
|
||||
# and exhaust the PID table (#53). The image also runs under tini, so this is
|
||||
# belt-and-suspenders for the quickstart.
|
||||
init: true
|
||||
# Cap the process table so a regression fails loudly instead of taking the
|
||||
# host down. Sits well above normal concurrent git fan-out.
|
||||
pids_limit: 1024
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -141,34 +141,6 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
|
||||
|
||||
---
|
||||
|
||||
## Hardening: owner-only push
|
||||
|
||||
By default the node authenticates every `git-receive-pack` push (a valid RFC 9421
|
||||
`did:key` signature) but does **not** check that the pusher owns the repo, except
|
||||
on branches that are explicitly protected. Because `did:key` is self-certifying,
|
||||
any party can generate a key, derive its DID, sign, and push to an unprotected
|
||||
branch — authentication is not authorization.
|
||||
|
||||
To require the authenticated pusher to be the repo owner on **every** branch, set:
|
||||
|
||||
```bash
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
```
|
||||
|
||||
- **Default `false`** — preserves current behavior so live nodes are unaffected by
|
||||
an upgrade. Turn it on once you're ready for owner-only writes.
|
||||
- **When `true`** — a push whose authenticated DID is not the repo owner is
|
||||
rejected (HTTP 403) before any ref update is applied. The owner is matched in
|
||||
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
- **Caution: this blocks every non-owner pusher, including your own delegated and
|
||||
CI agents.** Push authorization is owner-only today — a UCAN `git/push`
|
||||
capability is verified but not yet honored for authorization, so delegated keys
|
||||
cannot push while this is on. Don't enable it until every identity that pushes
|
||||
to your repos is the owner, or you'll lock out your own automation. Scoped
|
||||
collaborator / UCAN-delegated push rights are a planned follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Operational checklist
|
||||
|
||||
| Concern | Recommendation |
|
||||
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
gitlawb installer for Windows - downloads pre-built binaries from GitHub Releases.
|
||||
.EXAMPLE
|
||||
irm https://gitlawb.com/install.ps1 | iex
|
||||
.EXAMPLE
|
||||
& ([scriptblock]::Create((irm https://gitlawb.com/install.ps1))) -Version v0.3.9
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Version = "latest"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Windows PowerShell 5.1 may default to TLS 1.0; GitHub requires TLS 1.2+.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$Repo = if ($env:GITLAWB_RELEASE_REPO) { $env:GITLAWB_RELEASE_REPO } else { "Gitlawb/node" }
|
||||
$InstallDir = if ($env:GITLAWB_INSTALL_DIR) { $env:GITLAWB_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\gitlawb" }
|
||||
|
||||
$arch = if ($env:PROCESSOR_ARCHITEW6432) {
|
||||
$env:PROCESSOR_ARCHITEW6432
|
||||
} else {
|
||||
$env:PROCESSOR_ARCHITECTURE
|
||||
}
|
||||
if ($arch -ne "AMD64") {
|
||||
throw "Unsupported architecture: $arch. Only x64 Windows binaries are published. Use WSL for arm64."
|
||||
}
|
||||
$target = "x86_64-pc-windows-msvc"
|
||||
|
||||
if ($Version -eq "latest") {
|
||||
Write-Host "Fetching latest release version..."
|
||||
$rel = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing -Headers @{ "User-Agent" = "gitlawb-installer" }
|
||||
$tag = $rel.tag_name
|
||||
} elseif ($Version.StartsWith("v")) {
|
||||
$tag = $Version
|
||||
} else {
|
||||
$tag = "v$Version"
|
||||
}
|
||||
$ver = $tag.TrimStart("v")
|
||||
|
||||
$archive = "gitlawb-node-$ver-$target.zip"
|
||||
$url = "https://github.com/$Repo/releases/download/$tag/$archive"
|
||||
|
||||
Write-Host "Installing gitlawb $tag for windows/x64"
|
||||
Write-Host " Archive: $archive"
|
||||
Write-Host " Into: $InstallDir"
|
||||
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("gitlawb-" + [System.Guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
try {
|
||||
$zipPath = Join-Path $tmp $archive
|
||||
Write-Host "Downloading..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -Headers @{ "User-Agent" = "gitlawb-installer" }
|
||||
|
||||
# Verify checksum. Every published asset has a matching .sha256, so fail closed
|
||||
# if it cannot be fetched rather than installing an unverified binary.
|
||||
$sumPath = "$zipPath.sha256"
|
||||
Invoke-WebRequest -Uri "$url.sha256" -OutFile $sumPath -UseBasicParsing -Headers @{ "User-Agent" = "gitlawb-installer" }
|
||||
$expected = ((Get-Content $sumPath -Raw).Trim() -split '\s+')[0].ToLower()
|
||||
$actual = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($expected -ne $actual) {
|
||||
throw "checksum mismatch! expected $expected got $actual"
|
||||
}
|
||||
Write-Host " checksum OK"
|
||||
|
||||
Write-Host "Extracting..."
|
||||
$extract = Join-Path $tmp "extract"
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extract -Force
|
||||
$pkgDir = Get-ChildItem -Path $extract -Directory | Select-Object -First 1
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
# The Windows archive always ships the two CLI binaries; the daemon is optional.
|
||||
$requiredBins = @("gl.exe", "git-remote-gitlawb.exe")
|
||||
$optionalBins = @("gitlawb-node.exe")
|
||||
$missing = $requiredBins | Where-Object {
|
||||
-not (Test-Path (Join-Path $pkgDir.FullName $_))
|
||||
}
|
||||
if ($missing.Count -gt 0) {
|
||||
throw "archive is missing required binaries: $($missing -join ', ')"
|
||||
}
|
||||
|
||||
$installed = @()
|
||||
foreach ($bin in $requiredBins + $optionalBins) {
|
||||
$src = Join-Path $pkgDir.FullName $bin
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination (Join-Path $InstallDir $bin) -Force
|
||||
$installed += $bin
|
||||
}
|
||||
}
|
||||
|
||||
# Drop binaries no longer shipped (e.g. an optional one removed in a new release)
|
||||
# so an upgrade in place never leaves a stale, mixed-version install behind.
|
||||
foreach ($bin in $requiredBins + $optionalBins) {
|
||||
if ($installed -notcontains $bin) {
|
||||
$dst = Join-Path $InstallDir $bin
|
||||
if (Test-Path $dst) { Remove-Item -Path $dst -Force }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Installed gitlawb $tag"
|
||||
foreach ($bin in $installed) { Write-Host " $bin -> $InstallDir\$bin" }
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Add to the user PATH if missing.
|
||||
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if (($userPath -split ';') -notcontains $InstallDir) {
|
||||
$newPath = if ([string]::IsNullOrEmpty($userPath)) { $InstallDir } else { "$userPath;$InstallDir" }
|
||||
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
|
||||
Write-Host ""
|
||||
Write-Host "Added $InstallDir to your PATH. Restart your terminal, then run:"
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "Run:"
|
||||
}
|
||||
Write-Host " gl doctor"
|
||||
Write-Host " gl quickstart"
|
||||
Write-Host ""
|
||||
Write-Host "Docs: https://docs.gitlawb.com"
|
||||
@@ -1,15 +0,0 @@
|
||||
node_modules/
|
||||
|
||||
# Platform binaries — laid in by the release workflow at publish time, not committed.
|
||||
packages/gl-darwin-arm64/gl
|
||||
packages/gl-darwin-arm64/git-remote-gitlawb
|
||||
packages/gl-darwin-x64/gl
|
||||
packages/gl-darwin-x64/git-remote-gitlawb
|
||||
packages/gl-linux-arm64/gl
|
||||
packages/gl-linux-arm64/git-remote-gitlawb
|
||||
packages/gl-linux-x64/gl
|
||||
packages/gl-linux-x64/git-remote-gitlawb
|
||||
|
||||
# Wrapper bin placeholders are overwritten by postinstall.
|
||||
packages/gl/bin/gl
|
||||
packages/gl/bin/git-remote-gitlawb
|
||||
@@ -1,37 +0,0 @@
|
||||
# npm distribution for the gitlawb CLI
|
||||
|
||||
This directory packages the `gl` and `git-remote-gitlawb` binaries as the
|
||||
[`@gitlawb/gl`](https://www.npmjs.com/package/@gitlawb/gl) npm package.
|
||||
|
||||
It uses the **optionalDependencies platform-package** pattern (the same one
|
||||
esbuild/swc/turbo use): the wrapper `@gitlawb/gl` declares four per-platform
|
||||
packages as `optionalDependencies`, each gated by `os`/`cpu`. npm installs only
|
||||
the one matching the host, and the wrapper's `postinstall` (`install.js`) copies
|
||||
the binary out of it into `bin/`. No binaries are downloaded at install time.
|
||||
|
||||
```text
|
||||
packages/
|
||||
gl/ @gitlawb/gl wrapper (postinstall, bin shims)
|
||||
gl-darwin-arm64/ @gitlawb/gl-darwin-arm64 aarch64-apple-darwin
|
||||
gl-darwin-x64/ @gitlawb/gl-darwin-x64 x86_64-apple-darwin
|
||||
gl-linux-arm64/ @gitlawb/gl-linux-arm64 aarch64-unknown-linux-musl
|
||||
gl-linux-x64/ @gitlawb/gl-linux-x64 x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
Publishing is **automated** by `.github/workflows/release.yml` (the `npm-publish`
|
||||
job) on every release-please release. That job:
|
||||
|
||||
1. Downloads the four unix release tarballs from the `Gitlawb/node` GitHub release.
|
||||
2. Lays `gl` + `git-remote-gitlawb` into each platform package.
|
||||
3. Rewrites every `package.json` `version` (and the wrapper's
|
||||
`optionalDependencies` pins) to the release version.
|
||||
4. Publishes the four platform packages first, then the wrapper, with
|
||||
`npm publish --provenance --access public`.
|
||||
|
||||
The committed `version` fields here are placeholders; the workflow is the single
|
||||
source of truth and always uses the release tag. There is no manual publish step.
|
||||
|
||||
Windows is intentionally not published to npm — the wrapper points Windows users
|
||||
at the curl/PowerShell installer instead.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@gitlawb/gl-darwin-arm64",
|
||||
"version": "0.3.9",
|
||||
"description": "Gitlawb CLI binary for macOS Apple Silicon (arm64)",
|
||||
"license": "MIT",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gitlawb/node",
|
||||
"directory": "npm/packages/gl-darwin-arm64"
|
||||
},
|
||||
"files": [
|
||||
"gl",
|
||||
"git-remote-gitlawb"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@gitlawb/gl-darwin-x64",
|
||||
"version": "0.3.9",
|
||||
"description": "Gitlawb CLI binary for macOS Intel (x64)",
|
||||
"license": "MIT",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gitlawb/node",
|
||||
"directory": "npm/packages/gl-darwin-x64"
|
||||
},
|
||||
"files": [
|
||||
"gl",
|
||||
"git-remote-gitlawb"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@gitlawb/gl-linux-arm64",
|
||||
"version": "0.3.9",
|
||||
"description": "Gitlawb CLI binary for Linux arm64",
|
||||
"license": "MIT",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gitlawb/node",
|
||||
"directory": "npm/packages/gl-linux-arm64"
|
||||
},
|
||||
"files": [
|
||||
"gl",
|
||||
"git-remote-gitlawb"
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@gitlawb/gl-linux-x64",
|
||||
"version": "0.3.9",
|
||||
"description": "Gitlawb CLI binary for Linux x64",
|
||||
"license": "MIT",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gitlawb/node",
|
||||
"directory": "npm/packages/gl-linux-x64"
|
||||
},
|
||||
"files": [
|
||||
"gl",
|
||||
"git-remote-gitlawb"
|
||||
]
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
# @gitlawb/gl
|
||||
|
||||
The [gitlawb](https://gitlawb.com) CLI — decentralized git for AI agents and developers.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# macOS / Linux / WSL
|
||||
npm install -g @gitlawb/gl
|
||||
```
|
||||
|
||||
Also works with yarn, pnpm, and bun:
|
||||
|
||||
```bash
|
||||
# macOS / Linux / WSL
|
||||
yarn global add @gitlawb/gl
|
||||
pnpm add -g @gitlawb/gl
|
||||
bun add -g @gitlawb/gl
|
||||
```
|
||||
|
||||
Native Windows is not supported via npm — use the PowerShell installer below.
|
||||
|
||||
### Other install methods
|
||||
|
||||
```bash
|
||||
# Homebrew
|
||||
brew install gitlawb/tap/gl
|
||||
|
||||
# curl (macOS / Linux)
|
||||
curl -sSf https://gitlawb.com/install.sh | sh
|
||||
|
||||
# PowerShell (Windows)
|
||||
irm https://gitlawb.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
gl identity new
|
||||
gl register
|
||||
gl doctor
|
||||
gl repo create my-project --description "My first gitlawb repo"
|
||||
git remote add gitlawb gitlawb://my-project
|
||||
git push gitlawb main
|
||||
```
|
||||
|
||||
## What's included
|
||||
|
||||
- **`gl`** — the main CLI for identity, repos, PRs, bounties, and agents
|
||||
- **`git-remote-gitlawb`** — git remote helper for `gitlawb://` URLs
|
||||
|
||||
## Supported platforms
|
||||
|
||||
| Platform | Architecture | Package |
|
||||
|----------|-------------|---------|
|
||||
| macOS | Apple Silicon (arm64) | `@gitlawb/gl-darwin-arm64` |
|
||||
| macOS | Intel (x64) | `@gitlawb/gl-darwin-x64` |
|
||||
| Linux | arm64 | `@gitlawb/gl-linux-arm64` |
|
||||
| Linux | x64 | `@gitlawb/gl-linux-x64` |
|
||||
|
||||
Windows is supported via the PowerShell installer or WSL.
|
||||
|
||||
## Links
|
||||
|
||||
- Docs: https://docs.gitlawb.com
|
||||
- Site: https://gitlawb.com
|
||||
- Source: https://github.com/Gitlawb/node
|
||||
Whitespace-only changes.
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { existsSync, copyFileSync, chmodSync, mkdirSync } = require("fs");
|
||||
const { join, dirname } = require("path");
|
||||
|
||||
const PLATFORM_PACKAGES = {
|
||||
"darwin-arm64": "@gitlawb/gl-darwin-arm64",
|
||||
"darwin-x64": "@gitlawb/gl-darwin-x64",
|
||||
"linux-arm64": "@gitlawb/gl-linux-arm64",
|
||||
"linux-x64": "@gitlawb/gl-linux-x64",
|
||||
};
|
||||
|
||||
const BINARIES = ["gl", "git-remote-gitlawb"];
|
||||
|
||||
function getPlatformKey() {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
if (arch !== "arm64" && arch !== "x64") {
|
||||
throw new Error(
|
||||
`Unsupported architecture: ${arch}. Gitlawb CLI supports x64 and arm64.`
|
||||
);
|
||||
}
|
||||
|
||||
if (platform !== "darwin" && platform !== "linux") {
|
||||
throw new Error(
|
||||
`Unsupported platform: ${platform}. Gitlawb CLI supports macOS and Linux.\n` +
|
||||
`For Windows, use WSL or the installer: https://gitlawb.com/install.ps1`
|
||||
);
|
||||
}
|
||||
|
||||
return `${platform}-${arch}`;
|
||||
}
|
||||
|
||||
function findPlatformPackage(packageName) {
|
||||
try {
|
||||
const pkgJson = require.resolve(`${packageName}/package.json`);
|
||||
return dirname(pkgJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function install() {
|
||||
const platformKey = getPlatformKey();
|
||||
const packageName = PLATFORM_PACKAGES[platformKey];
|
||||
|
||||
if (!packageName) {
|
||||
console.error(`No binary package available for ${platformKey}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageDir = findPlatformPackage(packageName);
|
||||
|
||||
if (!packageDir) {
|
||||
// On a supported platform the matching optional dependency must be present;
|
||||
// fail loudly rather than leave @gitlawb/gl installed without its binaries.
|
||||
console.error(
|
||||
`@gitlawb/gl: Platform package ${packageName} not found.\n` +
|
||||
`This can happen if optional dependencies were skipped.\n` +
|
||||
`Install manually: curl -sSf https://gitlawb.com/install.sh | sh`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binDir = join(__dirname, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
|
||||
for (const binary of BINARIES) {
|
||||
const src = join(packageDir, binary);
|
||||
const dest = join(binDir, binary);
|
||||
|
||||
if (!existsSync(src)) {
|
||||
throw new Error(`Binary ${binary} not found in ${packageName}`);
|
||||
}
|
||||
|
||||
copyFileSync(src, dest);
|
||||
chmodSync(dest, 0o755);
|
||||
}
|
||||
|
||||
console.log(`@gitlawb/gl: Installed gitlawb CLI for ${platformKey}`);
|
||||
}
|
||||
|
||||
try {
|
||||
install();
|
||||
} catch (err) {
|
||||
console.error(`@gitlawb/gl: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "@gitlawb/gl",
|
||||
"version": "0.3.9",
|
||||
"description": "Gitlawb CLI — decentralized git for AI agents and developers",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"gl": "bin/gl",
|
||||
"git-remote-gitlawb": "bin/git-remote-gitlawb"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node install.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Gitlawb/node",
|
||||
"directory": "npm/packages/gl"
|
||||
},
|
||||
"keywords": [
|
||||
"gitlawb",
|
||||
"git",
|
||||
"decentralized",
|
||||
"cli",
|
||||
"ai-agents",
|
||||
"did",
|
||||
"ucan"
|
||||
],
|
||||
"files": [
|
||||
"bin",
|
||||
"install.js",
|
||||
"README.md"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"@gitlawb/gl-darwin-arm64": "0.3.9",
|
||||
"@gitlawb/gl-darwin-x64": "0.3.9",
|
||||
"@gitlawb/gl-linux-arm64": "0.3.9",
|
||||
"@gitlawb/gl-linux-x64": "0.3.9"
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,11 @@
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "simple",
|
||||
"release-type": "rust",
|
||||
"package-name": "gitlawb-node",
|
||||
"bump-minor-pre-major": true,
|
||||
"include-v-in-tag": true,
|
||||
"include-component-in-tag": false,
|
||||
"extra-files": [
|
||||
{ "type": "generic", "path": "crates/gitlawb-core/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/gitlawb-node/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/gl/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/git-remote-gitlawb/Cargo.toml" },
|
||||
{ "type": "generic", "path": "crates/gitlawb-attest/Cargo.toml" }
|
||||
]
|
||||
"include-component-in-tag": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user