mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
180
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0763ac2a | ||
|
|
8652b86d33 | ||
|
|
a4e2b9f3f3 | ||
|
|
61033af261 | ||
|
|
5445dda3f5 | ||
|
|
d3074a6c86 | ||
|
|
612d9d2dbe | ||
|
|
806f9027c0 | ||
|
|
ad71f79e6d | ||
|
|
90b8294c2b | ||
|
|
db3681e48d | ||
|
|
7be9f08b3d | ||
|
|
6663fed064 | ||
|
|
ac31868205 | ||
|
|
af17831429 | ||
|
|
04263dbd49 | ||
|
|
fdf716dd25 | ||
|
|
c926e1ede5 | ||
|
|
5f445152a8 | ||
|
|
cd810b3375 | ||
|
|
acb47145be | ||
|
|
b4016dce9d | ||
|
|
ee9bc93198 | ||
|
|
9836c3a21b | ||
|
|
13b565bcac | ||
|
|
0e42eb3343 | ||
|
|
c83cbc56e0 | ||
|
|
1964f88378 | ||
|
|
99b2b9a166 | ||
|
|
9418e315bf | ||
|
|
c70b1df42d | ||
|
|
811ad504bc | ||
|
|
e2560ee46c | ||
|
|
75e3e03c09 | ||
|
|
fbc5868bd1 | ||
|
|
457be11d72 | ||
|
|
6e64d0d0a7 | ||
|
|
2e664b7885 | ||
|
|
9aa4c17aef | ||
|
|
27b0495a6f | ||
|
|
6456bfb0ba | ||
|
|
3ca94739ed | ||
|
|
c587a40a50 | ||
|
|
9f00e24aec | ||
|
|
51d82ded57 | ||
|
|
a49e04ddcd | ||
|
|
ebfe89d1c9 | ||
|
|
5091f2dba0 | ||
|
|
f2f99c6684 | ||
|
|
b605b2269a | ||
|
|
52a8f010b7 | ||
|
|
b8eff30fc2 | ||
|
|
111cff7e20 | ||
|
|
e9da517fde | ||
|
|
9ca94c4d47 | ||
|
|
c0ad500b55 | ||
|
|
e9d6aadd58 | ||
|
|
b484a24955 | ||
|
|
32006b26a2 | ||
|
|
b75aa2f1da | ||
|
|
c681af70db | ||
|
|
1ba9ee505a | ||
|
|
3fa38dce4e | ||
|
|
c71bba5cd1 | ||
|
|
e1e6e1cf83 | ||
|
|
e3df51d76c | ||
|
|
0765c42fef | ||
|
|
94c61f7942 | ||
|
|
b0a2ed8308 | ||
|
|
cb42a4830d | ||
|
|
2d88937fba | ||
|
|
e14c2d3f3f | ||
|
|
4465beb0c2 | ||
|
|
82efd60229 | ||
|
|
e8f193a5df | ||
|
|
07d76ba556 | ||
|
|
05e434d906 | ||
|
|
7593540f62 | ||
|
|
ccc2cf46f2 | ||
|
|
1fddc81b7c | ||
|
|
62d73c4165 | ||
|
|
2f622ae5fa | ||
|
|
f9506b466e | ||
|
|
d5ec4695f4 | ||
|
|
98d728c325 | ||
|
|
b6877a6598 | ||
|
|
b07d41ea97 | ||
|
|
c7427e943d | ||
|
|
69a62e51c2 | ||
|
|
ee8d7c03b2 | ||
|
|
82fec97a97 | ||
|
|
9be5eac3a2 | ||
|
|
6b2ec93de1 | ||
|
|
ad7c2b2b83 | ||
|
|
c98b503f90 | ||
|
|
3ec8cbf1e1 | ||
|
|
6b5e5bc7ae | ||
|
|
dfcaa22b23 | ||
|
|
2109d08e2c | ||
|
|
c5e5099def | ||
|
|
cd67718f49 | ||
|
|
6bafaa6dc5 | ||
|
|
26bc3f6987 | ||
|
|
d00d89ae5b | ||
|
|
67ad2b876c | ||
|
|
6cff5286b4 | ||
|
|
5f63f28215 | ||
|
|
06388ec26a | ||
|
|
2df6ff9d30 | ||
|
|
2620e973e3 | ||
|
|
e3a20317d5 | ||
|
|
c296e23678 | ||
|
|
20d6848846 | ||
|
|
4891db3889 | ||
|
|
6f36fc07b8 | ||
|
|
563c456803 | ||
|
|
6c95592d18 | ||
|
|
466a550915 | ||
|
|
09a0cb23b9 | ||
|
|
cd4680ebeb | ||
|
|
8d96cab4f7 | ||
|
|
0190fbbbf6 | ||
|
|
86341d195d | ||
|
|
8b9ceec25e | ||
|
|
12bfb1b3c8 | ||
|
|
788a8686c1 | ||
|
|
174f25a206 | ||
|
|
3f08d9936b | ||
|
|
adc20f9eff | ||
|
|
828dd279a2 | ||
|
|
6ae316cc88 | ||
|
|
6809201daa | ||
|
|
3e1e9045e4 | ||
|
|
1960f6b28f | ||
|
|
abc9ad03ac | ||
|
|
338ff83584 | ||
|
|
636d3abe17 | ||
|
|
2f6611a30f | ||
|
|
3e8e333aa0 | ||
|
|
f28fa02236 | ||
|
|
1af4fdf485 | ||
|
|
3d880cd56f | ||
|
|
0a15e763d2 | ||
|
|
2153b0b7a6 | ||
|
|
96fcfdb1ca | ||
|
|
c9f43b0105 | ||
|
|
97d37fd541 | ||
|
|
a8cc33a185 | ||
|
|
2202b0097f | ||
|
|
ff492b4521 | ||
|
|
2d4105e479 | ||
|
|
803d83efdb | ||
|
|
7533b74f79 | ||
|
|
abdc775770 | ||
|
|
e37ea7fec6 | ||
|
|
197c4e1cf1 | ||
|
|
a5d2264cc0 | ||
|
|
5ff7af84fa | ||
|
|
8680d0f9d6 | ||
|
|
c03c9af8fa | ||
|
|
7a8b6c4974 | ||
|
|
7a99d0f27c | ||
|
|
c7df6cad20 | ||
|
|
b61a1bd46f | ||
|
|
e365a57b51 | ||
|
|
4a59056a90 | ||
|
|
3a401eb079 | ||
|
|
61b383019f | ||
|
|
a2217bff27 | ||
|
|
6abaf1d7ed | ||
|
|
9e6e832ec5 | ||
|
|
09a339745e | ||
|
|
2ce4da9cc5 | ||
|
|
927e4d0cfb | ||
|
|
924bccd8e5 | ||
|
|
a9f208a378 | ||
|
|
491978a809 | ||
|
|
0b056f3ad0 | ||
|
|
d366fc6b97 | ||
|
|
b12c6bc328 |
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# cargo-audit configuration.
|
||||
#
|
||||
# cargo-audit reads its config from .cargo/audit.toml (relative to the working
|
||||
# directory) or ~/.cargo/audit.toml. It does NOT read a repo-root audit.toml,
|
||||
# and there is no CLI flag to point at a config file (`-f` is the lockfile path).
|
||||
# This file lives at .cargo/audit.toml for that reason.
|
||||
#
|
||||
# The audit job stays hard-fail: only the advisories explicitly listed below are
|
||||
# suppressed, so any NEW advisory still breaks CI. Each entry below has no
|
||||
# available upstream fix; everything fixable is fixed in Cargo.lock instead.
|
||||
#
|
||||
# Tracking: the hickory ignores are reachable accepted risk. A scheduled re-check
|
||||
# (.github/workflows/audit-schedule.yml) runs without these ignores and fails if
|
||||
# the lockfile moves off the pinned vulnerable versions while the ignores remain.
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# hickory-proto 0.25.2 (pulled by libp2p-dns 0.44 -> hickory-resolver 0.25).
|
||||
# REACHABLE: the node wraps its QUIC transport in the system DNS resolver
|
||||
# (crates/gitlawb-node/src/p2p/mod.rs:238), so a malicious DNS response can
|
||||
# trigger these DoS paths. No fix is available: the latest libp2p-dns on
|
||||
# crates.io is 0.44 and pins hickory 0.25; the fix requires hickory >=0.26.1.
|
||||
# REMOVE both once libp2p-dns ships a release using hickory 0.26 (verified
|
||||
# 2026-06-21: cargo update -p hickory-proto locks 0 packages; still 0.25.2).
|
||||
# Tracking: https://github.com/Gitlawb/node/issues/76
|
||||
"RUSTSEC-2026-0118", # hickory NSEC3 closest-encloser unbounded loop
|
||||
"RUSTSEC-2026-0119", # hickory O(n^2) name-compression CPU exhaustion
|
||||
|
||||
# rsa 0.9.10 (Marvin timing sidechannel). Present in Cargo.lock because sqlx
|
||||
# resolves sqlx-mysql unconditionally, and sqlx-mysql depends on rsa. cargo
|
||||
# audit scans the lockfile, so it flags rsa even though it is NOT linked into
|
||||
# our binary: sqlx is built postgres-only (crates/gitlawb-node/Cargo.toml),
|
||||
# so the MySQL RSA auth path is never compiled and there is no RSA oracle to
|
||||
# attack. No upstream fix exists regardless. REMOVE this ignore if sqlx is
|
||||
# ever built with the `mysql` feature, or any other consumer of rsa enters
|
||||
# the build, at which point it becomes a real reachable advisory.
|
||||
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
|
||||
]
|
||||
+97
-2
@@ -21,6 +21,23 @@ GITLAWB_REPOS_DIR=/data/repos
|
||||
# When using the bundled docker-compose, this is wired automatically.
|
||||
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
|
||||
# ── Database pool & startup resilience ────────────────────────────────────
|
||||
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
|
||||
# connections open lazily. Size against the DB server's max_connections,
|
||||
# remembering admin tooling opens its own pool.
|
||||
GITLAWB_DB_MAX_CONNECTIONS=20
|
||||
# Seconds a request waits for a pool connection before failing with 503.
|
||||
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
|
||||
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
|
||||
# generous enough for a peer instance to finish migrating (the migration
|
||||
# advisory lock is held across a deploy); on expiry the attempt is retried.
|
||||
GITLAWB_DB_CONNECT_TIMEOUT_SECS=60
|
||||
# Startup retry backoff while the database is unavailable: first delay and
|
||||
# ceiling, in seconds. The node stays up serving 503s (with a truthful
|
||||
# /ready) instead of crash-looping.
|
||||
GITLAWB_DB_RETRY_INITIAL_SECS=5
|
||||
GITLAWB_DB_RETRY_MAX_SECS=60
|
||||
|
||||
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
|
||||
# Get a JWT at https://app.pinata.cloud/developers/api-keys
|
||||
GITLAWB_PINATA_JWT=
|
||||
@@ -63,13 +80,91 @@ POSTGRES_PASSWORD=changeme
|
||||
|
||||
# ── P2P networking ────────────────────────────────────────────────────────
|
||||
GITLAWB_P2P_PORT=7546
|
||||
# Comma-separated multiaddrs of bootstrap peers
|
||||
# Comma-separated HTTP peer URLs to announce to on startup.
|
||||
# The binary also ships with bootstrap-peers.json; set
|
||||
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
|
||||
GITLAWB_BOOTSTRAP_PEERS=
|
||||
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
|
||||
# Require signatures on peer announce/sync write routes. Keep false during
|
||||
# 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=
|
||||
|
||||
# ── Access control ────────────────────────────────────────────────────────
|
||||
# Set to false to require auth on all read endpoints
|
||||
# Reserved for private-read mode. Public/private repo read enforcement is not
|
||||
# wired in the current live release; do not rely on this for private repositories.
|
||||
GITLAWB_PUBLIC_READ=true
|
||||
|
||||
# Maximum git smart-HTTP pack request size, in bytes.
|
||||
GITLAWB_MAX_PACK_BYTES=2147483648
|
||||
|
||||
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
|
||||
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
|
||||
# worker and, on push, the repo write lock. Does NOT cover the info/refs
|
||||
# advertisement or the withheld-blob path, which remain unbounded. Default 600.
|
||||
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
|
||||
|
||||
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
|
||||
# Max receive-pack requests (info/refs advertisement + push POST) per client
|
||||
# IP per hour. 0 disables. Default 600.
|
||||
GITLAWB_PUSH_RATE_LIMIT=600
|
||||
|
||||
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
|
||||
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
|
||||
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
|
||||
# limit alone is bypassed by a did:key farm (one throwaway identity per repo),
|
||||
# which is how spam-repo floods slip past it and the iCaptcha gate; the per-IP
|
||||
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
|
||||
# the client IP. 0 disables. Default 120.
|
||||
GITLAWB_CREATE_RATE_LIMIT=120
|
||||
|
||||
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
|
||||
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
|
||||
# known peers and run at higher frequency, so a generous bucket. Separate from
|
||||
# the trigger bucket so an unsigned notify flood can't drain trigger's quota.
|
||||
# 0 disables. Default 600.
|
||||
GITLAWB_PEER_WRITE_RATE_LIMIT=600
|
||||
# /api/v1/sync/trigger requires a signature and fans out to every peer per call,
|
||||
# so it gets a tight bucket. 0 disables. Default 60.
|
||||
GITLAWB_SYNC_TRIGGER_RATE_LIMIT=60
|
||||
|
||||
# Which forwarded header the edge is trusted to set, used to resolve the real
|
||||
# client IP for the push limiter. One of:
|
||||
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
|
||||
# fly — behind Fly's edge (trust Fly-Client-IP).
|
||||
# x-forwarded-for — behind a single reverse proxy like Caddy/NGINX (trust the
|
||||
# rightmost X-Forwarded-For hop).
|
||||
# Only set this when a proxy you control actually fronts the node; trusting a
|
||||
# forwarded header on a directly-exposed node lets clients spoof the key.
|
||||
GITLAWB_TRUSTED_PROXY=
|
||||
|
||||
# ── 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
|
||||
@@ -0,0 +1,11 @@
|
||||
# Ownership for CI, release, and security-sensitive infrastructure.
|
||||
# A PR that edits these paths requires review from a code owner once
|
||||
# branch protection's "Require review from Code Owners" is enabled.
|
||||
|
||||
/.github/ @beardthelion
|
||||
/Dockerfile @beardthelion
|
||||
/Dockerfile.bins @beardthelion
|
||||
/docker-compose.yml @beardthelion
|
||||
/release-please-config.json @beardthelion
|
||||
/.release-please-manifest.json @beardthelion
|
||||
/SECURITY.md @beardthelion
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Bug report
|
||||
description: Report a defect in a Gitlawb crate or node behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the report. Please fill in the fields below so we can reproduce the issue.
|
||||
For security vulnerabilities, do NOT open an issue — follow SECURITY.md instead.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear description of the bug.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: crate
|
||||
attributes:
|
||||
label: Affected crate
|
||||
options:
|
||||
- gitlawb-node
|
||||
- gl
|
||||
- git-remote-gitlawb
|
||||
- gitlawb-core
|
||||
- not sure / other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version or commit
|
||||
description: Output of the binary's `--version`, or the git commit you built.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, Rust version (`rustc --version`), and anything else relevant.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste logs or errors. This will be rendered as code, no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Gitlawb/node/blob/main/SECURITY.md
|
||||
about: Please report security issues privately per our security policy, not as a public issue.
|
||||
- name: Questions & discussion
|
||||
url: https://github.com/Gitlawb/node/discussions
|
||||
about: For usage questions and open-ended discussion.
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement or new capability
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / use case
|
||||
description: What are you trying to do that's hard or impossible today?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: protocol
|
||||
attributes:
|
||||
label: Does this touch the protocol?
|
||||
description: Identity, signatures, UCAN, ref certs, or P2P wire formats. Protocol changes need an issue discussion before any PR.
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
- "Not sure"
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,63 @@
|
||||
<!--
|
||||
Thanks for contributing to Gitlawb. Keep PRs focused on one change.
|
||||
Protocol-level changes (identity, signatures, wire format) should start
|
||||
as an issue before code — see CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences: what changes and why it matters. -->
|
||||
|
||||
|
||||
|
||||
## Motivation & context
|
||||
|
||||
<!-- What problem does this solve? Link prior discussion if any. -->
|
||||
Closes #
|
||||
|
||||
## Kind of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Security fix
|
||||
- [ ] Docs
|
||||
- [ ] Tests / CI
|
||||
- [ ] Refactor (no behavior change)
|
||||
- [ ] Breaking or protocol change (issue required first)
|
||||
|
||||
## What changed
|
||||
|
||||
<!-- Bullet the concrete changes. Note the crate(s) touched:
|
||||
gitlawb-core / gitlawb-node / gl / git-remote-gitlawb -->
|
||||
|
||||
-
|
||||
|
||||
## How a reviewer can verify
|
||||
|
||||
<!-- Commands to run, or repro steps for a bug plus proof it's fixed. -->
|
||||
|
||||
```sh
|
||||
|
||||
```
|
||||
|
||||
## Before you request review
|
||||
|
||||
- [ ] Scope is one logical change; no unrelated churn
|
||||
- [ ] `cargo test --workspace` passes locally
|
||||
- [ ] New behavior is covered by tests (required for fixes)
|
||||
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` are clean
|
||||
- [ ] Commit titles use Conventional Commits (`feat(...)`, `fix(...)`, `docs(...)`)
|
||||
- [ ] Docs / `.env.example` updated if behavior or config changed (or N/A)
|
||||
- [ ] Checked existing PRs so this isn't a duplicate
|
||||
|
||||
## Protocol & signing impact
|
||||
|
||||
<!-- Delete this block if your change doesn't touch the protocol. -->
|
||||
|
||||
- [ ] Touches DID / `did:key`, Ed25519 / RFC 9421 signatures, UCAN, ref certs, or P2P wire formats
|
||||
- [ ] Discussed in an issue before implementation
|
||||
- [ ] Backward-compatible with existing nodes and previously signed history
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything out of scope, follow-ups, or known limitations. Optional. -->
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Scheduled Audit
|
||||
|
||||
# Self-expiring suppression check (companion to the PR-blocking audit gate in
|
||||
# pr-checks.yml). Runs weekly. It re-runs cargo audit WITHOUT the .cargo/audit.toml
|
||||
# ignore list to surface the full advisory set for visibility, and it hard-fails
|
||||
# if the lockfile has moved off the pinned vulnerable versions while the ignores
|
||||
# are still present, i.e. the upstream fix landed but the ignore was not dropped.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Mondays 06:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
scheduled-audit:
|
||||
name: scheduled audit (no ignores)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: audit-schedule
|
||||
|
||||
- name: Install cargo-audit (floating; drift canary)
|
||||
# Deliberately UNPINNED, unlike the blocking gate in pr-checks.yml. This
|
||||
# job is cron-only (weekly); it is never pull_request- or push-triggered,
|
||||
# so it NEVER gates a merge no matter what it does. Floating the scanner
|
||||
# here is the drift canary: if a newer cargo-audit regresses, at worst
|
||||
# this weekly run reds (the install step below is unguarded) or its
|
||||
# visibility report changes, and that is exactly the signal that latest
|
||||
# has moved and the pinned gate should not be bumped yet. The scan result
|
||||
# itself never fails the run (the "Full audit report" step is `|| true`
|
||||
# and the only other hard-fail is a Cargo.lock grep). The point is that
|
||||
# drift surfaces here without ever blocking a PR. Do NOT pin to match
|
||||
# pr-checks.yml.
|
||||
run: cargo install --locked cargo-audit
|
||||
|
||||
# Full advisory report with NO suppressions. Run from a scratch dir that
|
||||
# has no .cargo/audit.toml so the ignore list does not apply; never fail
|
||||
# the build on this step, it is visibility only.
|
||||
- name: Full audit report (ignores not applied)
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/full-audit"
|
||||
cp Cargo.lock "$RUNNER_TEMP/full-audit/Cargo.lock"
|
||||
cd "$RUNNER_TEMP/full-audit"
|
||||
{
|
||||
echo '### Full cargo audit (no ignores applied)'
|
||||
echo '```'
|
||||
cargo audit -f Cargo.lock || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Drift guard: the hickory ignores are only valid while hickory-proto is
|
||||
# pinned at 0.25.x. If the lockfile has moved past that, the upstream fix
|
||||
# is in and the ignores in .cargo/audit.toml must be removed. Fail loudly.
|
||||
- name: Fail if hickory moved past 0.25.x while ignores remain
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(grep -A1 'name = "hickory-proto"' Cargo.lock | grep '^version' | head -1 | cut -d'"' -f2)"
|
||||
echo "hickory-proto in Cargo.lock: ${version:-not present}"
|
||||
ignores_present=false
|
||||
if grep -q 'RUSTSEC-2026-011' .cargo/audit.toml; then
|
||||
ignores_present=true
|
||||
fi
|
||||
# Drift = the lockfile no longer matches what the ignores assume:
|
||||
# hickory moved off 0.25.x (fix shipped) OR was removed entirely
|
||||
# (dead ignore entries left behind). Both must fail.
|
||||
if [ "$ignores_present" = true ] && { [ -z "${version}" ] || [[ "${version}" != 0.25.* ]]; }; then
|
||||
echo "::error::hickory-proto is ${version:-absent from Cargo.lock} but the RUSTSEC-2026-0118/0119 ignores are still in .cargo/audit.toml. The upstream fix appears to be available or the dependency is gone; remove the ignores."
|
||||
exit 1
|
||||
fi
|
||||
echo "No drift: ignore list is consistent with the pinned hickory-proto version."
|
||||
@@ -0,0 +1,121 @@
|
||||
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
|
||||
# issue when a push-to-main run of PR Checks ends red, and closes it when a
|
||||
# subsequent run on main's tip goes green.
|
||||
#
|
||||
# Why: two individually-green PRs can break main in combination when the
|
||||
# second merges on checks that ran against a stale base (semantic merge
|
||||
# race — #113 + #145 broke test compilation, found only via #229). A red main
|
||||
# is otherwise silent, and release-please PRs inherit the breakage.
|
||||
#
|
||||
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
|
||||
# commit is no longer main's tip are ignored (a re-run of an old red run
|
||||
# after a fix has landed must not cry wolf), and a green run on the tip
|
||||
# closes any open alert.
|
||||
name: Main red alert
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Checks"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read # getBranch tip check
|
||||
|
||||
# Serialize runs: concurrent completions would race the label/issue lookups
|
||||
# into duplicates (or 422 on createLabel). queue: max keeps every pending
|
||||
# completion instead of letting a later (possibly green-skipped) arrival
|
||||
# displace a pending red alert under burst merges; it is only incompatible
|
||||
# with cancel-in-progress: true, which we don't use.
|
||||
concurrency:
|
||||
group: main-red-alert
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
track:
|
||||
name: Track main CI state
|
||||
# timed_out (the test job runs 45 min against Postgres) and
|
||||
# startup_failure (malformed workflow) are red too — only cancelled is
|
||||
# excluded as a deliberate human abort. success runs are needed to close
|
||||
# the alert once main recovers.
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Reconcile the broken-main issue with main's current state
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const label = 'ci-main-red';
|
||||
|
||||
// Freshness gate: only the run for main's current tip may open or
|
||||
// close anything. Re-runs of superseded commits are events about
|
||||
// the past, not statements about main's state.
|
||||
const { data: branch } = await github.rest.repos.getBranch({
|
||||
...context.repo, branch: 'main',
|
||||
});
|
||||
if (branch.commit.sha !== run.head_sha) {
|
||||
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
|
||||
return;
|
||||
}
|
||||
|
||||
// listForRepo returns PRs carrying the label too — those must
|
||||
// never be mistaken for the tracking issue.
|
||||
const open = (await github.rest.issues.listForRepo({
|
||||
...context.repo, state: 'open', labels: label, per_page: 100,
|
||||
})).data.filter(item => !item.pull_request);
|
||||
|
||||
if (run.conclusion === 'success') {
|
||||
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
|
||||
for (const issue of open) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo, issue_number: issue.number, body,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
|
||||
const body = [
|
||||
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
|
||||
'',
|
||||
`> ${headline}`,
|
||||
'',
|
||||
'Likely a semantic merge race: each merged PR was green on a stale base.',
|
||||
'Fix main before merging anything else — release-please PRs inherit this breakage.',
|
||||
'',
|
||||
"This issue closes automatically when a run on main's tip goes green.",
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
await github.rest.issues.getLabel({ ...context.repo, name: label });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
await github.rest.issues.createLabel({
|
||||
...context.repo,
|
||||
name: label,
|
||||
color: 'b60205',
|
||||
description: 'CI is failing on main',
|
||||
});
|
||||
}
|
||||
|
||||
if (open.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo, issue_number: open[0].number, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.create({
|
||||
...context.repo,
|
||||
title: 'CI is red on main',
|
||||
body,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,17 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Run in the merge queue so a PR is re-validated against the true merged
|
||||
# result before it lands. Guards against semantic merge races where two
|
||||
# individually-green PRs break main in combination (#113 + #145 → #229).
|
||||
merge_group:
|
||||
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
|
||||
# and the lock-sync job), and GitHub's recursion guard means those pushes
|
||||
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
|
||||
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
|
||||
# the release branch to attach check runs to its head. Read-only workflow
|
||||
# (contents: read), so the dispatch surface adds no privilege.
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -9,18 +20,25 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel superseded runs on the same PR/branch; never cancel runs on main.
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
fmt-clippy-test:
|
||||
name: fmt + clippy + test
|
||||
fmt-clippy:
|
||||
name: fmt + clippy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
@@ -35,7 +53,233 @@ jobs:
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.toolchain }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: ${{ matrix.toolchain }}
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --workspace
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
|
||||
run: cargo test --locked --workspace
|
||||
|
||||
build-release:
|
||||
name: build --release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: release
|
||||
|
||||
- name: cargo build --release
|
||||
run: cargo build --locked --release --workspace
|
||||
|
||||
audit:
|
||||
name: cargo audit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: audit-0.22.2
|
||||
|
||||
- name: Install cargo-audit (pinned)
|
||||
# Pin the scanner on this BLOCKING gate: an unpinned `cargo install`
|
||||
# floats to the latest release, so a regressed cargo-audit could red CI
|
||||
# repo-wide with no code change. Advisory-DB freshness is fetched at scan
|
||||
# time, independent of the binary, so this does not weaken what the gate
|
||||
# catches. The weekly Scheduled Audit (audit-schedule.yml) deliberately
|
||||
# floats to latest as the drift canary; when it shows a newer cargo-audit
|
||||
# behaving differently, bump this version (and the cache key above) as a
|
||||
# deliberate maintainer action. If 0.22.2 is ever yanked from crates.io
|
||||
# this step fails loud until the pin is bumped.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo install --locked --version 0.22.2 cargo-audit
|
||||
# Assert the pin took effect so an accidental future unpin (or a drift
|
||||
# off 0.22.2) fails loudly here instead of silently running a different
|
||||
# scanner. `cargo audit --version` prints "cargo-audit-audit 0.22.2".
|
||||
installed="$(cargo audit --version)"
|
||||
echo "cargo-audit installed: $installed"
|
||||
echo "$installed" | grep -qE '(^| )0\.22\.2($| )' || {
|
||||
echo "::error::cargo-audit is not the pinned 0.22.2 (got: $installed)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Hard-fail gate. Suppressions live in .cargo/audit.toml (read automatically
|
||||
# from the repo root), each with no available upstream fix. A green check
|
||||
# here means "no NEW advisories", not "advisory-clean": surface the active
|
||||
# ignore list so a reviewer sees exactly what is being accepted.
|
||||
- name: Show active audit ignores
|
||||
run: |
|
||||
{
|
||||
echo '### cargo audit: accepted advisories (.cargo/audit.toml)'
|
||||
echo '```toml'
|
||||
sed -n '/^\[advisories\]/,$p' .cargo/audit.toml
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: cargo audit
|
||||
run: cargo audit
|
||||
|
||||
msrv:
|
||||
name: MSRV (Rust 1.91)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust 1.91
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.91
|
||||
with:
|
||||
toolchain: "1.91"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: msrv
|
||||
|
||||
- name: cargo check on MSRV
|
||||
run: cargo check --locked --workspace --all-targets
|
||||
|
||||
docker-build:
|
||||
name: Docker build smoke test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t gitlawb-node:ci-test .
|
||||
|
||||
- name: Smoke test --version
|
||||
run: docker run --rm gitlawb-node:ci-test --version
|
||||
|
||||
test-windows:
|
||||
name: test (windows, non-blocking)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
|
||||
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
|
||||
# test coverage, so run their suites here to make Windows-only regressions
|
||||
# VISIBLE on the PR without gating merge (#228). These two are the client
|
||||
# CLIs and depend on no database, so no Postgres service is needed (service
|
||||
# containers do not run on windows-latest regardless). `git` is preinstalled
|
||||
# on windows-latest, which the real_git_fetch harness relies on.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: windows
|
||||
|
||||
- name: cargo test (shipped Windows crates)
|
||||
run: cargo test --locked -p gl -p git-remote-gitlawb
|
||||
|
||||
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
|
||||
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
|
||||
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
|
||||
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
|
||||
# dependency reds CI whether or not anyone thought to ban it, which a denylist
|
||||
# cannot do. Regen instructions live in the allowlist header.
|
||||
core-deps-purity:
|
||||
name: gitlawb-core dependency purity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: core-deps-purity
|
||||
|
||||
- name: Check gitlawb-core dependency allowlist
|
||||
run: bash scripts/check-gitlawb-core-deps.sh
|
||||
@@ -0,0 +1,124 @@
|
||||
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:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: pr-triage-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Quality-signal triage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Label and guide
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Trusted authors bypass triage entirely.
|
||||
const trusted = ["OWNER", "MEMBER", "COLLABORATOR"];
|
||||
if (trusted.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is trusted; skipping triage.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Labels this workflow owns. Anything here may be added or removed each run.
|
||||
const MANAGED = ["needs-issue", "needs-description", "needs-tests", "workflow-change"];
|
||||
const want = new Set();
|
||||
|
||||
const body = pr.body || "";
|
||||
|
||||
// Linked issue: look for "closes/fixes/resolves #N" or any bare #N reference.
|
||||
const hasLinkedIssue =
|
||||
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\b[^\n]*#\d+/i.test(body) ||
|
||||
/#\d+/.test(body);
|
||||
if (!hasLinkedIssue) want.add("needs-issue");
|
||||
|
||||
// Description substance: strip HTML comments and markdown headings, then measure.
|
||||
const stripped = body
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/^#+.*$/gm, "")
|
||||
.trim();
|
||||
if (stripped.length < 50) want.add("needs-description");
|
||||
|
||||
// File-based signals.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: prNumber, per_page: 100,
|
||||
});
|
||||
const names = files.map(f => f.filename);
|
||||
|
||||
const isFork = pr.head.repo && pr.head.repo.full_name !== pr.base.repo.full_name;
|
||||
const touchesWorkflows = names.some(n => n.startsWith(".github/workflows/"));
|
||||
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.
|
||||
const addsInlineTest = files.some(f =>
|
||||
f.filename.endsWith(".rs") && f.patch &&
|
||||
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
|
||||
const touchedTests =
|
||||
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
|
||||
if (changedRust && !touchedTests) want.add("needs-tests");
|
||||
|
||||
// Reconcile labels: add wanted managed labels, remove managed labels no longer wanted.
|
||||
const current = (await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber,
|
||||
})).data.map(l => l.name);
|
||||
|
||||
const toAdd = [...want].filter(l => !current.includes(l));
|
||||
const toRemove = MANAGED.filter(l => current.includes(l) && !want.has(l));
|
||||
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd });
|
||||
}
|
||||
for (const name of toRemove) {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name })
|
||||
.catch(() => {}); // ignore if already gone
|
||||
}
|
||||
|
||||
// Guidance comment: only for advisory signals, and only once.
|
||||
const advisory = ["needs-issue", "needs-description", "needs-tests"].filter(l => want.has(l));
|
||||
if (!advisory.length) return;
|
||||
|
||||
const MARKER = "<!-- gitlawb-triage -->";
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
if (comments.some(c => c.body && c.body.includes(MARKER))) {
|
||||
core.info("Guidance comment already present; not reposting.");
|
||||
return;
|
||||
}
|
||||
|
||||
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-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");
|
||||
const comment = [
|
||||
MARKER,
|
||||
"Thanks for the contribution. A couple of things will help us review this faster:",
|
||||
"",
|
||||
lines,
|
||||
"",
|
||||
"See [CONTRIBUTING.md](https://github.com/Gitlawb/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
|
||||
].join("\n");
|
||||
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: comment });
|
||||
+751
-52
@@ -4,14 +4,34 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Manual backfill: republish an already-cut release to a channel that
|
||||
# missed it. Fill in only the channel(s) you want — an empty input leaves
|
||||
# that channel's jobs skipped. npm reuses the existing release's binary
|
||||
# assets; docker rebuilds the image from the tag and re-tags the manifest.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_backfill_tag:
|
||||
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
|
||||
required: false
|
||||
type: string
|
||||
docker_backfill_tag:
|
||||
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
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' }}
|
||||
# Skipped on manual dispatch: a backfill must never create or advance a
|
||||
# release PR as a side effect (npm-publish tolerates the skipped need via
|
||||
# !cancelled(), and every other job gates on release_created == 'true').
|
||||
if: ${{ github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -21,29 +41,205 @@ jobs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
pr: ${{ steps.release.outputs.pr }}
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.3.0
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
# The release PR bumps the workspace crate versions in the Cargo.tomls but
|
||||
# release-please cannot update Cargo.lock, so without this job every release
|
||||
# merge ships a tag whose lockfile disagrees with its manifests (a --locked
|
||||
# build from the tag fails, and pr-checks builds --locked). Sync the lock on
|
||||
# the release branch so the tagged tree is internally consistent.
|
||||
#
|
||||
# Runs on every push-triggered release run, not only when release-please
|
||||
# reports the PR: the `pr` output is set only on runs that CREATE or UPDATE
|
||||
# the release PR, so gating on it would leave an already-open release PR
|
||||
# (opened before this job existed, or untouched by a non-releasable push)
|
||||
# permanently without the sync and without check runs. The resolve step
|
||||
# discovers any open release-please PR itself and no-ops when there is none.
|
||||
sync-release-lock:
|
||||
name: Sync Cargo.lock on the release PR
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
||||
if: ${{ !cancelled() && github.repository == 'Gitlawb/node' && github.event_name != 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
# actions: write lets the final step dispatch pr-checks on the release
|
||||
# branch. Note this grant is repo-wide dispatch rights (GitHub cannot
|
||||
# scope it to one workflow); acceptable here because reaching this job's
|
||||
# steps already requires write to main. Needed because every push to the
|
||||
# release branch (release-please's and the lock sync below) is made with
|
||||
# GITHUB_TOKEN, which triggers no workflow runs, so without an explicit
|
||||
# dispatch the release PR's head carries no check runs and the --locked
|
||||
# gate never validates it.
|
||||
actions: write
|
||||
steps:
|
||||
# Prefer the fresh `pr` output when this run just created/updated the
|
||||
# release PR; otherwise fall back to querying for an open release-please
|
||||
# branch (prefix match: the branch carries `--components--` suffixes).
|
||||
- name: Resolve the open release PR branch
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
BRANCH_FROM_OUTPUT: ${{ needs.release-please.outputs.pr && fromJSON(needs.release-please.outputs.pr).headBranchName || '' }}
|
||||
run: |
|
||||
branch="$BRANCH_FROM_OUTPUT"
|
||||
if [ -z "$branch" ]; then
|
||||
# The branch name alone is not a trust signal. `gh pr list` includes
|
||||
# fork PRs, and a fork's headRefName is bare (no owner prefix), so a
|
||||
# prefix match by itself lets any fork nominate the branch this job
|
||||
# checks out, commits to, and dispatches checks on. Require the head
|
||||
# repo to be THIS repository and the author to be the release-please
|
||||
# app, so neither a fork nor a same-repo human branch named
|
||||
# `release-please--*` can be selected. Then take the highest PR
|
||||
# number: `[0]` depended on gh's default ordering, which is not a
|
||||
# documented guarantee, and picking a stable one keeps a second
|
||||
# (component) release PR from making the choice flap between runs.
|
||||
branch=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--json headRefName,headRepositoryOwner,number,author \
|
||||
--jq '[ .[]
|
||||
| select(.headRepositoryOwner.login == env.OWNER)
|
||||
| select(.author.login == "app/github-actions")
|
||||
| select(.headRefName | startswith("release-please--"))
|
||||
] | max_by(.number).headRefName // empty')
|
||||
fi
|
||||
if [ -z "$branch" ]; then
|
||||
echo "no open release PR; nothing to sync"
|
||||
fi
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release branch
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.branch }}
|
||||
|
||||
# Resolve on a declared toolchain instead of whatever Rust the runner
|
||||
# image happens to ship, so the lock this job writes comes off the same
|
||||
# stable channel the other cargo jobs use. Not a claim of identical
|
||||
# resolution: `stable` still moves between runs, and the MSRV gate
|
||||
# checks this same lock on 1.91 (pr-checks.yml). It only removes the
|
||||
# runner image as an undeclared input.
|
||||
- name: Install Rust toolchain
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Sync workspace crate versions into Cargo.lock
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
run: cargo update --workspace
|
||||
|
||||
- name: Commit and push when the lock changed
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
run: |
|
||||
if git diff --quiet Cargo.lock; then
|
||||
echo "Cargo.lock already in sync"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Cargo.lock
|
||||
git commit -m "chore: sync Cargo.lock with the release version bump"
|
||||
git push
|
||||
|
||||
# Every push to the release branch is made with GITHUB_TOKEN (release-
|
||||
# please's own pushes and the lock sync above), and events created by
|
||||
# GITHUB_TOKEN start no workflow runs. workflow_dispatch is the documented
|
||||
# exception GITHUB_TOKEN may trigger, so dispatch pr-checks at the release
|
||||
# branch to attach check runs to its current head. Unconditional (also on
|
||||
# the already-in-sync path) because the branch's ORIGINAL head has the
|
||||
# same no-runs problem. Loud on failure by design: a silent fallback
|
||||
# would recreate the checkless-head problem, and a job rerun is safe
|
||||
# (sync no-ops, dispatch retries). Loop-safe: pr-checks is read-only and
|
||||
# dispatches nothing. Runs after the push ack, so the dispatched ref tip
|
||||
# is the sync commit. Merge-queue note: this validates the branch tip;
|
||||
# the enforced merge queue's merge_group run still re-validates the true
|
||||
# merged result before landing.
|
||||
- name: Dispatch PR checks on the release branch
|
||||
if: ${{ steps.resolve.outputs.branch != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ steps.resolve.outputs.branch }}
|
||||
run: |
|
||||
# Every dispatch failure fails the job, including the 422 a release
|
||||
# branch cut before pr-checks gained its workflow_dispatch trigger
|
||||
# returns ("does not have a workflow_dispatch trigger", reported
|
||||
# against the dispatched ref's copy of the file). An earlier revision
|
||||
# tolerated exactly that 422 on the theory that release-please would
|
||||
# rewrite the branch and heal it, but a rewrite only happens when a
|
||||
# later releasable commit lands: an open release PR nobody touches is
|
||||
# never rewritten. Tolerating it therefore left the lock-sync commit
|
||||
# this job had just pushed sitting on the release head with no
|
||||
# --locked validation, which is the precise state this workflow exists
|
||||
# to prevent. The tolerance is also no longer needed: once this lands,
|
||||
# every release branch is cut from a main that already carries the
|
||||
# trigger, so the only branch that could 422 is one open at merge time
|
||||
# (there is none), and a job rerun after a manual rebase is safe (the
|
||||
# sync no-ops, the dispatch retries).
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/pr-checks.yml/dispatches" \
|
||||
-f "ref=$BRANCH"
|
||||
|
||||
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
|
||||
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
|
||||
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
|
||||
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
|
||||
# workflow concurrency group, blocked every queued release run behind it.
|
||||
docker:
|
||||
name: Build & Push Docker Image (${{ matrix.arch }})
|
||||
needs: release-please
|
||||
# Runs on a fresh release, or on manual dispatch for an existing tag
|
||||
# (release-please is skipped on dispatch, hence !cancelled()).
|
||||
if: >-
|
||||
${{ !cancelled() && (
|
||||
needs.release-please.outputs.release_created == 'true' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
|
||||
) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
- arch: arm64
|
||||
os: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing — a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
@@ -55,45 +251,130 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and load locally (smoke)
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
load: true
|
||||
tags: gitlawb-node:smoke
|
||||
cache-from: type=gha
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
|
||||
# Native runner on both arches, so the built image can always execute.
|
||||
# Assert the released version so a stale artifact fails here, not in prod.
|
||||
- name: Smoke test
|
||||
run: docker run --rm gitlawb-node:smoke --version
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$(docker run --rm gitlawb-node:smoke --version)"
|
||||
echo "$out"
|
||||
grep -qF "$VERSION" <<<"$out" || {
|
||||
echo "::error::image --version did not report expected version $VERSION (got: $out)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
# Push by digest only — tags are applied once by docker-manifest so a
|
||||
# half-finished matrix can never publish a partially-tagged image.
|
||||
# provenance:false keeps each push a plain single-arch manifest, which is
|
||||
# what imagetools create expects to merge.
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: false
|
||||
outputs: type=image,name=${{ steps.rel.outputs.image }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
env:
|
||||
DIGEST: ${{ steps.push.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/digests
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: docker-digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker-manifest:
|
||||
name: Publish Docker manifest
|
||||
needs: [release-please, docker]
|
||||
if: ${{ !cancelled() && needs.docker.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: docker-digest-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ghcr requires a lowercase repository path.
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
digests=""
|
||||
for f in /tmp/digests/*; do
|
||||
digests="$digests $IMAGE@sha256:$(basename "$f")"
|
||||
done
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:$MAJOR_MINOR" \
|
||||
-t "$IMAGE:latest" \
|
||||
$digests
|
||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
|
||||
echo "## Released $TAG"
|
||||
echo
|
||||
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
|
||||
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
|
||||
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
|
||||
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
release-binaries:
|
||||
@@ -101,6 +382,9 @@ 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:
|
||||
@@ -109,17 +393,24 @@ jobs:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
# Native arm64 runner: cross-compiling this target with the glibc
|
||||
# toolchain broke aws-lc-sys, and native lets the smoke test run.
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-24.04-arm
|
||||
# macos-13 (the last plain Intel image) is retired and queues forever;
|
||||
# macos-15-intel is GitHub's supported Intel label.
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
- 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
|
||||
@@ -127,46 +418,454 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# Both musl targets build natively on a matching-arch runner, so the stock
|
||||
# musl-gcc wrapper is all that's needed (no cross toolchain).
|
||||
- name: Install musl tools (linux)
|
||||
if: contains(matrix.target, 'linux-musl')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y musl-tools
|
||||
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||
echo "CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target ${{ matrix.target }} -p gl -p git-remote-gitlawb -p gitlawb-node
|
||||
# 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: Package
|
||||
id: pkg
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${{ matrix.target }}"
|
||||
args=""
|
||||
for b in $BINS; do args="$args -p $b"; done
|
||||
cargo build --release --locked --target ${{ matrix.target }} $args
|
||||
|
||||
# Run each packaged binary's --version so a broken release artifact fails the
|
||||
# build instead of shipping. Every target builds on a matching-arch runner,
|
||||
# so all of them can execute here.
|
||||
- name: Smoke test binaries
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
echo "== $bin --version =="
|
||||
out="$("$BIN_DIR/$bin$EXE" --version)"
|
||||
echo "$out"
|
||||
# Each binary prints "<name> <version>"; assert the released version is present.
|
||||
grep -qF "$VERSION" <<<"$out" || {
|
||||
echo "::error::$bin --version did not report expected version $VERSION (got: $out)"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Package
|
||||
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
|
||||
}
|
||||
|
||||
mkdir -p "dist/$NAME"
|
||||
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/"
|
||||
for bin in $BINS; do
|
||||
cp "$BIN_DIR/$bin$EXE" "dist/$NAME/"
|
||||
done
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "dist/$NAME/"
|
||||
|
||||
cd dist
|
||||
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"
|
||||
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
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: ${{ needs.release-please.outputs.tag_name }}
|
||||
files: |
|
||||
${{ steps.pkg.outputs.asset }}
|
||||
${{ steps.pkg.outputs.checksum }}
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/*.sha256
|
||||
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
needs: [release-please, release-binaries]
|
||||
# Runs on a fresh release, or on manual dispatch for an existing tag
|
||||
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
|
||||
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
|
||||
# @gitlawb package must have this repo + workflow (release.yml) configured
|
||||
# as its trusted publisher on npmjs.com, or publish fails loudly here —
|
||||
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
|
||||
# never reached npm.
|
||||
if: >-
|
||||
${{ !cancelled() && (
|
||||
(needs.release-please.outputs.release_created == 'true' && needs.release-binaries.result == 'success') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.npm_backfill_tag != '')
|
||||
) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm trusted publishing (OIDC) + provenance
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
|
||||
esac
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
|
||||
# package has a trusted publisher configured; older npm silently falls
|
||||
# back to (absent) token auth and fails confusingly.
|
||||
- name: Set up Node 24 + OIDC-capable npm
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g npm@^11.5.1
|
||||
npm --version
|
||||
|
||||
- name: Lay in release binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm platform package -> Rust target triple (unix only; Windows is not
|
||||
# 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
|
||||
env:
|
||||
VERSION: ${{ steps.rel.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
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# No token: npm exchanges this job's GitHub OIDC identity with the
|
||||
# registry (trusted publishing); provenance is attested automatically.
|
||||
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
|
||||
# Skip versions already on the registry so a rerun after a partial publish
|
||||
# is idempotent instead of erroring on the first existing package.
|
||||
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
|
||||
|
||||
def caveats
|
||||
<<~CAVEATS
|
||||
oh-my-zsh's git plugin aliases gl='git pull', which shadows this
|
||||
binary in interactive shells. If \`gl\` prints "fatal: not a git
|
||||
repository", run:
|
||||
echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc
|
||||
CAVEATS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/gl --version")
|
||||
end
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Stale PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *" # daily at 01:30 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Flag and close ignored PRs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
# 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"
|
||||
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.
|
||||
close-pr-message: >
|
||||
Closing due to inactivity. Reopen whenever you're ready to update it. See
|
||||
CONTRIBUTING.md for what helps us review quickly.
|
||||
ascending: true
|
||||
@@ -23,3 +23,6 @@ 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.3.8"
|
||||
".": "0.7.1"
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Changelog
|
||||
|
||||
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
|
||||
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
|
||||
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
|
||||
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
|
||||
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
|
||||
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
|
||||
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
|
||||
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
|
||||
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
|
||||
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
|
||||
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
|
||||
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
|
||||
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
|
||||
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
|
||||
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
|
||||
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
|
||||
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
|
||||
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
|
||||
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
|
||||
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
|
||||
|
||||
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
|
||||
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
|
||||
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
|
||||
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
|
||||
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
|
||||
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
|
||||
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
|
||||
|
||||
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
|
||||
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
|
||||
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
|
||||
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
|
||||
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
|
||||
|
||||
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bounties:** add tests for claim_bounty repo-read gate ([#160](https://github.com/Gitlawb/node/issues/160)) ([#169](https://github.com/Gitlawb/node/issues/169)) ([6bafaa6](https://github.com/Gitlawb/node/commit/6bafaa6dc5f05c0dcd61c708397afddbcf8c2e3f))
|
||||
* **deps:** bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#162](https://github.com/Gitlawb/node/issues/162)) ([#163](https://github.com/Gitlawb/node/issues/163)) ([67ad2b8](https://github.com/Gitlawb/node/commit/67ad2b876c8d9a336219d1016968de7a88fc4e75))
|
||||
* **node,git:** bound a hung served git with a total-duration timeout ([#62](https://github.com/Gitlawb/node/issues/62)) ([#165](https://github.com/Gitlawb/node/issues/165)) ([cd67718](https://github.com/Gitlawb/node/commit/cd67718f49ec38726a40f6bcf36f539ccdb42969))
|
||||
* **node:** bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth ([#147](https://github.com/Gitlawb/node/issues/147)) ([#149](https://github.com/Gitlawb/node/issues/149)) ([6b5e5bc](https://github.com/Gitlawb/node/commit/6b5e5bc7aee00a2d03295d3620df1ee4d8c024a2))
|
||||
* **node:** gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes ([#82](https://github.com/Gitlawb/node/issues/82)) ([#161](https://github.com/Gitlawb/node/issues/161)) ([d00d89a](https://github.com/Gitlawb/node/commit/d00d89ae5be992d1f63e95b714ae1bd3735e8457))
|
||||
* **node:** rate-limit repo/agent creation per client IP to stop DID-farm spam floods ([#180](https://github.com/Gitlawb/node/issues/180)) ([dfcaa22](https://github.com/Gitlawb/node/commit/dfcaa22b23ec91be4c75926956aa994ca89de8d5))
|
||||
* **release:** build aarch64-musl natively on arm64 runners, replace retired macos-13 ([#155](https://github.com/Gitlawb/node/issues/155)) ([6cff528](https://github.com/Gitlawb/node/commit/6cff5286b436fdee44ad881999e2a5f4bdba18f9))
|
||||
* **visibility:** gate repo-scoped read surfaces on visibility ([#120](https://github.com/Gitlawb/node/issues/120)) ([#157](https://github.com/Gitlawb/node/issues/157)) ([26bc3f6](https://github.com/Gitlawb/node/commit/26bc3f69870aa77e43c0d92115a5aa59555b7d88))
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **gl:** sanctioned iCaptcha client flow + secure git lifecycle ([#138](https://github.com/Gitlawb/node/issues/138)) ([06388ec](https://github.com/Gitlawb/node/commit/06388ec26aa29d356ae311276fdb91be054e9ecc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **gl:** sign the CLI's /ipfs/pins reads under the [#134](https://github.com/Gitlawb/node/issues/134) auth gate ([#146](https://github.com/Gitlawb/node/issues/146)) ([20d6848](https://github.com/Gitlawb/node/commit/20d6848846b3a988d604208833167a528b7d8820))
|
||||
* **node,git-remote:** gate receive-pack advertisement, sign client fetch/push ([#119](https://github.com/Gitlawb/node/issues/119)) ([6f36fc0](https://github.com/Gitlawb/node/commit/6f36fc07b8e10a650c5948b269feac1cb25cae2a))
|
||||
* **node,gossip:** route gossip HTTP through the no-redirect client ([#93](https://github.com/Gitlawb/node/issues/93)) ([#140](https://github.com/Gitlawb/node/issues/140)) ([563c456](https://github.com/Gitlawb/node/commit/563c456803bf3e958d63869db424b3940472bc3d))
|
||||
* **node:** close two spam-vector root causes (trust upsert + ungated push) ([#152](https://github.com/Gitlawb/node/issues/152)) ([2df6ff9](https://github.com/Gitlawb/node/commit/2df6ff9d30de62f754fa41473e85db316021718e))
|
||||
* **node:** gate GET /ipfs/{cid} on reachable allowed-set, not deny-set ([#126](https://github.com/Gitlawb/node/issues/126)) ([#133](https://github.com/Gitlawb/node/issues/133)) ([466a550](https://github.com/Gitlawb/node/commit/466a550915edd711856ef32035f9f474e2577c4f))
|
||||
* **node:** gate the ref-updates feeds on read visibility ([#112](https://github.com/Gitlawb/node/issues/112), [#114](https://github.com/Gitlawb/node/issues/114)) ([#143](https://github.com/Gitlawb/node/issues/143)) ([4891db3](https://github.com/Gitlawb/node/commit/4891db38892663326ee0c1417a2db931988be4b5))
|
||||
* **node:** prefer canonical repo row over mirror row in get_repo ([#124](https://github.com/Gitlawb/node/issues/124)) ([#141](https://github.com/Gitlawb/node/issues/141)) ([6c95592](https://github.com/Gitlawb/node/commit/6c95592d188222ac3446dc23ef8d9befbf82ad6f))
|
||||
* **remote:** include HTTP error response body ([#137](https://github.com/Gitlawb/node/issues/137)) ([09a0cb2](https://github.com/Gitlawb/node/commit/09a0cb23b9f284ccbd69aca6958b70671f3bfb46))
|
||||
* **repos:** log the cause when repo create fails ([#103](https://github.com/Gitlawb/node/issues/103)) ([2620e97](https://github.com/Gitlawb/node/commit/2620e973e3cd4835ed42ddd4adcd8183b5b3080e))
|
||||
|
||||
## [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))
|
||||
+20
-1
@@ -55,10 +55,29 @@ Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://
|
||||
4. **Conventional commits.** Use `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Releases are automated by [release-please](https://github.com/googleapis/release-please) — your commit prefixes drive the next version bump.
|
||||
5. **Format and lint.** Run `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` before submitting. CI will reject anything that fails these.
|
||||
|
||||
## What gets merged, what gets closed
|
||||
|
||||
We welcome contributions from humans and agents alike. To keep review sustainable, PRs are
|
||||
expected to clear a basic quality bar:
|
||||
|
||||
- **Link an issue.** Bug fixes and features should reference an issue (`Closes #123`). For
|
||||
protocol-level changes (identity, signatures, UCAN, ref certs, wire formats), open the
|
||||
issue *before* writing code.
|
||||
- **One change per PR.** Unrelated churn slows review and gets sent back.
|
||||
- **Tests and a green pipeline.** New behavior needs tests; `cargo fmt`, `cargo clippy`,
|
||||
and the full CI suite must pass.
|
||||
- **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.
|
||||
|
||||
## Development environment
|
||||
|
||||
**Requirements:**
|
||||
- Rust stable (≥ 1.85) — install via [rustup](https://rustup.rs)
|
||||
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
|
||||
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
|
||||
- Docker (optional, for full-stack local testing)
|
||||
|
||||
|
||||
Generated
+307
-587
File diff suppressed because it is too large.
Load diff
+16
-2
@@ -5,11 +5,20 @@ members = [
|
||||
"crates/gitlawb-node",
|
||||
"crates/gl",
|
||||
"crates/git-remote-gitlawb",
|
||||
"crates/gitlawb-attest",
|
||||
"crates/icaptcha-client",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.9"
|
||||
# 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.
|
||||
edition = "2021"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["gitlawb contributors"]
|
||||
repository = "https://github.com/gitlawb/node"
|
||||
@@ -20,12 +29,13 @@ tokio = { version = "1", features = ["full"] }
|
||||
# serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_jcs = "0.2"
|
||||
# errors
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# crypto
|
||||
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"] }
|
||||
rand = "0.8"
|
||||
rand = "0.8.6"
|
||||
sha2 = "0.10"
|
||||
# encoding
|
||||
hex = "0.4.3"
|
||||
@@ -45,3 +55,7 @@ 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"
|
||||
+28
-7
@@ -8,20 +8,33 @@ FROM rust:1.91-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies first for faster rebuilds
|
||||
# Cache dependencies first for faster rebuilds.
|
||||
# EVERY workspace member's manifest must be copied here, not just the ones this
|
||||
# image ships: cargo loads the whole workspace before it resolves anything, so a
|
||||
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
|
||||
# layer before a single crate is fetched.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
|
||||
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
|
||||
COPY crates/gl/Cargo.toml crates/gl/
|
||||
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
|
||||
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
|
||||
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
|
||||
|
||||
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
|
||||
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
|
||||
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
|
||||
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
|
||||
# is what let it sit dead from the moment gl gained a path dependency on
|
||||
# icaptcha-client. A failure here should stop the build, not silently turn every
|
||||
# image build into a cold one.
|
||||
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
|
||||
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
|
||||
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
|
||||
echo 'fn main() {}' > crates/gl/src/main.rs && \
|
||||
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
|
||||
echo '' > crates/gitlawb-core/src/lib.rs && \
|
||||
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
|
||||
echo '' > crates/gitlawb-attest/src/lib.rs && \
|
||||
echo '' > crates/icaptcha-client/src/lib.rs && \
|
||||
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
|
||||
|
||||
# Now copy real sources and build for real.
|
||||
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
|
||||
@@ -34,7 +47,7 @@ RUN find crates -name "*.rs" -exec touch {} + && \
|
||||
rm -rf target/release/.fingerprint/gitlawb-node-* \
|
||||
target/release/.fingerprint/gl-* \
|
||||
target/release/.fingerprint/git-remote-gitlawb-* && \
|
||||
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
|
||||
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
|
||||
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
|
||||
|
||||
# ── Runtime stage ───────────────────────────────────────────────────────────
|
||||
@@ -44,6 +57,7 @@ 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
|
||||
@@ -65,11 +79,18 @@ ENV GITLAWB_REPOS_DIR=/data/repos \
|
||||
GITLAWB_PORT=7545 \
|
||||
GITLAWB_P2P_PORT=7546
|
||||
|
||||
EXPOSE 7545 7546
|
||||
EXPOSE 7545 7546/udp
|
||||
|
||||
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
|
||||
|
||||
ENTRYPOINT ["gitlawb-node"]
|
||||
# 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"]
|
||||
+5
-2
@@ -3,7 +3,10 @@
|
||||
# Used by scripts/build-bins.sh — not used for node deployment.
|
||||
ARG TARGET=x86_64-unknown-linux-musl
|
||||
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
|
||||
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
|
||||
# base image here fails only when someone runs scripts/build-bins.sh.
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
ARG TARGET
|
||||
|
||||
WORKDIR /build
|
||||
@@ -23,7 +26,7 @@ COPY crates/ crates/
|
||||
# Configure cross-linker for aarch64
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
||||
|
||||
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
|
||||
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
ARG TARGET
|
||||
|
||||
@@ -1,49 +1,321 @@
|
||||
# gitlawb node
|
||||
# Gitlawb Node
|
||||
|
||||
**Decentralized git for AI agents and developers.** Run your own node, host repos under your own DID, and connect to the gitlawb network.
|
||||
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
|
||||
|
||||
This repository contains the open-source node software:
|
||||
Gitlawb Node is the open-source node software behind the Gitlawb network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
|
||||
|
||||
- `gitlawb-node` — Axum HTTP server, git smart-HTTP, libp2p gossip, optional S3-compatible storage
|
||||
- `gl` — CLI for identity, repos, PRs, MCP server
|
||||
- `git-remote-gitlawb` — `git remote helper` for `gitlawb://` URLs
|
||||
- `gitlawb-core` — shared crypto primitives (DID, CID, HTTP signatures, UCAN)
|
||||
Gitlawb is not trying to be only “another git host.” The long-term direction is:
|
||||
|
||||
```txt
|
||||
Decentralized GitHub
|
||||
+ signed agent-native workflows
|
||||
+ resilient repo replication
|
||||
+ CDN-style app/code delivery
|
||||
```
|
||||
|
||||
The mission is simple: once code is pushed to the network, it should not disappear because one server went down.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
## What is in this repository?
|
||||
|
||||
The fastest way to run a node is via Docker. You'll get a node + Postgres locally with one command.
|
||||
This is a Rust workspace with four crates:
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `gitlawb-node` | The node daemon: Axum HTTP server, git smart-HTTP, Postgres metadata, libp2p gossip, optional S3/Tigris/IPFS/Arweave/Base PoS hooks. |
|
||||
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
|
||||
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
|
||||
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
|
||||
|
||||
---
|
||||
|
||||
## Why Gitlawb Nodes?
|
||||
|
||||
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
|
||||
|
||||
- **Own your identity** — every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
|
||||
- **Signed writes by default** — write requests use RFC 9421 HTTP Signatures instead of passwords.
|
||||
- **Git-native transport** — repositories are still real git repositories served over smart HTTP.
|
||||
- **Agent-native workflows** — the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
|
||||
- **Peer-aware delivery** — nodes can announce, discover, gossip, and sync with each other.
|
||||
- **App CDN direction** — the network can evolve from decentralized code storage into code + asset + app delivery.
|
||||
|
||||
---
|
||||
|
||||
## Current status
|
||||
|
||||
Gitlawb Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
|
||||
|
||||
Good today:
|
||||
|
||||
- Local or Docker node startup.
|
||||
- Postgres-backed repo metadata.
|
||||
- Bare git repository storage.
|
||||
- Git smart-HTTP clone/fetch/push.
|
||||
- RFC 9421-signed writes.
|
||||
- DID identities.
|
||||
- `gl` CLI workflows.
|
||||
- libp2p peer discovery/gossip foundation.
|
||||
- Optional Tigris/S3 storage.
|
||||
- Optional IPFS/Pinata and Arweave/Irys hooks.
|
||||
- Optional Base node-operator staking/heartbeat hooks.
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Private repository read enforcement is not wired yet. Treat public nodes as public infrastructure unless you restrict access at your proxy/firewall.
|
||||
- UCAN chain validation and revocation are not complete.
|
||||
- Repository write authorization is not capability-complete yet; HTTP signatures prove identity, not full authorization policy.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
|
||||
|
||||
See:
|
||||
|
||||
- [`SECURITY.md`](SECURITY.md)
|
||||
- [`docs/OSS-READINESS-AUDIT.md`](docs/OSS-READINESS-AUDIT.md)
|
||||
- [`docs/MAINTAINER-ROADMAP.md`](docs/MAINTAINER-ROADMAP.md)
|
||||
|
||||
---
|
||||
|
||||
## Quickstart: run a local node
|
||||
|
||||
The fastest path is Docker Compose. It starts a node and Postgres.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/gitlawb/node.git
|
||||
git clone https://github.com/Gitlawb/node.git
|
||||
cd node
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your node is now serving:
|
||||
Your local node will serve:
|
||||
|
||||
- HTTP + git smart-HTTP on `:7545`
|
||||
- libp2p gossip on `:7546`
|
||||
| Service | Default |
|
||||
|---|---|
|
||||
| HTTP API + git smart-HTTP | `http://localhost:7545` |
|
||||
| libp2p QUIC/UDP | `7546` |
|
||||
| Postgres | compose-managed |
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7545/health
|
||||
curl http://localhost:7545/api/v1/stats
|
||||
```
|
||||
|
||||
Expected health response:
|
||||
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
Stop it:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
## Install the CLI
|
||||
|
||||
- **Identity** — every actor (human or agent) is an Ed25519 keypair → `did:key:z6Mk...`. No accounts, no passwords.
|
||||
- **Auth** — every write is signed via HTTP Signatures (RFC 9421).
|
||||
- **Delegation** — UCAN capability tokens.
|
||||
- **Storage** — local disk + optional S3-compatible bucket (Tigris, MinIO, AWS S3).
|
||||
- **Networking** — libp2p Gossipsub for ref-update events, Kademlia DHT for node discovery.
|
||||
```bash
|
||||
# npm (macOS / Linux)
|
||||
npm install -g @gitlawb/gl
|
||||
|
||||
The node is **stateless beyond Postgres + the repos directory.** You can scale horizontally by pointing multiple nodes at a shared S3 bucket and database.
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
cargo build --release -p gl -p git-remote-gitlawb -p gitlawb-node
|
||||
```
|
||||
|
||||
Put these binaries on your `PATH`:
|
||||
|
||||
```txt
|
||||
target/release/gl
|
||||
target/release/git-remote-gitlawb
|
||||
target/release/gitlawb-node
|
||||
```
|
||||
|
||||
Check your setup:
|
||||
|
||||
```bash
|
||||
gl doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First repo flow
|
||||
|
||||
Create an identity:
|
||||
|
||||
```bash
|
||||
gl identity new
|
||||
gl identity show
|
||||
```
|
||||
|
||||
Register against your local node:
|
||||
|
||||
```bash
|
||||
gl register --node http://localhost:7545
|
||||
```
|
||||
|
||||
Create a repo:
|
||||
|
||||
```bash
|
||||
gl repo create my-repo --description "My first Gitlawb repo" --node http://localhost:7545
|
||||
```
|
||||
|
||||
Use the git remote helper:
|
||||
|
||||
```bash
|
||||
export GITLAWB_NODE=http://localhost:7545
|
||||
git clone gitlawb://did:key:z6Mk.../my-repo
|
||||
```
|
||||
|
||||
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
|
||||
|
||||
### Full lifecycle against an iCaptcha-enforcing node
|
||||
|
||||
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
|
||||
|
||||
1. **RFC 9421 HTTP Signatures** — every write is signed by your identity key. `gl`
|
||||
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
|
||||
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
|
||||
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
|
||||
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
|
||||
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
|
||||
locally (arithmetic / algebra / sequence), and **retries the same signed request**
|
||||
with the `x-icaptcha-proof` header — no manual steps, no env vars.
|
||||
|
||||
```bash
|
||||
gl identity new # create did:key identity
|
||||
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
|
||||
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
|
||||
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
|
||||
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
|
||||
gl doctor # preflight: identity, node, version, iCaptcha
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **`requesterId` is always your DID.** The proof's `sub` claim must equal the
|
||||
authenticated signer; `gl`/helper set this automatically and the node enforces
|
||||
`sub == authenticated DID` (so a proof minted for another identity is rejected).
|
||||
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
|
||||
solving and use, the client transparently solves a fresh one and retries.
|
||||
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
|
||||
`git push` is **signed-only** (owner signature is the gate — no per-push challenge);
|
||||
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
|
||||
clear `404`, never a placeholder.
|
||||
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
|
||||
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
|
||||
`https` origin whose host is allowlisted (that URL or the public default), and
|
||||
sends the bearer token **only** to your configured origin — never to a URL a
|
||||
node advertises — so a hostile node can't capture the key or redirect the solve.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```txt
|
||||
┌──────────────────────────┐
|
||||
│ gl CLI / git / AI agents │
|
||||
└────────────┬─────────────┘
|
||||
│ signed HTTP writes / git smart-HTTP
|
||||
↓
|
||||
┌──────────────────────────┐
|
||||
│ gitlawb-node │
|
||||
│ Axum API + git routes │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
↓ ↓
|
||||
Postgres Bare git repos
|
||||
metadata local disk / optional S3
|
||||
│ │
|
||||
└────────┬────────┘
|
||||
↓
|
||||
libp2p peers
|
||||
gossip + discovery + sync
|
||||
↓
|
||||
optional IPFS / Arweave / Base PoS
|
||||
```
|
||||
|
||||
### Core concepts
|
||||
|
||||
| Concept | Meaning |
|
||||
|---|---|
|
||||
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
|
||||
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
|
||||
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
|
||||
| Gossipsub | libp2p topic for ref-update events. |
|
||||
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
|
||||
The node exposes both git smart-HTTP routes and JSON APIs.
|
||||
|
||||
Common public read routes:
|
||||
|
||||
```txt
|
||||
GET /health
|
||||
GET /
|
||||
GET /api/v1/stats
|
||||
GET /api/v1/contracts
|
||||
GET /api/v1/repos
|
||||
GET /api/v1/repos/{owner}/{repo}
|
||||
GET /api/v1/repos/{owner}/{repo}/tree
|
||||
GET /api/v1/repos/{owner}/{repo}/blob/{path}
|
||||
GET /api/v1/repos/{owner}/{repo}/issues
|
||||
GET /api/v1/repos/{owner}/{repo}/pulls
|
||||
GET /api/v1/peers
|
||||
GET /{owner}/{repo}/info/refs
|
||||
POST /{owner}/{repo}/git-upload-pack
|
||||
```
|
||||
|
||||
Signed write routes include:
|
||||
|
||||
```txt
|
||||
POST /api/v1/repos
|
||||
POST /api/register
|
||||
POST /api/v1/repos/{owner}/{repo}/fork
|
||||
POST /api/v1/repos/{owner}/{repo}/issues
|
||||
POST /api/v1/repos/{owner}/{repo}/pulls
|
||||
POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge
|
||||
POST /api/v1/repos/{owner}/{repo}/hooks
|
||||
POST /api/v1/bounties/{id}/...
|
||||
POST /{owner}/{repo}/git-receive-pack
|
||||
```
|
||||
|
||||
These peer write routes support staged rollout:
|
||||
|
||||
```txt
|
||||
POST /api/v1/peers/announce
|
||||
POST /api/v1/sync/notify
|
||||
```
|
||||
|
||||
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Once all live peers upgrade, operators can set:
|
||||
|
||||
```bash
|
||||
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
|
||||
```
|
||||
|
||||
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
|
||||
|
||||
---
|
||||
|
||||
@@ -51,116 +323,186 @@ The node is **stateless beyond Postgres + the repos directory.** You can scale h
|
||||
|
||||
All configuration is via environment variables. See [`.env.example`](.env.example) for the full reference.
|
||||
|
||||
The minimum required:
|
||||
Minimum required for a persistent node:
|
||||
|
||||
- `DATABASE_URL` — Postgres connection string
|
||||
```env
|
||||
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
```
|
||||
|
||||
Everything else has sensible defaults. Optional features:
|
||||
Important node settings:
|
||||
|
||||
- `GITLAWB_TIGRIS_BUCKET` (+ AWS credentials) — S3-compatible shared storage
|
||||
- `GITLAWB_PINATA_JWT` — IPFS warm-storage pinning
|
||||
- `GITLAWB_IRYS_URL` — Arweave permanent anchoring
|
||||
- `GITLAWB_BOOTSTRAP_PEERS` — comma-separated peer URLs to announce to on startup
|
||||
- `GITLAWB_P2P_BOOTSTRAP` — comma-separated libp2p multiaddrs
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `GITLAWB_HOST` / `GITLAWB_PORT` | HTTP bind address and port. |
|
||||
| `GITLAWB_REPOS_DIR` | Local bare repo storage directory. |
|
||||
| `GITLAWB_PUBLIC_URL` | Public HTTP URL announced to peers. |
|
||||
| `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. |
|
||||
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
|
||||
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
|
||||
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
|
||||
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack/receive-pack may run before it is aborted (504). Default 600. Does not bound `info/refs` or the withheld-blob path. |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
|
||||
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
|
||||
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
|
||||
|
||||
### On-chain Proof-of-Stake (advanced, optional)
|
||||
|
||||
The node supports an optional on-chain PoS layer where operators register their DID, stake $GITLAWB on Base L2, and receive a share of protocol fees. Smart contracts are in a separate repository at [github.com/gitlawb/contracts](https://github.com/gitlawb/contracts).
|
||||
|
||||
PoS is **disabled by default** and currently paused pending external audit. To enable, set `GITLAWB_CONTRACT_NODE_STAKING` and `GITLAWB_OPERATOR_PRIVATE_KEY`. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md) for details.
|
||||
Production note: change the default Postgres password before exposing a node publicly.
|
||||
|
||||
---
|
||||
|
||||
## Build from source
|
||||
## Optional node staking
|
||||
|
||||
Requires Rust 1.85+ and a Postgres instance.
|
||||
Gitlawb Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
|
||||
|
||||
PoS is disabled unless these are configured:
|
||||
|
||||
```env
|
||||
GITLAWB_CONTRACT_NODE_STAKING=0x...
|
||||
GITLAWB_OPERATOR_PRIVATE_KEY=0x...
|
||||
GITLAWB_CHAIN_RPC_URL=https://mainnet.base.org
|
||||
```
|
||||
|
||||
Recommended for operators:
|
||||
|
||||
```env
|
||||
GITLAWB_OPERATOR_STRICT_MODE=true
|
||||
GITLAWB_HEARTBEAT_INTERVAL_HOURS=20
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md)
|
||||
- [`docs/ECONOMICS.md`](docs/ECONOMICS.md)
|
||||
|
||||
Use a dedicated low-balance operator wallet. Do not use a treasury wallet as the heartbeat key.
|
||||
|
||||
---
|
||||
|
||||
## Building from source
|
||||
|
||||
Requires Rust 1.91+.
|
||||
|
||||
```bash
|
||||
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb
|
||||
```
|
||||
|
||||
Binaries are placed in `target/release/`. The `gl` and `git-remote-gitlawb` binaries should be on your `$PATH` for client use.
|
||||
|
||||
---
|
||||
|
||||
## CLI usage
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
gl identity new
|
||||
gl register --node http://localhost:7545
|
||||
gl repo create my-repo --description "..."
|
||||
git clone gitlawb://did:key:z6Mk.../my-repo
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Full CLI reference: `gl --help`.
|
||||
Run the node from source:
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
crates/
|
||||
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN)
|
||||
├── gitlawb-node/ node daemon (Axum + git smart-HTTP)
|
||||
├── gl/ CLI
|
||||
└── git-remote-gitlawb/ git remote helper
|
||||
```bash
|
||||
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb \
|
||||
cargo run -p gitlawb-node --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## macOS Menu Bar App
|
||||
## macOS menu bar app
|
||||
|
||||
A native Swift/AppKit menu bar app that manages the Docker Compose stack (node + Postgres) without touching the terminal.
|
||||
A native Swift/AppKit menu bar app is included for managing a local Docker Compose stack without living in the terminal.
|
||||
|
||||
**Requirements:** macOS 26+, Xcode Command Line Tools (`xcode-select --install`), and a Docker runtime (Docker Desktop, OrbStack, or Colima).
|
||||
Requirements:
|
||||
|
||||
### Build
|
||||
- macOS 26+
|
||||
- Xcode Command Line Tools
|
||||
- Docker Desktop, OrbStack, or Colima
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
./scripts/build-macos-app.sh
|
||||
```
|
||||
|
||||
The resulting `Gitlawb Node.app` and `.dmg` are placed in `dist/`.
|
||||
Output:
|
||||
|
||||
To codesign for distribution:
|
||||
|
||||
```bash
|
||||
./scripts/build-macos-app.sh --sign "Developer ID Application: ..."
|
||||
```txt
|
||||
dist/Gitlawb Node.app
|
||||
dist/Gitlawb Node.dmg
|
||||
```
|
||||
|
||||
### Features
|
||||
Features:
|
||||
|
||||
- Start/Stop the node from the menu bar
|
||||
- Status indicator (green = running, yellow = starting, red = stopped)
|
||||
- Settings GUI (ports, Postgres password, operator config)
|
||||
- Auto-start on login
|
||||
- Detects Docker Desktop, OrbStack, and Colima automatically
|
||||
- Start/stop local node stack.
|
||||
- Status indicator.
|
||||
- Settings GUI.
|
||||
- Auto-start on login.
|
||||
- Docker runtime detection.
|
||||
|
||||
### Running an unsigned build
|
||||
|
||||
If you built the app locally without a Developer ID, macOS Gatekeeper will block it. To allow it:
|
||||
Unsigned local build:
|
||||
|
||||
```bash
|
||||
xattr -cr "dist/Gitlawb Node.app"
|
||||
```
|
||||
|
||||
Then open the app normally. Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway** after the first blocked launch attempt.
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
The current maintainer focus is live-network stability first.
|
||||
|
||||
Short-term priorities:
|
||||
|
||||
1. Keep CI green: fmt, clippy, tests, release build.
|
||||
2. Add Docker and installer smoke tests.
|
||||
3. Improve operator docs and `gl doctor` checks.
|
||||
4. Harden peer writes and publish the signed-peer rollout plan.
|
||||
5. Implement repo write authorization: owner checks, protected branches, and UCAN capability checks.
|
||||
6. Implement private-read enforcement or remove private repo affordances until it exists.
|
||||
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
|
||||
|
||||
Product direction:
|
||||
|
||||
1. Reliable repo replication.
|
||||
2. Health-aware peer syncing.
|
||||
3. CDN-style clone/fetch routing to healthy replicas.
|
||||
4. App asset/build delivery from nodes.
|
||||
5. Operator dashboard and desktop UX.
|
||||
|
||||
Read the maintainer roadmap:
|
||||
|
||||
```txt
|
||||
docs/MAINTAINER-ROADMAP.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security issues: see [`SECURITY.md`](SECURITY.md).
|
||||
Start here:
|
||||
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
- [`docs/MAINTAINER-ROADMAP.md`](docs/MAINTAINER-ROADMAP.md)
|
||||
- [`docs/OSS-READINESS-AUDIT.md`](docs/OSS-READINESS-AUDIT.md)
|
||||
- [`SECURITY.md`](SECURITY.md)
|
||||
|
||||
Good first contribution areas:
|
||||
|
||||
- docs and install polish
|
||||
- Docker smoke tests
|
||||
- CLI error messages
|
||||
- `gl doctor` checks
|
||||
- operator dashboard UX
|
||||
- test coverage for peer sync and signed writes
|
||||
|
||||
Security issues should follow [`SECURITY.md`](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of
|
||||
Licensed under either of:
|
||||
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual licensed as above, without any additional terms or conditions.
|
||||
+22
-11
@@ -16,7 +16,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
|
||||
## Current Security Architecture
|
||||
|
||||
### What is production-ready (v0.1)
|
||||
### Current live security controls
|
||||
|
||||
**Ed25519 identity and HTTP Signatures**
|
||||
- Every write operation is signed with RFC 9421 HTTP Signatures
|
||||
@@ -42,35 +42,46 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
|
||||
|
||||
## Dependency Vulnerability Status
|
||||
|
||||
| Crate | Advisory | Severity | Status |
|
||||
|-------|----------|----------|--------|
|
||||
| `rsa 0.9.x` | RUSTSEC-2023-0071 Marvin Attack | Medium | Unexploitable — bundled via `sqlx-mysql` internals; gitlawb uses PostgreSQL only, MySQL auth code is never executed. No upstream fix available. |
|
||||
| `lru 0.12.x` | RUSTSEC-2026-0002 unsound IterMut | Warning | In `libp2p-swarm`; the specific unsafe code path is internal to libp2p and not reachable through gitlawb's usage. No upstream fix yet. |
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Dependabot alerts | Current open Rust alerts were addressed by updating vulnerable dependencies, removing libp2p mDNS, and moving P2P transport from TCP/Yamux to QUIC/UDP. |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (Planned for v0.2)
|
||||
|
||||
These are **documented, accepted limitations** for the current testnet release. They will be addressed before mainnet.
|
||||
These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades.
|
||||
|
||||
### UCAN chain validation
|
||||
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
|
||||
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
|
||||
- **Mitigation:** Nodes can set `GITLAWB_PUBLIC_READ=false` to restrict access. The v0.1 trust score system provides soft rate limiting.
|
||||
- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### UCAN revocation
|
||||
- Issued UCAN tokens cannot be revoked before expiry.
|
||||
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
|
||||
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Nodes will reject the old DID on sight if manually blocklisted.
|
||||
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Until revocation/blocklisting is implemented, operators should remove compromised DIDs directly from their local database.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### git-receive-pack authentication
|
||||
- The `git-receive-pack` endpoint enforces HTTP Signature auth via the `git-remote-gitlawb` remote helper. Direct HTTP push without the helper is currently unauthenticated on v0.1 nodes.
|
||||
- **Impact:** A node operator accepting direct HTTP git pushes (not via `gitlawb://`) cannot verify the pusher's identity.
|
||||
- **Mitigation:** Use `gitlawb://` remote URLs. Do not expose `POST /*/git-receive-pack` to untrusted networks.
|
||||
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
|
||||
- **Impact:** Direct HTTP pushes without RFC 9421 headers are rejected; users need `gitlawb://` remotes or equivalent signed clients.
|
||||
- **Mitigation:** Use `gitlawb://` remote URLs and keep `git-remote-gitlawb` on the user's `PATH`.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### Private repository reads
|
||||
- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release.
|
||||
- **Impact:** Do not store private repositories or secrets on public nodes.
|
||||
- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer.
|
||||
- **Fix target:** v0.2
|
||||
|
||||
### Peer route hardening rollout
|
||||
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
|
||||
- **Impact:** Unsigned peer writes are still accepted by default so existing live nodes can keep communicating during rolling upgrades.
|
||||
- **Mitigation:** After all active peers run signed-node builds, operators can set `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true`.
|
||||
- **Fix target:** staged rollout
|
||||
|
||||
---
|
||||
|
||||
## Supported Versions
|
||||
|
||||
+26
-2
@@ -1,16 +1,40 @@
|
||||
{
|
||||
"$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-05-16",
|
||||
"updated": "2026-06-30",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"operator": "Gitlawb (Kevin)",
|
||||
"operator": "Gitlawb",
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Dependency allowlist for the `gitlawb-core` crate.
|
||||
#
|
||||
# gitlawb-core is the shared-primitives crate every consumer embeds (the `gl`
|
||||
# CLI, git-remote-gitlawb, the node daemon). It must stay lean so those
|
||||
# consumers do not inherit daemon-weight dependencies. This file is the source
|
||||
# of truth for the crates gitlawb-core's NORMAL (non-dev, non-build) transitive
|
||||
# dependency closure is allowed to contain. CI (`scripts/check-gitlawb-core-deps.sh`,
|
||||
# wired into the `core-deps-purity` job in .github/workflows/pr-checks.yml)
|
||||
# fails if gitlawb-core's tree contains any crate not listed here.
|
||||
#
|
||||
# The allowlist is exhaustive-by-construction: a NEW dependency reds CI whether
|
||||
# or not anyone thought to ban it, which a denylist cannot do.
|
||||
#
|
||||
# Measured in-workspace, so feature unification across the workspace can, in
|
||||
# rare cases, surface a crate here that gitlawb-core would not pull standalone.
|
||||
# An unexpected new entry is a prompt to check what pulled it in, not noise to
|
||||
# rubber-stamp.
|
||||
#
|
||||
# Regenerate (from repo root, after an INTENTIONAL dependency change):
|
||||
# cargo tree -p gitlawb-core --edges normal --prefix none \
|
||||
# | sed -E 's/ v[0-9].*$//' \
|
||||
# | grep -v '^gitlawb-core$' \
|
||||
# | sort -u
|
||||
# then paste the result below this header block. (The checker snapshots and
|
||||
# restores Cargo.lock; a manual regen may refresh it — `git checkout Cargo.lock`
|
||||
# afterward if you did not intend that.)
|
||||
#
|
||||
# Lines starting with `#` and blank lines are ignored by the checker.
|
||||
aead
|
||||
anyhow
|
||||
base-x
|
||||
base256emoji
|
||||
base64
|
||||
base64ct
|
||||
block-buffer
|
||||
cfg-if
|
||||
chacha20
|
||||
chacha20poly1305
|
||||
chrono
|
||||
cid
|
||||
cipher
|
||||
const-oid
|
||||
const-str
|
||||
core2
|
||||
cpufeatures
|
||||
crypto-common
|
||||
crypto_box
|
||||
crypto_secretbox
|
||||
curve25519-dalek
|
||||
curve25519-dalek-derive
|
||||
data-encoding
|
||||
data-encoding-macro
|
||||
data-encoding-macro-internal
|
||||
der
|
||||
digest
|
||||
ed25519
|
||||
ed25519-dalek
|
||||
equivalent
|
||||
generic-array
|
||||
getrandom
|
||||
hashbrown
|
||||
hex
|
||||
iana-time-zone
|
||||
indexmap
|
||||
inout
|
||||
itoa
|
||||
libc
|
||||
match-lookup
|
||||
memchr
|
||||
multibase
|
||||
multihash
|
||||
multihash-codetable
|
||||
multihash-derive
|
||||
multihash-derive-impl
|
||||
num-traits
|
||||
opaque-debug
|
||||
pem-rfc7468
|
||||
pkcs8
|
||||
poly1305
|
||||
ppv-lite86
|
||||
proc-macro-crate
|
||||
proc-macro2
|
||||
quote
|
||||
rand
|
||||
rand_chacha
|
||||
rand_core
|
||||
salsa20
|
||||
serde
|
||||
serde_core
|
||||
serde_derive
|
||||
serde_json
|
||||
sha2
|
||||
signature
|
||||
spki
|
||||
subtle
|
||||
syn
|
||||
synstructure
|
||||
thiserror
|
||||
thiserror-impl
|
||||
toml_datetime
|
||||
toml_edit
|
||||
toml_parser
|
||||
typenum
|
||||
unicode-ident
|
||||
universal-hash
|
||||
unsigned-varint
|
||||
uuid
|
||||
winnow
|
||||
zerocopy
|
||||
zeroize
|
||||
zeroize_derive
|
||||
zmij
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "git-remote-gitlawb"
|
||||
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
|
||||
version.workspace = true
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -15,3 +16,6 @@ anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "gitlawb-attest"
|
||||
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_jcs = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
multibase = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gitlawb-core = { path = "../gitlawb-core" }
|
||||
rand = { workspace = true }
|
||||
@@ -0,0 +1,557 @@
|
||||
//! One typed, signed, cert-bound provenance blob.
|
||||
//!
|
||||
//! Fields: `type` (discriminator), `payload` (opaque, type-specific JSON),
|
||||
//! `cert_hash` (binds to one ref-update cert), `signer` (`did:key`), and
|
||||
//! `sig` (base64url-no-pad ed25519).
|
||||
//!
|
||||
//! The signature covers the domain-separated input
|
||||
//! `b"gitlawb-attest-sig/v1\n"` followed by JCS-encoded `{type, payload,
|
||||
//! cert_hash}` per RFC 8785. The domain tag is in the byte stream rather
|
||||
//! than the canonical JSON so the wire shape stays additive: an `Attestation`
|
||||
//! deserialized today and one constructed by a future `v2` signer differ only
|
||||
//! by which tag the verifier prepends.
|
||||
//!
|
||||
//! Type discriminators are slash-separated namespace + version strings
|
||||
//! (`covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`); verifiers register
|
||||
//! by exact match.
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Domain-separation tag prepended to the JCS signing input. Bumped to
|
||||
/// `v2` if the signing-input shape changes.
|
||||
const SIGNING_DOMAIN: &[u8] = b"gitlawb-attest-sig/v1\n";
|
||||
|
||||
/// Multicodec varint prefix for Ed25519 public keys (0xed 0x01).
|
||||
const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
|
||||
|
||||
/// A signed provenance blob attached to a ref-update cert.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Attestation {
|
||||
/// Type discriminator, e.g. `covenant/exec/v1`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
|
||||
/// Type-specific payload. The verifier reparses into its concrete shape.
|
||||
pub payload: serde_json::Value,
|
||||
|
||||
/// Lowercase SHA-256 hex of the cert body. Binds this attestation to one
|
||||
/// cert. The lowercase form is canonical because the JCS signing input
|
||||
/// includes this field verbatim — accepting mixed case on the wire would
|
||||
/// require both signer and verifier to normalize before computing the
|
||||
/// JCS bytes, an easy spec rule to miss across language ports.
|
||||
pub cert_hash: String,
|
||||
|
||||
/// `did:key` of the signer; the verifying key is recoverable from it.
|
||||
pub signer: String,
|
||||
|
||||
/// base64url-no-pad ed25519 signature over the JCS signing input.
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
/// Type-specific payload. Implement on a struct that is `Serialize +
|
||||
/// DeserializeOwned` to participate.
|
||||
pub trait AttestationPayload: Serialize + DeserializeOwned + Send + Sync {
|
||||
/// The discriminator string written on the wire.
|
||||
fn payload_type() -> &'static str;
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SigningInput<'a> {
|
||||
#[serde(rename = "type")]
|
||||
type_: &'a str,
|
||||
payload: &'a serde_json::Value,
|
||||
cert_hash: &'a str,
|
||||
}
|
||||
|
||||
impl Attestation {
|
||||
/// Sign a fresh attestation. `cert_hash_bytes` comes from
|
||||
/// [`crate::cert::cert_hash`].
|
||||
pub fn sign<P: AttestationPayload>(
|
||||
signing_key: &SigningKey,
|
||||
payload: P,
|
||||
cert_hash_bytes: [u8; 32],
|
||||
) -> Result<Self> {
|
||||
let type_ = P::payload_type().to_string();
|
||||
validate_type(&type_)?;
|
||||
let payload_value = serde_json::to_value(payload)?;
|
||||
let cert_hash_hex = hex::encode(cert_hash_bytes);
|
||||
|
||||
let bytes = canonical_signing_bytes(&type_, &payload_value, &cert_hash_hex)?;
|
||||
let sig: Signature = signing_key.sign(&bytes);
|
||||
|
||||
Ok(Self {
|
||||
type_,
|
||||
payload: payload_value,
|
||||
cert_hash: cert_hash_hex,
|
||||
signer: did_key_from_verifying_key(&signing_key.verifying_key()),
|
||||
sig: B64U.encode(sig.to_bytes()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify the signature and check that `cert_hash` matches
|
||||
/// `expected_cert_hash`. Returns the recovered verifying key so the caller
|
||||
/// can check it against an allowlist.
|
||||
pub fn verify_signature(&self, expected_cert_hash: [u8; 32]) -> Result<VerifyingKey> {
|
||||
let expected_hex = hex::encode(expected_cert_hash);
|
||||
if self.cert_hash != expected_hex {
|
||||
return Err(Error::CertHashMismatch);
|
||||
}
|
||||
|
||||
validate_type(&self.type_)?;
|
||||
let bytes = canonical_signing_bytes(&self.type_, &self.payload, &self.cert_hash)?;
|
||||
|
||||
let vk = verifying_key_from_did_key(&self.signer)?;
|
||||
let sig_bytes: [u8; 64] = B64U
|
||||
.decode(&self.sig)
|
||||
.map_err(|e| Error::Signature(format!("base64url: {e}")))?
|
||||
.try_into()
|
||||
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
|
||||
let sig = Signature::from_bytes(&sig_bytes);
|
||||
vk.verify(&bytes, &sig)
|
||||
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
|
||||
|
||||
Ok(vk)
|
||||
}
|
||||
|
||||
/// Reparse `payload` as `P`. Errors if the type discriminator does not
|
||||
/// match `P::payload_type()`.
|
||||
pub fn payload_as<P: AttestationPayload>(&self) -> Result<P> {
|
||||
if self.type_ != P::payload_type() {
|
||||
return Err(Error::Type(format!(
|
||||
"expected '{}', got '{}'",
|
||||
P::payload_type(),
|
||||
self.type_
|
||||
)));
|
||||
}
|
||||
Ok(P::deserialize(&self.payload)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Discriminator grammar: nonempty, ASCII-graphic only, slash-separated
|
||||
/// segments of `[a-zA-Z0-9._+-]`, no empty segment, capped at 128 bytes.
|
||||
/// Matches `covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`.
|
||||
fn validate_type(t: &str) -> Result<()> {
|
||||
if t.is_empty() {
|
||||
return Err(Error::Type("empty discriminator".to_string()));
|
||||
}
|
||||
if t.len() > 128 {
|
||||
return Err(Error::Type(format!(
|
||||
"discriminator longer than 128 bytes ({})",
|
||||
t.len()
|
||||
)));
|
||||
}
|
||||
if !t.is_ascii() {
|
||||
return Err(Error::Type(format!("discriminator must be ASCII: '{t}'")));
|
||||
}
|
||||
for segment in t.split('/') {
|
||||
if segment.is_empty() {
|
||||
return Err(Error::Type(format!(
|
||||
"discriminator has an empty segment: '{t}'"
|
||||
)));
|
||||
}
|
||||
for c in segment.chars() {
|
||||
if !(c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '+' || c == '-') {
|
||||
return Err(Error::Type(format!(
|
||||
"discriminator segment '{segment}' has disallowed character '{c}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_signing_bytes(
|
||||
type_: &str,
|
||||
payload: &serde_json::Value,
|
||||
cert_hash_hex: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let input = SigningInput {
|
||||
type_,
|
||||
payload,
|
||||
cert_hash: cert_hash_hex,
|
||||
};
|
||||
let jcs = serde_jcs::to_vec(&input).map_err(|e| Error::Jcs(e.to_string()))?;
|
||||
let mut out = Vec::with_capacity(SIGNING_DOMAIN.len() + jcs.len());
|
||||
out.extend_from_slice(SIGNING_DOMAIN);
|
||||
out.extend_from_slice(&jcs);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
|
||||
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&key.to_bytes());
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &buf);
|
||||
format!("did:key:{encoded}")
|
||||
}
|
||||
|
||||
/// The DID:key spec mandates base58btc (multibase `z`-prefix). Other multibase
|
||||
/// encodings of the same key bytes are rejected so allowlists, dedup, and
|
||||
/// signer-pinning policies that compare `signer` strings remain consistent
|
||||
/// across peers.
|
||||
fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
|
||||
let method_id = did
|
||||
.strip_prefix("did:key:")
|
||||
.ok_or_else(|| Error::Did(format!("not a did:key: {did}")))?;
|
||||
if !method_id.starts_with('z') {
|
||||
return Err(Error::Did(format!(
|
||||
"did:key must use base58btc (z-prefix): {did}"
|
||||
)));
|
||||
}
|
||||
let (base, bytes) =
|
||||
multibase::decode(method_id).map_err(|e| Error::Did(format!("multibase: {e}")))?;
|
||||
if base != multibase::Base::Base58Btc {
|
||||
return Err(Error::Did(format!(
|
||||
"did:key must use base58btc (z-prefix): {did}"
|
||||
)));
|
||||
}
|
||||
if bytes.len() != ED25519_MULTICODEC.len() + 32
|
||||
|| bytes[..ED25519_MULTICODEC.len()] != ED25519_MULTICODEC
|
||||
{
|
||||
return Err(Error::Did("not an ed25519 did:key".to_string()));
|
||||
}
|
||||
let key_bytes: [u8; 32] = bytes[ED25519_MULTICODEC.len()..]
|
||||
.try_into()
|
||||
.expect("length checked above");
|
||||
VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn fresh() -> SigningKey {
|
||||
SigningKey::generate(&mut OsRng)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
struct DummyPayload {
|
||||
agent: String,
|
||||
commit: String,
|
||||
}
|
||||
|
||||
impl AttestationPayload for DummyPayload {
|
||||
fn payload_type() -> &'static str {
|
||||
"test/dummy/v1"
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_cert_hash() -> [u8; 32] {
|
||||
let mut h = [0u8; 32];
|
||||
for (i, b) in h.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn dummy_attestation(sk: &SigningKey, cert_hash: [u8; 32]) -> Attestation {
|
||||
Attestation::sign(
|
||||
sk,
|
||||
DummyPayload {
|
||||
agent: "a".to_string(),
|
||||
commit: "b".to_string(),
|
||||
},
|
||||
cert_hash,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_then_verify_roundtrip() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_cert_hash();
|
||||
let payload = DummyPayload {
|
||||
agent: "did:key:z6MkTest".to_string(),
|
||||
commit: "deadbeef".to_string(),
|
||||
};
|
||||
let att = Attestation::sign(&sk, payload.clone(), cert_hash).unwrap();
|
||||
let vk = att.verify_signature(cert_hash).unwrap();
|
||||
assert_eq!(vk.to_bytes(), sk.verifying_key().to_bytes());
|
||||
assert_eq!(att.payload_as::<DummyPayload>().unwrap(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_cert_replay_fails() {
|
||||
let sk = fresh();
|
||||
let cert_a = sample_cert_hash();
|
||||
let mut cert_b = sample_cert_hash();
|
||||
cert_b[0] ^= 0xff;
|
||||
let att = dummy_attestation(&sk, cert_a);
|
||||
let err = att.verify_signature(cert_b).unwrap_err();
|
||||
assert!(matches!(err, Error::CertHashMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_payload_fails_verify() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_cert_hash();
|
||||
let mut att = dummy_attestation(&sk, cert_hash);
|
||||
att.payload = serde_json::json!({ "agent": "evil", "commit": "b" });
|
||||
let err = att.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_as_wrong_type_errors() {
|
||||
let sk = fresh();
|
||||
let att = dummy_attestation(&sk, sample_cert_hash());
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Wrong {
|
||||
#[allow(dead_code)]
|
||||
x: String,
|
||||
}
|
||||
impl AttestationPayload for Wrong {
|
||||
fn payload_type() -> &'static str {
|
||||
"test/other/v1"
|
||||
}
|
||||
}
|
||||
let err = att.payload_as::<Wrong>().unwrap_err();
|
||||
assert!(matches!(err, Error::Type(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip_preserves_signature() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_cert_hash();
|
||||
let att = dummy_attestation(&sk, cert_hash);
|
||||
let json = serde_json::to_string(&att).unwrap();
|
||||
let back: Attestation = serde_json::from_str(&json).unwrap();
|
||||
back.verify_signature(cert_hash).unwrap();
|
||||
}
|
||||
|
||||
/// Wire shape is part of the public protocol — pin field names so an
|
||||
/// accidental rename surfaces as a test failure, not silent interop drift.
|
||||
#[test]
|
||||
fn wire_shape_pins_field_names() {
|
||||
let sk = fresh();
|
||||
let att = dummy_attestation(&sk, sample_cert_hash());
|
||||
let v: serde_json::Value = serde_json::to_value(&att).unwrap();
|
||||
let obj = v.as_object().expect("attestation must serialize as object");
|
||||
let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
|
||||
let expected: std::collections::BTreeSet<&str> =
|
||||
["type", "payload", "cert_hash", "signer", "sig"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(keys, expected);
|
||||
assert!(obj["type"].is_string());
|
||||
assert!(obj["cert_hash"].is_string());
|
||||
assert!(obj["signer"].is_string());
|
||||
assert!(obj["sig"].is_string());
|
||||
}
|
||||
|
||||
/// The spec mandates lowercase hex on the wire. An uppercase cert_hash
|
||||
/// is rejected as `CertHashMismatch` so a non-canonical signer can't
|
||||
/// produce attestations that verify against some peers but not others.
|
||||
#[test]
|
||||
fn uppercase_cert_hash_is_rejected_as_non_canonical() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_cert_hash();
|
||||
let mut att = dummy_attestation(&sk, cert_hash);
|
||||
att.cert_hash = att.cert_hash.to_uppercase();
|
||||
let err = att.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::CertHashMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_type_rejected_at_signing() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Empty {}
|
||||
impl AttestationPayload for Empty {
|
||||
fn payload_type() -> &'static str {
|
||||
""
|
||||
}
|
||||
}
|
||||
let sk = fresh();
|
||||
let err = Attestation::sign(&sk, Empty {}, sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Type(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_type_grammar() {
|
||||
validate_type("covenant/exec/v1").unwrap();
|
||||
validate_type("slsa/v1.0").unwrap();
|
||||
validate_type("sigstore/dsse/v1").unwrap();
|
||||
validate_type("a").unwrap();
|
||||
validate_type("a.b-c_d+e/x").unwrap();
|
||||
|
||||
for bad in [
|
||||
"",
|
||||
" ",
|
||||
"covenant/exec/v1 ",
|
||||
"covenant//v1",
|
||||
"/covenant/v1",
|
||||
"covenant/v1/",
|
||||
"covenant\x00exec/v1",
|
||||
"covenant/exec\nv1",
|
||||
"сovenant/exec/v1",
|
||||
"covenant/ex ec/v1",
|
||||
"covenant/exec/v1#frag",
|
||||
] {
|
||||
let err = validate_type(bad).unwrap_err();
|
||||
assert!(matches!(err, Error::Type(_)), "must reject '{bad}'");
|
||||
}
|
||||
|
||||
let too_long = "a/".repeat(70);
|
||||
let err = validate_type(&too_long).unwrap_err();
|
||||
assert!(matches!(err, Error::Type(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_key_must_be_base58btc() {
|
||||
// Sign with a real key, then re-encode the public-key bytes in a
|
||||
// different multibase. Verification must reject.
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
let mut buf = Vec::with_capacity(34);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
buf.extend_from_slice(&sk.verifying_key().to_bytes());
|
||||
for base in [
|
||||
multibase::Base::Base64Url,
|
||||
multibase::Base::Base32Lower,
|
||||
multibase::Base::Base16Lower,
|
||||
] {
|
||||
att.signer = format!("did:key:{}", multibase::encode(base, &buf));
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Did(_)), "must reject base {base:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_did_without_did_key_prefix() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
att.signer = "ed25519:abc".to_string();
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Did(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_did_with_wrong_multicodec() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
// 0x12 0x00 = sha2-256 multicodec — wrong for an ed25519 public key.
|
||||
let mut buf = vec![0x12, 0x00];
|
||||
buf.extend_from_slice(&sk.verifying_key().to_bytes());
|
||||
att.signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Did(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_did_with_wrong_key_length() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
let mut buf = Vec::with_capacity(33);
|
||||
buf.extend_from_slice(&ED25519_MULTICODEC);
|
||||
// 31 bytes instead of 32.
|
||||
buf.extend_from_slice(&sk.verifying_key().to_bytes()[..31]);
|
||||
att.signer = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &buf)
|
||||
);
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Did(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_bad_signature_base64() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
att.sig = "!!not-base64!!".to_string();
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_signature_wrong_length() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
att.sig = B64U.encode([0u8; 32]); // valid base64, wrong size.
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
|
||||
/// A payload that happens to contain a `cert_hash` field of its own does
|
||||
/// not interfere with the outer binding: the attestation envelope's
|
||||
/// `cert_hash` is the only field consulted by `verify_signature`, and the
|
||||
/// payload is treated as opaque JSON throughout. A reviewer worried about
|
||||
/// shadowing or smuggling can verify this directly.
|
||||
#[test]
|
||||
fn payload_cert_hash_field_does_not_shadow_outer_binding() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Sneaky {
|
||||
cert_hash: String,
|
||||
x: String,
|
||||
}
|
||||
impl AttestationPayload for Sneaky {
|
||||
fn payload_type() -> &'static str {
|
||||
"test/sneaky/v1"
|
||||
}
|
||||
}
|
||||
|
||||
let sk = fresh();
|
||||
let outer_hash = sample_cert_hash();
|
||||
let att = Attestation::sign(
|
||||
&sk,
|
||||
Sneaky {
|
||||
cert_hash: "forged-by-the-attacker".to_string(),
|
||||
x: "y".to_string(),
|
||||
},
|
||||
outer_hash,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Envelope's cert_hash is the outer (real) hash, lowercase hex.
|
||||
assert_eq!(att.cert_hash, hex::encode(outer_hash));
|
||||
// Payload retains its own field — opaque to the protocol.
|
||||
assert_eq!(
|
||||
att.payload.get("cert_hash").and_then(|v| v.as_str()),
|
||||
Some("forged-by-the-attacker"),
|
||||
);
|
||||
// Verification binds to the outer envelope's cert_hash.
|
||||
att.verify_signature(outer_hash).unwrap();
|
||||
// And rejects when the outer cert hash differs.
|
||||
let mut wrong = outer_hash;
|
||||
wrong[0] ^= 0xff;
|
||||
let err = att.verify_signature(wrong).unwrap_err();
|
||||
assert!(matches!(err, Error::CertHashMismatch));
|
||||
}
|
||||
|
||||
/// A different protocol that also signs `{type, payload, cert_hash}` with
|
||||
/// the same key would still fail attestation verification because the
|
||||
/// signing input carries a domain tag the other protocol does not include.
|
||||
#[test]
|
||||
fn cross_protocol_replay_is_rejected_by_domain_separation() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_cert_hash();
|
||||
let att = dummy_attestation(&sk, cert_hash);
|
||||
|
||||
// Sign the same JCS bytes WITHOUT the domain tag and graft the
|
||||
// signature back onto the attestation.
|
||||
let cert_hash_hex = hex::encode(cert_hash);
|
||||
let bare = serde_jcs::to_vec(&SigningInput {
|
||||
type_: &att.type_,
|
||||
payload: &att.payload,
|
||||
cert_hash: &cert_hash_hex,
|
||||
})
|
||||
.unwrap();
|
||||
let other_sig: Signature = sk.sign(&bare);
|
||||
let mut tampered = att.clone();
|
||||
tampered.sig = B64U.encode(other_sig.to_bytes());
|
||||
|
||||
let err = tampered.verify_signature(cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Signature(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Envelope around a `gitlawb/ref-update/v1` cert plus optional attestations,
|
||||
//! and the cert-hash helper attestations bind to.
|
||||
//!
|
||||
//! An empty envelope serializes to the same bytes as the underlying bare cert.
|
||||
//! `cert_hash` strips `signatures` and `attestations`, JCS-encodes the rest,
|
||||
//! and SHA-256s; the result is what every attestation binds to.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::attestation::Attestation;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::verifier::{Registry, VerifiedAttestation};
|
||||
|
||||
/// The discriminator the wrapped cert must carry. Matches the constant of the
|
||||
/// same name in `gitlawb-core::cert`.
|
||||
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
|
||||
|
||||
/// A ref-update cert with optional attestations.
|
||||
///
|
||||
/// The cert is flattened into the top-level JSON, so an empty envelope
|
||||
/// produces the same bytes as a bare cert and existing decoders ignore the
|
||||
/// extra `attestations` field. The cert is held as `serde_json::Value` so
|
||||
/// this crate has no runtime dependency on `gitlawb-core::cert::RefUpdateCert`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttestedRefUpdateCert {
|
||||
/// The cert body and signatures, flattened to the top level.
|
||||
#[serde(flatten)]
|
||||
pub cert: serde_json::Value,
|
||||
|
||||
/// Attached attestations. Empty when absent on the wire.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attestations: Vec<Attestation>,
|
||||
}
|
||||
|
||||
impl AttestedRefUpdateCert {
|
||||
/// Wrap a cert. No attestations are attached yet.
|
||||
///
|
||||
/// The serialized cert must be a JSON object whose `type` field equals
|
||||
/// [`CERT_TYPE`] and which does **not** already carry an `attestations`
|
||||
/// field. The second rule catches two cases that would otherwise leave
|
||||
/// the envelope in an inconsistent in-memory state:
|
||||
///
|
||||
/// - Double-wrap: `from_cert(&previously_wrapped_envelope)` would put the
|
||||
/// inner attestations on `self.cert` and leave `self.attestations` empty.
|
||||
/// - Pre-attested cert from a peer: a malformed or malicious cert with an
|
||||
/// attached `attestations` field would survive `cert_hash` (which strips
|
||||
/// the field) but bypass the wrapper's append-and-verify path entirely.
|
||||
///
|
||||
/// Both are rejected with [`Error::Payload`]. To re-wrap, deserialize the
|
||||
/// envelope as `AttestedRefUpdateCert` directly.
|
||||
pub fn from_cert<T: Serialize>(cert: &T) -> Result<Self> {
|
||||
let value = serde_json::to_value(cert)?;
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Payload("cert must serialize as a JSON object".to_string()))?;
|
||||
match obj.get("type").and_then(|v| v.as_str()) {
|
||||
Some(t) if t == CERT_TYPE => {}
|
||||
Some(other) => {
|
||||
return Err(Error::Payload(format!(
|
||||
"cert type must be '{CERT_TYPE}', got '{other}'"
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(Error::Payload(format!(
|
||||
"cert missing 'type' field (expected '{CERT_TYPE}')"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if obj.contains_key("attestations") {
|
||||
return Err(Error::Payload(
|
||||
"cert already carries an 'attestations' field; deserialize as \
|
||||
AttestedRefUpdateCert directly instead of re-wrapping"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
cert: value,
|
||||
attestations: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The 32-byte cert hash every attached attestation must bind to.
|
||||
pub fn cert_hash(&self) -> Result<[u8; 32]> {
|
||||
cert_hash(&self.cert)
|
||||
}
|
||||
|
||||
/// Append an already-signed attestation.
|
||||
pub fn attach(&mut self, attestation: Attestation) {
|
||||
self.attestations.push(attestation);
|
||||
}
|
||||
|
||||
/// Verify every attached attestation against this envelope's cert hash,
|
||||
/// applying the registry's policy. Equivalent to
|
||||
/// `registry.verify_all(&self.attestations, self.cert_hash()?)` but
|
||||
/// reads as one operation at call sites.
|
||||
pub fn verify_attestations(&self, registry: &Registry) -> Result<Vec<VerifiedAttestation>> {
|
||||
registry.verify_all(&self.attestations, self.cert_hash()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 of the cert body. The body must be a JSON object; `signatures` and
|
||||
/// `attestations` are stripped before JCS encoding (RFC 8785), so the hash is
|
||||
/// reproducible across languages and JSON libraries and is stable across
|
||||
/// countersignatures.
|
||||
pub fn cert_hash(cert: &serde_json::Value) -> Result<[u8; 32]> {
|
||||
let mut body = cert.clone();
|
||||
let obj = body
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| Error::Payload("cert body must be a JSON object".to_string()))?;
|
||||
obj.remove("signatures");
|
||||
obj.remove("attestations");
|
||||
let bytes = serde_jcs::to_vec(&body).map_err(|e| Error::Jcs(e.to_string()))?;
|
||||
let mut h = Sha256::new();
|
||||
h.update(&bytes);
|
||||
let out = h.finalize();
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&out);
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn bare_cert() -> serde_json::Value {
|
||||
json!({
|
||||
"type": CERT_TYPE,
|
||||
"repo": "did:key:z6MkRepo",
|
||||
"ref_name": "refs/heads/main",
|
||||
"from": "0".repeat(64),
|
||||
"to": "a".repeat(64),
|
||||
"seq": 1,
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"nonce": "n1",
|
||||
"signatures": [{"signer": "did:key:z6MkRepo", "sig": "abc"}]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_envelope_serializes_as_bare_cert() {
|
||||
let bare = bare_cert();
|
||||
let env = AttestedRefUpdateCert::from_cert(&bare).unwrap();
|
||||
let env_json: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
|
||||
|
||||
let bare_keys: std::collections::BTreeSet<&String> =
|
||||
bare.as_object().unwrap().keys().collect();
|
||||
let env_keys: std::collections::BTreeSet<&String> =
|
||||
env_json.as_object().unwrap().keys().collect();
|
||||
assert_eq!(bare_keys, env_keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_cert_parses_as_envelope_with_no_attestations() {
|
||||
let bare = bare_cert();
|
||||
let env: AttestedRefUpdateCert = serde_json::from_value(bare.clone()).unwrap();
|
||||
assert!(env.attestations.is_empty());
|
||||
assert_eq!(env.cert, bare);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_hash_is_stable_across_countersignatures() {
|
||||
let base_body = json!({
|
||||
"type": CERT_TYPE,
|
||||
"repo": "did:key:z6MkRepo",
|
||||
"ref_name": "refs/heads/main",
|
||||
"from": "0".repeat(64),
|
||||
"to": "a".repeat(64),
|
||||
"seq": 1,
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"nonce": "n1"
|
||||
});
|
||||
|
||||
let mut one_sig = base_body.clone();
|
||||
one_sig["signatures"] = json!([{"signer": "x", "sig": "y"}]);
|
||||
|
||||
let mut two_sigs = base_body.clone();
|
||||
two_sigs["signatures"] = json!([
|
||||
{"signer": "x", "sig": "y"},
|
||||
{"signer": "a", "sig": "b"}
|
||||
]);
|
||||
|
||||
assert_eq!(cert_hash(&one_sig).unwrap(), cert_hash(&two_sigs).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_hash_changes_when_body_changes() {
|
||||
let body_a = json!({
|
||||
"type": CERT_TYPE,
|
||||
"repo": "did:key:z6MkRepo",
|
||||
"ref_name": "refs/heads/main",
|
||||
"from": "0".repeat(64),
|
||||
"to": "a".repeat(64),
|
||||
"seq": 1,
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"nonce": "n1",
|
||||
"signatures": []
|
||||
});
|
||||
let mut body_b = body_a.clone();
|
||||
body_b["to"] = json!("b".repeat(64));
|
||||
|
||||
assert_ne!(cert_hash(&body_a).unwrap(), cert_hash(&body_b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_hash_rejects_non_object_root() {
|
||||
let err = cert_hash(&json!(null)).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
let err = cert_hash(&json!([1, 2, 3])).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
let err = cert_hash(&json!("scalar")).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_cert_rejects_wrong_type() {
|
||||
let wrong = json!({"type": "gitlawb/ref-update/v0", "repo": "did:key:z"});
|
||||
let err = AttestedRefUpdateCert::from_cert(&wrong).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_cert_rejects_missing_type() {
|
||||
let no_type = json!({"repo": "did:key:z"});
|
||||
let err = AttestedRefUpdateCert::from_cert(&no_type).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_cert_rejects_non_object() {
|
||||
let arr = json!([1, 2, 3]);
|
||||
let err = AttestedRefUpdateCert::from_cert(&arr).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutating_env_cert_changes_the_hash() {
|
||||
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
|
||||
let hash_before = env.cert_hash().unwrap();
|
||||
env.cert.as_object_mut().unwrap().insert(
|
||||
"nonce".to_string(),
|
||||
serde_json::Value::String("mutated".to_string()),
|
||||
);
|
||||
let hash_after = env.cert_hash().unwrap();
|
||||
assert_ne!(hash_before, hash_after);
|
||||
}
|
||||
|
||||
/// A cert that already carries an `attestations` field is rejected
|
||||
/// regardless of the field's contents. Catches double-wrap (where the
|
||||
/// inner attestations would otherwise be smuggled into `self.cert`) and
|
||||
/// peer-supplied "pre-attested" certs that would bypass the wrapper.
|
||||
#[test]
|
||||
fn from_cert_rejects_preexisting_attestations_field() {
|
||||
let mut sneaky = bare_cert();
|
||||
sneaky["attestations"] = json!([{"smuggled": true}]);
|
||||
let err = AttestedRefUpdateCert::from_cert(&sneaky).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
|
||||
// Empty array still rejected — the field's mere presence is the signal.
|
||||
let mut empty = bare_cert();
|
||||
empty["attestations"] = json!([]);
|
||||
let err = AttestedRefUpdateCert::from_cert(&empty).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
/// `from_cert` on an already-wrapped envelope is the same case as
|
||||
/// `from_cert` on a cert with a pre-existing `attestations` field: serde
|
||||
/// flattens the envelope back into a JSON object that carries the field.
|
||||
#[test]
|
||||
fn from_cert_rejects_a_previously_wrapped_envelope() {
|
||||
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
|
||||
// Inject an attestation-shaped value so the wire round-trip would
|
||||
// actually re-emit the field.
|
||||
env.cert
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("attestations".to_string(), json!([]));
|
||||
let err = AttestedRefUpdateCert::from_cert(&env).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
/// `attestations: null` is explicitly not the same as missing. The
|
||||
/// `#[serde(default)]` attribute only fills in when the key is absent —
|
||||
/// a present-but-null wire field is rejected, which surfaces malformed
|
||||
/// payloads early instead of silently treating them as empty.
|
||||
#[test]
|
||||
fn attestations_null_on_wire_is_rejected() {
|
||||
let mut with_null = bare_cert();
|
||||
with_null["attestations"] = serde_json::Value::Null;
|
||||
let err = serde_json::from_value::<AttestedRefUpdateCert>(with_null).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("expected a sequence") || msg.contains("invalid type"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attestations_string_on_wire_is_rejected() {
|
||||
let mut with_str = bare_cert();
|
||||
with_str["attestations"] = serde_json::Value::String("oops".to_string());
|
||||
let err = serde_json::from_value::<AttestedRefUpdateCert>(with_str).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("expected a sequence") || msg.contains("invalid type"));
|
||||
}
|
||||
|
||||
/// `attach` does not deduplicate. Duplicate attestations are a verifier-
|
||||
/// policy concern (e.g., "at most one SLSA provenance"), not a wrapper
|
||||
/// concern. Pinning the behavior so a future deduplication change is an
|
||||
/// intentional break.
|
||||
#[test]
|
||||
fn attach_does_not_deduplicate() {
|
||||
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
|
||||
let att = Attestation {
|
||||
type_: "demo/v1".to_string(),
|
||||
payload: serde_json::json!({}),
|
||||
cert_hash: "deadbeef".to_string(),
|
||||
signer: "did:key:z6MkDup".to_string(),
|
||||
sig: "AAAA".to_string(),
|
||||
};
|
||||
env.attach(att.clone());
|
||||
env.attach(att);
|
||||
assert_eq!(env.attestations.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors from building or verifying an attestation.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// Discriminator failed validation (empty, whitespace, control char,
|
||||
/// disallowed grammar).
|
||||
#[error("attestation type: {0}")]
|
||||
Type(String),
|
||||
|
||||
/// Attestation was signed against a different cert.
|
||||
#[error("cert hash mismatch")]
|
||||
CertHashMismatch,
|
||||
|
||||
/// Ed25519 signature failed verification.
|
||||
#[error("signature: {0}")]
|
||||
Signature(String),
|
||||
|
||||
/// DID could not be parsed.
|
||||
#[error("did: {0}")]
|
||||
Did(String),
|
||||
|
||||
/// Payload structure check failed, or the cert body was not a JSON object.
|
||||
#[error("payload: {0}")]
|
||||
Payload(String),
|
||||
|
||||
/// No verifier registered for the type and policy required one.
|
||||
#[error("no verifier registered for type '{0}'")]
|
||||
UnknownType(String),
|
||||
|
||||
/// `RequireAll` policy did not find an entry for a required type, or the
|
||||
/// entry was not fully verified.
|
||||
#[error("required attestation type missing or unverified: '{0}'")]
|
||||
RequiredMissing(String),
|
||||
|
||||
/// JSON failure.
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// JCS encoding failure.
|
||||
#[error("jcs: {0}")]
|
||||
Jcs(String),
|
||||
}
|
||||
|
||||
/// Crate result alias.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Pluggable provenance attachments for gitlawb ref-update certs.
|
||||
//!
|
||||
//! A `gitlawb/ref-update/v1` cert proves who pushed and where a ref moved to.
|
||||
//! It does not say how the commit was produced. This crate adds an optional
|
||||
//! `attestations` field that lets any provenance system (SLSA, Sigstore,
|
||||
//! in-toto, agent runtimes) attach a typed, signed blob bound to the cert by
|
||||
//! its hash.
|
||||
//!
|
||||
//! The cert format stays additive. A cert with no attestations serializes the
|
||||
//! same bytes it does today, and existing decoders (which do not set
|
||||
//! `deny_unknown_fields`) silently drop the new `attestations` field rather
|
||||
//! than reject the cert.
|
||||
//!
|
||||
//! ## Wire shape
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "type": "gitlawb/ref-update/v1",
|
||||
//! // ...standard cert fields...,
|
||||
//! "signatures": [...],
|
||||
//! "attestations": [
|
||||
//! {
|
||||
//! "type": "covenant/exec/v1",
|
||||
//! "payload": { /* opaque, type-specific */ },
|
||||
//! "cert_hash": "<sha256 hex of JCS-encoded cert body>",
|
||||
//! "signer": "did:key:z6Mk...",
|
||||
//! "sig": "<base64url ed25519 over the signing input>"
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Canonical bytes
|
||||
//!
|
||||
//! Hashing and signing inputs use JCS (RFC 8785) so they reproduce across
|
||||
//! implementations regardless of struct field order or JSON library.
|
||||
//!
|
||||
//! The cert hash is `SHA-256(JCS(cert_body_without_signatures_or_attestations))`,
|
||||
//! expressed as lowercase hex on the wire. Uppercase is non-canonical and
|
||||
//! rejected on verify so the JCS signing input is uniquely determined by the
|
||||
//! cert bytes alone — there is no implicit normalization rule that
|
||||
//! cross-language signers might miss.
|
||||
//!
|
||||
//! The attestation signing input is the byte string
|
||||
//! `b"gitlawb-attest-sig/v1\n" || JCS({type, payload, cert_hash})`. The
|
||||
//! domain tag is in the byte stream, not the canonical JSON, so an attestation
|
||||
//! cannot be confused with a same-shape JCS document signed for a different
|
||||
//! protocol that uses the same Ed25519 key.
|
||||
//!
|
||||
//! ## DID:key
|
||||
//!
|
||||
//! Signers are `did:key` DIDs over Ed25519, encoded with the multibase
|
||||
//! base58btc (`z`) prefix per the W3C DID:key specification. Non-base58btc
|
||||
//! encodings of the same public key are rejected on verify so allowlists,
|
||||
//! deduplication, and signer-pinning policies that compare `signer` strings
|
||||
//! stay consistent across peers.
|
||||
//!
|
||||
//! ## Verification
|
||||
//!
|
||||
//! [`Registry`] looks up a verifier by type discriminator. [`Policy`] decides
|
||||
//! what to do when no verifier matches: `AcceptKnown` (default) lets unknown
|
||||
//! types pass without trust; `RequireAll` enforces a per-repo allowlist while
|
||||
//! still letting unrelated attached attestations through unverified (so a
|
||||
//! third-party attachment cannot DoS the cert); `RejectUnknown` rejects
|
||||
//! anything unregistered.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use ed25519_dalek::SigningKey;
|
||||
//! use gitlawb_attest::{
|
||||
//! Attestation, AttestationPayload, AttestationVerifier, AttestedRefUpdateCert,
|
||||
//! Error, Policy, Registry, Result, CERT_TYPE,
|
||||
//! };
|
||||
//! use serde::{Deserialize, Serialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize)]
|
||||
//! struct ExecPayload { agent: String }
|
||||
//! impl AttestationPayload for ExecPayload {
|
||||
//! fn payload_type() -> &'static str { "covenant/exec/v1" }
|
||||
//! }
|
||||
//!
|
||||
//! struct ExecVerifier;
|
||||
//! impl AttestationVerifier for ExecVerifier {
|
||||
//! fn type_(&self) -> &'static str { "covenant/exec/v1" }
|
||||
//! fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> {
|
||||
//! payload.get("agent").and_then(|v| v.as_str())
|
||||
//! .ok_or_else(|| Error::Payload("missing agent".into()))?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! # fn run() -> Result<()> {
|
||||
//! let cert = serde_json::json!({
|
||||
//! "type": CERT_TYPE, "repo": "did:key:z6MkRepo",
|
||||
//! "ref_name": "refs/heads/main",
|
||||
//! "from": "0".repeat(64), "to": "a".repeat(64),
|
||||
//! "seq": 1, "timestamp": "2026-01-01T00:00:00Z", "nonce": "n1",
|
||||
//! "signatures": []
|
||||
//! });
|
||||
//! let mut env = AttestedRefUpdateCert::from_cert(&cert)?;
|
||||
//!
|
||||
//! let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
//! let payload = ExecPayload { agent: "did:key:z6MkAgent".into() };
|
||||
//! let att = Attestation::sign(&sk, payload, env.cert_hash()?)?;
|
||||
//! env.attach(att);
|
||||
//!
|
||||
//! let mut registry = Registry::new().with_policy(Policy::RejectUnknown);
|
||||
//! registry.register(ExecVerifier);
|
||||
//! let verified = env.verify_attestations(®istry)?;
|
||||
//! assert_eq!(verified[0].type_, "covenant/exec/v1");
|
||||
//! assert!(verified[0].fully_verified);
|
||||
//! # Ok(()) }
|
||||
//! # run().unwrap();
|
||||
//! ```
|
||||
|
||||
pub mod attestation;
|
||||
pub mod cert;
|
||||
pub mod error;
|
||||
pub mod verifier;
|
||||
|
||||
pub use attestation::{Attestation, AttestationPayload};
|
||||
pub use cert::{cert_hash, AttestedRefUpdateCert, CERT_TYPE};
|
||||
pub use error::{Error, Result};
|
||||
pub use verifier::{AttestationVerifier, Policy, Registry, VerifiedAttestation};
|
||||
|
||||
/// Version string for the attestation envelope wire shape.
|
||||
///
|
||||
/// Bumped only when the binding rules — JCS input shape, domain-separation
|
||||
/// tag, hash algorithm — change. Independent of [`CERT_TYPE`], which versions
|
||||
/// the underlying ref-update cert.
|
||||
pub const ATTEST_ENVELOPE_VERSION: &str = "v1";
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Verifier trait, registry, and policy.
|
||||
//!
|
||||
//! A [`Registry`] maps a type discriminator to a verifier. [`Policy`] decides
|
||||
//! what happens for types nothing is registered for: accept without trust,
|
||||
//! reject, or require an allowlist.
|
||||
//!
|
||||
//! Under [`Policy::RequireAll`] the registry never short-circuits on unknown
|
||||
//! types — any attestation can be attached by any third party, so blocking
|
||||
//! the cert because an attacker added an unrelated `attacker/spam/v1` would
|
||||
//! be a denial-of-service vector. Unknown types are silently treated as
|
||||
//! `fully_verified = false`; the allowlist is enforced only by the
|
||||
//! presence-and-verified check on `required_types` after the batch runs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::attestation::Attestation;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Verifier for one attestation type. Implementors declare the discriminator
|
||||
/// they handle and validate the payload's shape. The signature and cert-hash
|
||||
/// binding are checked by [`Registry::verify`] before `verify_payload` runs.
|
||||
pub trait AttestationVerifier: Send + Sync {
|
||||
/// The discriminator this verifier handles. Must be a `'static` literal
|
||||
/// so the registry can key on it without copying.
|
||||
fn type_(&self) -> &'static str;
|
||||
|
||||
/// Validate payload structure. Signature is already verified.
|
||||
fn verify_payload(&self, payload: &serde_json::Value) -> Result<()>;
|
||||
}
|
||||
|
||||
/// What to do for attestation types with no registered verifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Policy {
|
||||
/// Unknown types pass but are flagged `fully_verified = false`. Default.
|
||||
#[default]
|
||||
AcceptKnown,
|
||||
|
||||
/// Every entry in `required_types` must be present and fully verified.
|
||||
/// Unknown types beyond the required set are still accepted unverified,
|
||||
/// so a malicious extra attestation cannot DoS the cert.
|
||||
RequireAll,
|
||||
|
||||
/// Reject every attestation whose type is not registered.
|
||||
RejectUnknown,
|
||||
}
|
||||
|
||||
/// Result of verifying one attestation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedAttestation {
|
||||
/// Type discriminator.
|
||||
pub type_: String,
|
||||
/// Signer's `did:key`.
|
||||
pub signer: String,
|
||||
/// Lowercase SHA-256 hex of the cert body the attestation bound to.
|
||||
pub cert_hash: String,
|
||||
/// `true` when a verifier ran a payload check and accepted it. `false`
|
||||
/// when the policy let an unknown type through without one.
|
||||
pub fully_verified: bool,
|
||||
}
|
||||
|
||||
/// Verifiers keyed by type discriminator.
|
||||
#[derive(Default)]
|
||||
pub struct Registry {
|
||||
by_type: HashMap<&'static str, Box<dyn AttestationVerifier>>,
|
||||
policy: Policy,
|
||||
required_types: Vec<String>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Empty registry with the default policy.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a verifier by value. Returns the prior verifier for the same
|
||||
/// type, if any, so callers can detect double-registration.
|
||||
pub fn register<V: AttestationVerifier + 'static>(
|
||||
&mut self,
|
||||
verifier: V,
|
||||
) -> Option<Box<dyn AttestationVerifier>> {
|
||||
self.register_boxed(Box::new(verifier))
|
||||
}
|
||||
|
||||
/// Like [`Self::register`] but takes an already-boxed trait object — useful
|
||||
/// when verifiers are constructed dynamically.
|
||||
pub fn register_boxed(
|
||||
&mut self,
|
||||
verifier: Box<dyn AttestationVerifier>,
|
||||
) -> Option<Box<dyn AttestationVerifier>> {
|
||||
let key = verifier.type_();
|
||||
self.by_type.insert(key, verifier)
|
||||
}
|
||||
|
||||
/// Switch policy.
|
||||
pub fn with_policy(mut self, policy: Policy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Type discriminators that must be present and verified under
|
||||
/// `Policy::RequireAll`. Ignored under other policies.
|
||||
pub fn require_types<I, S>(mut self, types: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
self.required_types = types.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Active policy.
|
||||
pub fn policy(&self) -> Policy {
|
||||
self.policy
|
||||
}
|
||||
|
||||
/// Verify signature, cert-hash binding, and (if a verifier is registered)
|
||||
/// payload structure. Under `RequireAll` this is lenient on unknown types
|
||||
/// — see the module docstring.
|
||||
pub fn verify(
|
||||
&self,
|
||||
attestation: &Attestation,
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<VerifiedAttestation> {
|
||||
attestation.verify_signature(expected_cert_hash)?;
|
||||
|
||||
let fully = match self.by_type.get(attestation.type_.as_str()) {
|
||||
Some(v) => {
|
||||
v.verify_payload(&attestation.payload)?;
|
||||
true
|
||||
}
|
||||
None => match self.policy {
|
||||
Policy::AcceptKnown | Policy::RequireAll => false,
|
||||
Policy::RejectUnknown => {
|
||||
return Err(Error::UnknownType(attestation.type_.clone()));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Ok(VerifiedAttestation {
|
||||
type_: attestation.type_.clone(),
|
||||
signer: attestation.signer.clone(),
|
||||
cert_hash: attestation.cert_hash.clone(),
|
||||
fully_verified: fully,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a batch, then enforce `RequireAll`.
|
||||
pub fn verify_all(
|
||||
&self,
|
||||
attestations: &[Attestation],
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<Vec<VerifiedAttestation>> {
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
}
|
||||
if self.policy == Policy::RequireAll {
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::attestation::{Attestation, AttestationPayload};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn fresh() -> SigningKey {
|
||||
SigningKey::generate(&mut OsRng)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Demo {
|
||||
a: String,
|
||||
}
|
||||
impl AttestationPayload for Demo {
|
||||
fn payload_type() -> &'static str {
|
||||
"demo/v1"
|
||||
}
|
||||
}
|
||||
|
||||
struct DemoVerifier;
|
||||
impl AttestationVerifier for DemoVerifier {
|
||||
fn type_(&self) -> &'static str {
|
||||
"demo/v1"
|
||||
}
|
||||
fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> {
|
||||
payload
|
||||
.get("a")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| Error::Payload("missing or empty 'a'".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A second verifier that has the same discriminator as `DemoVerifier` —
|
||||
/// used to test last-wins on `register`.
|
||||
struct DemoVerifierAlt;
|
||||
impl AttestationVerifier for DemoVerifierAlt {
|
||||
fn type_(&self) -> &'static str {
|
||||
"demo/v1"
|
||||
}
|
||||
fn verify_payload(&self, _payload: &serde_json::Value) -> Result<()> {
|
||||
Err(Error::Payload("alt always rejects".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Other {
|
||||
x: u32,
|
||||
}
|
||||
impl AttestationPayload for Other {
|
||||
fn payload_type() -> &'static str {
|
||||
"other/v1"
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_hash() -> [u8; 32] {
|
||||
let mut h = [0u8; 32];
|
||||
for (i, b) in h.iter_mut().enumerate() {
|
||||
*b = (i + 1) as u8;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn signed_demo(sk: &SigningKey, cert_hash: [u8; 32], a: &str) -> Attestation {
|
||||
Attestation::sign(sk, Demo { a: a.to_string() }, cert_hash).unwrap()
|
||||
}
|
||||
|
||||
fn signed_other(sk: &SigningKey, cert_hash: [u8; 32], x: u32) -> Attestation {
|
||||
Attestation::sign(sk, Other { x }, cert_hash).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_accepts_registered_type() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new();
|
||||
reg.register(DemoVerifier);
|
||||
let att = signed_demo(&sk, cert_hash, "ok");
|
||||
let v = reg.verify(&att, cert_hash).unwrap();
|
||||
assert!(v.fully_verified);
|
||||
assert_eq!(v.type_, "demo/v1");
|
||||
assert_eq!(v.cert_hash, hex::encode(cert_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_known_lets_unknown_pass_unverified() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let reg = Registry::new().with_policy(Policy::AcceptKnown);
|
||||
let att = signed_demo(&sk, cert_hash, "ok");
|
||||
let v = reg.verify(&att, cert_hash).unwrap();
|
||||
assert!(!v.fully_verified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unknown_blocks_unregistered_types() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let reg = Registry::new().with_policy(Policy::RejectUnknown);
|
||||
let att = signed_demo(&sk, cert_hash, "ok");
|
||||
let err = reg.verify(&att, cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::UnknownType(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_all_enforces_presence() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
let err = reg.verify_all(&[], cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
|
||||
let att = signed_demo(&sk, cert_hash, "ok");
|
||||
let v = reg.verify_all(&[att], cert_hash).unwrap();
|
||||
assert_eq!(v.len(), 1);
|
||||
}
|
||||
|
||||
/// Even when a junk attestation of an unrelated type is attached first, a
|
||||
/// `RequireAll` registry must still accept the cert as long as the
|
||||
/// required type is present and fully verified — the attacker shouldn't
|
||||
/// be able to DoS the cert by attaching `attacker/spam/v1` before
|
||||
/// `covenant/exec/v1`.
|
||||
#[test]
|
||||
fn require_all_is_lenient_on_unknown_types_in_the_batch() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
let spam = signed_other(&sk, cert_hash, 7);
|
||||
let real = signed_demo(&sk, cert_hash, "ok");
|
||||
let verified = reg.verify_all(&[spam, real], cert_hash).unwrap();
|
||||
assert_eq!(verified.len(), 2);
|
||||
assert!(verified
|
||||
.iter()
|
||||
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
|
||||
assert!(verified
|
||||
.iter()
|
||||
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
|
||||
}
|
||||
|
||||
/// A required type that is present but unverified (no verifier registered
|
||||
/// even though the type appears in `required_types`) must fail
|
||||
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
|
||||
/// the operator listed a type but forgot to register a verifier.
|
||||
#[test]
|
||||
fn require_all_rejects_present_but_unverified_required_type() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]); // type listed, but no verifier registered.
|
||||
|
||||
let att = signed_demo(&sk, cert_hash, "ok");
|
||||
let err = reg.verify_all(&[att], cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_check_failure_rejects() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new();
|
||||
reg.register(DemoVerifier);
|
||||
// Sign an empty `a` so the signature verifies but the payload check fails.
|
||||
let att = Attestation::sign(&sk, Demo { a: String::new() }, cert_hash).unwrap();
|
||||
let err = reg.verify(&att, cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
|
||||
/// `RequireAll` with an empty allowlist degenerates to "no required types
|
||||
/// to check" — the loop over `required_types` is a no-op, so an empty
|
||||
/// batch verifies successfully. Pinned so operators relying on the empty
|
||||
/// allowlist as a default-permissive RequireAll see consistent behavior.
|
||||
#[test]
|
||||
fn require_all_with_empty_allowlist_and_empty_batch_succeeds() {
|
||||
let reg = Registry::new().with_policy(Policy::RequireAll);
|
||||
let verified = reg.verify_all(&[], sample_hash()).unwrap();
|
||||
assert!(verified.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_returns_the_prior_verifier_on_double_register() {
|
||||
let mut reg = Registry::new();
|
||||
assert!(reg.register(DemoVerifier).is_none());
|
||||
let prior = reg.register(DemoVerifierAlt);
|
||||
let prior = prior.expect("second register must return the previous verifier");
|
||||
assert_eq!(prior.type_(), "demo/v1");
|
||||
|
||||
// Last-wins semantics confirmed by behavior.
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let att = signed_demo(&sk, cert_hash, "ok");
|
||||
let err = reg.verify(&att, cert_hash).unwrap_err();
|
||||
assert!(matches!(err, Error::Payload(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Canonical test vectors.
|
||||
//!
|
||||
//! Ed25519 signatures are deterministic (RFC 8032). JCS canonicalization is
|
||||
//! deterministic (RFC 8785). SHA-256 is deterministic. Given fixed inputs the
|
||||
//! cert hash, signature, signer DID, and full wire JSON are therefore exactly
|
||||
//! reproducible across implementations. This file pins those values so a
|
||||
//! port to Go, JS, Python, or any other language has a concrete oracle to
|
||||
//! check against without reading Rust.
|
||||
//!
|
||||
//! If a vector changes, that is a wire-protocol break. The change is either
|
||||
//! intentional (bump `ATTEST_ENVELOPE_VERSION` and document the migration) or
|
||||
//! a regression that broke interoperability with every other implementation.
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use gitlawb_attest::{Attestation, AttestationPayload, AttestedRefUpdateCert, Registry, CERT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Fixed seed for the attestation signer. Real signers use a CSPRNG; this is a
|
||||
/// test-vector convention only.
|
||||
const SIGNER_SEED: [u8; 32] = [
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
||||
];
|
||||
|
||||
/// Fixed cert body for the vectors. Mirrors the field layout of a
|
||||
/// `gitlawb-core::cert::RefUpdateBody` plus a `signatures` array. The
|
||||
/// signatures array is deliberately non-empty to test that it is stripped
|
||||
/// before hashing.
|
||||
fn fixture_cert_body() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": CERT_TYPE,
|
||||
"repo": "did:key:z6MkpYqTAt8jq3SExeUiVdyTrDdocy9j2NjGxFE6S6ZBh1Yt",
|
||||
"ref_name": "refs/heads/main",
|
||||
"from": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"to": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"seq": 1,
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"nonce": "vector-nonce-1",
|
||||
"signatures": [
|
||||
{"signer": "did:key:z6MkpYqTAt8jq3SExeUiVdyTrDdocy9j2NjGxFE6S6ZBh1Yt", "sig": "ignored"}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed attestation payload. Shape mirrors what Covenant's runtime would
|
||||
/// produce for a `covenant/exec/v1` attestation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
struct ExecPayload {
|
||||
agent: String,
|
||||
capability: String,
|
||||
sandbox_digest: String,
|
||||
}
|
||||
|
||||
impl AttestationPayload for ExecPayload {
|
||||
fn payload_type() -> &'static str {
|
||||
"covenant/exec/v1"
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_payload() -> ExecPayload {
|
||||
ExecPayload {
|
||||
agent: "did:key:z6MkAgentTestVector".to_string(),
|
||||
capability: "code.write+sandbox.exec".to_string(),
|
||||
sandbox_digest: "sha256:c0ffee".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The expected lowercase-hex SHA-256 of `JCS(cert_body \ {signatures})`.
|
||||
/// Any port that produces a different value is not JCS-canonicalizing the
|
||||
/// same way (likely culprits: key ordering, integer or string escaping).
|
||||
const EXPECTED_CERT_HASH_HEX: &str =
|
||||
"5898d7c7a7d32a5faaaf9a52bfc92e0eff98027554adeefba0c575816f43ce56";
|
||||
|
||||
/// The expected `did:key` of the signer derived from `SIGNER_SEED`. Any port
|
||||
/// that produces a different value has a multibase or multicodec encoding bug.
|
||||
const EXPECTED_SIGNER_DID: &str = "did:key:z6MkqkKY69DYo23W4YFDoCNgjr7cMvmJqHtJVoQkEzrbaopZ";
|
||||
|
||||
/// The expected base64url-no-pad Ed25519 signature over
|
||||
/// `b"gitlawb-attest-sig/v1\n" || JCS({type, payload, cert_hash})`. Any port
|
||||
/// that produces a different value is missing the domain tag, JCS-encoding
|
||||
/// the input differently, or using a different Ed25519 implementation.
|
||||
const EXPECTED_SIGNATURE_B64URL: &str =
|
||||
"4L3ONbhrksB9pn--u86lMeAdePSiZZplAsxuN2Cy6vgI30ndle7gDUVuoGRJr4Ylhz7ipQvUzXaDTvZGFLPXBQ";
|
||||
|
||||
#[test]
|
||||
fn cert_hash_is_deterministic_from_jcs() {
|
||||
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
|
||||
let actual_hex = hex::encode(env.cert_hash().unwrap());
|
||||
assert_eq!(
|
||||
actual_hex, EXPECTED_CERT_HASH_HEX,
|
||||
"cert hash drifted — JCS encoding or hash algorithm changed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_did_is_deterministic_from_seed() {
|
||||
let sk = SigningKey::from_bytes(&SIGNER_SEED);
|
||||
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
|
||||
let cert_hash = env.cert_hash().unwrap();
|
||||
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
|
||||
assert_eq!(
|
||||
att.signer, EXPECTED_SIGNER_DID,
|
||||
"signer DID drifted — multibase or multicodec encoding changed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attestation_signature_is_deterministic() {
|
||||
let sk = SigningKey::from_bytes(&SIGNER_SEED);
|
||||
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
|
||||
let cert_hash = env.cert_hash().unwrap();
|
||||
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
|
||||
assert_eq!(
|
||||
att.sig, EXPECTED_SIGNATURE_B64URL,
|
||||
"signature drifted — domain tag, JCS shape, or Ed25519 implementation changed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_attestation_verifies_through_the_public_api() {
|
||||
// Round-trip the entire pipeline using only the public API on the fixed
|
||||
// vectors. A non-Rust implementor should be able to:
|
||||
// 1. Compute cert_hash and check against EXPECTED_CERT_HASH_HEX.
|
||||
// 2. Derive signer DID and check against EXPECTED_SIGNER_DID.
|
||||
// 3. Sign a payload identical to fixture_payload() and check against
|
||||
// EXPECTED_SIGNATURE_B64URL.
|
||||
// 4. Reconstruct the wire envelope and verify it round-trips.
|
||||
let sk = SigningKey::from_bytes(&SIGNER_SEED);
|
||||
let mut env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
|
||||
let cert_hash = env.cert_hash().unwrap();
|
||||
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
|
||||
env.attach(att);
|
||||
|
||||
let wire = serde_json::to_string(&env).unwrap();
|
||||
let back: AttestedRefUpdateCert = serde_json::from_str(&wire).unwrap();
|
||||
assert_eq!(back.attestations.len(), 1);
|
||||
assert_eq!(back.attestations[0].sig, EXPECTED_SIGNATURE_B64URL);
|
||||
|
||||
let recovered = back.attestations[0]
|
||||
.verify_signature(back.cert_hash().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(recovered.to_bytes(), sk.verifying_key().to_bytes());
|
||||
|
||||
// And through a registry, with the convenience method.
|
||||
let reg = Registry::new();
|
||||
let verified = back.verify_attestations(®).unwrap();
|
||||
assert_eq!(verified.len(), 1);
|
||||
assert_eq!(verified[0].cert_hash, EXPECTED_CERT_HASH_HEX);
|
||||
assert_eq!(verified[0].signer, EXPECTED_SIGNER_DID);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! End-to-end integration with `gitlawb-core`.
|
||||
//!
|
||||
//! Proves the attest envelope composes with the real `RefUpdateCert` from
|
||||
//! `gitlawb-core`: a cert built and signed through the core API wraps cleanly,
|
||||
//! survives signing and verifying an attestation, and round-trips back to a
|
||||
//! `RefUpdateCert` that core still verifies. None of this is exercised by the
|
||||
//! unit tests in `cert.rs`, which use hand-written JSON literals to keep the
|
||||
//! crate's runtime deps minimal.
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use gitlawb_attest::{
|
||||
Attestation, AttestationPayload, AttestationVerifier, AttestedRefUpdateCert, Error, Policy,
|
||||
Registry, Result as AttestResult,
|
||||
};
|
||||
use gitlawb_core::cert::RefUpdateCert;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn zero_hash() -> String {
|
||||
"0".repeat(64)
|
||||
}
|
||||
|
||||
fn commit_hash(c: char) -> String {
|
||||
c.to_string().repeat(64)
|
||||
}
|
||||
|
||||
fn fresh_signing_key() -> SigningKey {
|
||||
SigningKey::generate(&mut OsRng)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
struct CovenantExecPayload {
|
||||
agent: String,
|
||||
capability: String,
|
||||
sandbox_digest: String,
|
||||
}
|
||||
|
||||
impl AttestationPayload for CovenantExecPayload {
|
||||
fn payload_type() -> &'static str {
|
||||
"covenant/exec/v1"
|
||||
}
|
||||
}
|
||||
|
||||
struct CovenantExecVerifier;
|
||||
|
||||
impl AttestationVerifier for CovenantExecVerifier {
|
||||
fn type_(&self) -> &'static str {
|
||||
"covenant/exec/v1"
|
||||
}
|
||||
|
||||
fn verify_payload(&self, payload: &serde_json::Value) -> AttestResult<()> {
|
||||
for field in ["agent", "capability", "sandbox_digest"] {
|
||||
payload
|
||||
.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| Error::Payload(format!("missing or empty '{field}'")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_real_cert_signs_and_verifies_attestation_then_unwraps_cleanly() {
|
||||
// 1. Build a real cert through gitlawb-core. The pusher is the repo owner
|
||||
// in this minimal setup; the cert is single-signature.
|
||||
let pusher = Keypair::generate();
|
||||
let repo_did = pusher.did();
|
||||
let cert = RefUpdateCert::new(
|
||||
repo_did,
|
||||
"refs/heads/main".to_string(),
|
||||
zero_hash(),
|
||||
commit_hash('a'),
|
||||
1,
|
||||
&pusher,
|
||||
)
|
||||
.unwrap();
|
||||
cert.validate_structure().unwrap();
|
||||
let valid_signers = cert.verify_all().unwrap();
|
||||
assert_eq!(valid_signers.len(), 1);
|
||||
|
||||
// 2. Wrap. With no attestations attached the envelope is byte-identical to
|
||||
// the underlying cert — that's the additive-on-the-wire promise.
|
||||
let cert_json = serde_json::to_value(&cert).unwrap();
|
||||
let mut env = AttestedRefUpdateCert::from_cert(&cert).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&env).unwrap(),
|
||||
cert_json,
|
||||
"empty envelope must serialize as the bare cert"
|
||||
);
|
||||
|
||||
// 3. A separate party (here, an agent runtime) attests to how the commit
|
||||
// was produced. The attestation signer is intentionally not the cert
|
||||
// pusher: trust in the attestation is evaluated independently of trust
|
||||
// in the push.
|
||||
let agent_signer = fresh_signing_key();
|
||||
let payload = CovenantExecPayload {
|
||||
agent: "did:key:z6MkAgent".to_string(),
|
||||
capability: "code.write+sandbox.exec".to_string(),
|
||||
sandbox_digest: "sha256:c0ffee".to_string(),
|
||||
};
|
||||
let cert_hash = env.cert_hash().unwrap();
|
||||
let attestation = Attestation::sign(&agent_signer, payload.clone(), cert_hash).unwrap();
|
||||
env.attach(attestation);
|
||||
|
||||
// 4. Verify through a registry that knows `covenant/exec/v1`. Strict
|
||||
// policy so an unknown discriminator would have been rejected.
|
||||
let mut registry = Registry::new().with_policy(Policy::RejectUnknown);
|
||||
registry.register(CovenantExecVerifier);
|
||||
let verified = registry
|
||||
.verify_all(&env.attestations, env.cert_hash().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(verified.len(), 1);
|
||||
assert!(verified[0].fully_verified);
|
||||
assert_eq!(verified[0].type_, "covenant/exec/v1");
|
||||
assert_eq!(verified[0].cert_hash, hex::encode(cert_hash));
|
||||
|
||||
// 5. Round-trip through JSON and back. The wrapped cert with one
|
||||
// attestation must parse back into the same envelope, and stripping
|
||||
// the attestations must yield a `RefUpdateCert` core still verifies.
|
||||
let on_wire = serde_json::to_string(&env).unwrap();
|
||||
let back: AttestedRefUpdateCert = serde_json::from_str(&on_wire).unwrap();
|
||||
assert_eq!(back.attestations.len(), 1);
|
||||
assert_eq!(
|
||||
back.attestations[0]
|
||||
.payload_as::<CovenantExecPayload>()
|
||||
.unwrap(),
|
||||
payload
|
||||
);
|
||||
|
||||
let mut unwrapped = back.cert.clone();
|
||||
if let Some(obj) = unwrapped.as_object_mut() {
|
||||
obj.remove("attestations");
|
||||
}
|
||||
let unwrapped_cert: RefUpdateCert = serde_json::from_value(unwrapped).unwrap();
|
||||
let revalidated = unwrapped_cert.verify_all().unwrap();
|
||||
assert_eq!(revalidated, valid_signers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_hash_is_stable_when_a_maintainer_countersigns() {
|
||||
// The cert hash strips `signatures` before hashing, so countersignatures
|
||||
// added after an attestation is signed must not invalidate the
|
||||
// attestation's binding.
|
||||
let pusher = Keypair::generate();
|
||||
let maintainer = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
pusher.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
zero_hash(),
|
||||
commit_hash('b'),
|
||||
7,
|
||||
&pusher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let env_before = AttestedRefUpdateCert::from_cert(&cert).unwrap();
|
||||
let hash_before = env_before.cert_hash().unwrap();
|
||||
|
||||
cert.countersign(&maintainer).unwrap();
|
||||
let env_after = AttestedRefUpdateCert::from_cert(&cert).unwrap();
|
||||
let hash_after = env_after.cert_hash().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
hash_before, hash_after,
|
||||
"cert hash must be stable across countersignatures"
|
||||
);
|
||||
|
||||
// An attestation signed against the pre-countersign hash still verifies
|
||||
// against the post-countersign cert.
|
||||
let agent = fresh_signing_key();
|
||||
let payload = CovenantExecPayload {
|
||||
agent: "did:key:z6MkAgent2".to_string(),
|
||||
capability: "code.write".to_string(),
|
||||
sandbox_digest: "sha256:deadbeef".to_string(),
|
||||
};
|
||||
let attestation = Attestation::sign(&agent, payload, hash_before).unwrap();
|
||||
attestation.verify_signature(hash_after).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_cert_replay_is_rejected_against_a_real_cert() {
|
||||
// Negative case: an attestation signed for one cert must not verify on a
|
||||
// different cert built through the core API.
|
||||
let pusher = Keypair::generate();
|
||||
let cert_a = RefUpdateCert::new(
|
||||
pusher.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
zero_hash(),
|
||||
commit_hash('a'),
|
||||
1,
|
||||
&pusher,
|
||||
)
|
||||
.unwrap();
|
||||
let cert_b = RefUpdateCert::new(
|
||||
pusher.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
commit_hash('a'),
|
||||
commit_hash('b'),
|
||||
2,
|
||||
&pusher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let env_a = AttestedRefUpdateCert::from_cert(&cert_a).unwrap();
|
||||
let env_b = AttestedRefUpdateCert::from_cert(&cert_b).unwrap();
|
||||
let hash_a = env_a.cert_hash().unwrap();
|
||||
let hash_b = env_b.cert_hash().unwrap();
|
||||
assert_ne!(hash_a, hash_b);
|
||||
|
||||
let agent = fresh_signing_key();
|
||||
let payload = CovenantExecPayload {
|
||||
agent: "did:key:z6MkAgent3".to_string(),
|
||||
capability: "code.write".to_string(),
|
||||
sandbox_digest: "sha256:cafe".to_string(),
|
||||
};
|
||||
let attestation = Attestation::sign(&agent, payload, hash_a).unwrap();
|
||||
let err = attestation.verify_signature(hash_b).unwrap_err();
|
||||
assert!(matches!(err, Error::CertHashMismatch));
|
||||
}
|
||||
|
||||
/// A cert deserialized from JSON and a cert built through the core API must
|
||||
/// hash identically. JCS canonicalization is what makes the additive-on-wire
|
||||
/// promise hold across the serialize/deserialize boundary — without it, the
|
||||
/// hash would depend on which side built the `serde_json::Value`.
|
||||
#[test]
|
||||
fn cert_hash_is_identical_across_the_deserialize_boundary() {
|
||||
let pusher = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
pusher.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
zero_hash(),
|
||||
commit_hash('c'),
|
||||
9,
|
||||
&pusher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let direct_hash = AttestedRefUpdateCert::from_cert(&cert)
|
||||
.unwrap()
|
||||
.cert_hash()
|
||||
.unwrap();
|
||||
|
||||
let wire = serde_json::to_string(&cert).unwrap();
|
||||
let parsed_value: serde_json::Value = serde_json::from_str(&wire).unwrap();
|
||||
let parsed_env: AttestedRefUpdateCert = serde_json::from_value(parsed_value).unwrap();
|
||||
let parsed_hash = parsed_env.cert_hash().unwrap();
|
||||
|
||||
assert_eq!(direct_hash, parsed_hash);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "gitlawb-core"
|
||||
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
|
||||
version.workspace = true
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -23,6 +24,9 @@ chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
pkcs8 = { version = "0.10", features = ["pem", "std"] }
|
||||
curve25519-dalek = "4"
|
||||
crypto_box = { version = "0.9", features = ["std", "chacha20"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
@@ -257,4 +257,105 @@ mod tests {
|
||||
// Round-trip
|
||||
let _: RefUpdateCert = serde_json::from_str(&json).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_structure_rejects_wrong_cert_type() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.body.type_ = "gitlawb/ref-update/v0".to_string();
|
||||
assert!(cert.validate_structure().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_structure_rejects_invalid_to_hash() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.body.to = "not-a-valid-sha256".to_string();
|
||||
assert!(cert.validate_structure().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_structure_rejects_empty_signatures() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
cert.signatures.clear();
|
||||
assert!(cert.validate_structure().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_fails_verify_all() {
|
||||
let kp = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
// Replace kp's signature with a signature from kp2 over different bytes.
|
||||
// The signer DID still points to kp, so verification must fail.
|
||||
cert.signatures[0].sig = kp2.sign_b64(b"unrelated bytes");
|
||||
assert!(cert.verify_all().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_not_met_when_signer_outside_maintainer_list() {
|
||||
let kp_pusher = Keypair::generate();
|
||||
let kp_maintainer = Keypair::generate();
|
||||
|
||||
let cert = RefUpdateCert::new(
|
||||
kp_pusher.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp_pusher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let maintainers = vec![kp_maintainer.did()];
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_always_satisfied() {
|
||||
let kp = Keypair::generate();
|
||||
let cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cert.satisfies_threshold(&[], 0).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -129,4 +129,61 @@ mod tests {
|
||||
let bytes = sha256_hex_to_bytes(&hex).unwrap();
|
||||
assert_eq!(sha256_bytes(data), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_sha256_bytes_matches_object_bytes() {
|
||||
// from_sha256_bytes must produce the same CID as from_git_object_bytes
|
||||
// when given the SHA-256 of those bytes — the two construction paths
|
||||
// must be equivalent for self-verifying content addressing to hold.
|
||||
let data = b"blob 5\0hello";
|
||||
let cid_from_object = Cid::from_git_object_bytes(data);
|
||||
let hash = sha256_bytes(data);
|
||||
let cid_from_hash = Cid::from_sha256_bytes(&hash);
|
||||
assert_eq!(cid_from_object, cid_from_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_parses_valid_cid() {
|
||||
let data = b"tree content for test";
|
||||
let cid = Cid::from_git_object_bytes(data);
|
||||
let parsed = Cid::from_str(cid.as_str()).unwrap();
|
||||
assert_eq!(cid, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_rejects_invalid_string() {
|
||||
let result = Cid::from_str("not-a-valid-cid");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_data_produces_different_cids() {
|
||||
let c1 = Cid::from_git_object_bytes(b"blob 5\0hello");
|
||||
let c2 = Cid::from_git_object_bytes(b"blob 5\0world");
|
||||
assert_ne!(c1, c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_to_bytes_rejects_non_hex() {
|
||||
let non_hex = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
|
||||
assert_eq!(non_hex.len(), 64);
|
||||
let result = sha256_hex_to_bytes(non_hex);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_to_bytes_rejects_wrong_length() {
|
||||
let result = sha256_hex_to_bytes("deadbeef");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_of_empty_input_is_well_known() {
|
||||
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
|
||||
let h = sha256_hex(b"");
|
||||
assert_eq!(
|
||||
h,
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -252,4 +252,60 @@ mod tests {
|
||||
let json = serde_json::to_string_pretty(&doc).unwrap();
|
||||
assert!(json.contains("Ed25519VerificationKey2020"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_verifying_key_fails_for_did_web() {
|
||||
let web = Did::web("example.com");
|
||||
let result = web.to_verifying_key();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"did:web cannot resolve to a verifying key locally"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_verifying_key_fails_for_did_gitlawb() {
|
||||
let gl = Did::gitlawb("z6MkSomeKey");
|
||||
let result = gl.to_verifying_key();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"did:gitlawb cannot resolve to a verifying key locally"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_rejects_missing_did_prefix() {
|
||||
let result = "key:z6MkABCD".parse::<Did>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_rejects_unsupported_method() {
|
||||
let result = "did:ethr:0x1234abcd".parse::<Did>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_all_supported_methods() {
|
||||
Did::web("example.com").validate().unwrap();
|
||||
Did::gitlawb("z6MkSomeKey").validate().unwrap();
|
||||
Keypair::generate().did().validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_did_key_and_is_did_gitlawb_predicates() {
|
||||
let key_did = Keypair::generate().did();
|
||||
assert!(key_did.is_did_key());
|
||||
assert!(!key_did.is_did_gitlawb());
|
||||
|
||||
let gl = Did::gitlawb("z6MkSomeKey");
|
||||
assert!(!gl.is_did_key());
|
||||
assert!(gl.is_did_gitlawb());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_id_extraction() {
|
||||
assert_eq!(Did::web("example.com").method_id(), "example.com");
|
||||
assert_eq!(Did::gitlawb("z6MkSomeKey").method_id(), "z6MkSomeKey");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Envelope encryption for withheld blobs (Option B). A random content key
|
||||
//! encrypts the blob (XChaCha20-Poly1305); the content key is wrapped to each
|
||||
//! recipient via an X25519 box keyed from their Ed25519 `did:key`. The node
|
||||
//! seals with public keys only; readers open with their own private key.
|
||||
|
||||
use crate::identity::Keypair;
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// X25519 public key (Montgomery u) for an Ed25519 verifying key.
|
||||
fn x25519_public(vk: &VerifyingKey) -> Result<[u8; 32]> {
|
||||
use curve25519_dalek::edwards::CompressedEdwardsY;
|
||||
let edwards = CompressedEdwardsY::from_slice(vk.as_bytes())
|
||||
.ok()
|
||||
.and_then(|c| c.decompress())
|
||||
.context("verifying key is not a valid edwards point")?;
|
||||
Ok(edwards.to_montgomery().to_bytes())
|
||||
}
|
||||
|
||||
/// X25519 secret scalar for an Ed25519 seed (SHA-512 of seed, lower 32, clamped).
|
||||
/// Returns the scalar wrapped in `Zeroizing`, and scrubs the intermediate
|
||||
/// SHA-512 digest, so no copy of this secret material lingers in freed memory.
|
||||
fn x25519_secret_from_seed(seed: &[u8; 32]) -> Zeroizing<[u8; 32]> {
|
||||
use sha2::{Digest, Sha512};
|
||||
use zeroize::Zeroize;
|
||||
let mut h = Sha512::digest(seed);
|
||||
let mut s = Zeroizing::new([0u8; 32]);
|
||||
s.copy_from_slice(&h[..32]);
|
||||
s[0] &= 248;
|
||||
s[31] &= 127;
|
||||
s[31] |= 64;
|
||||
h.as_mut_slice().zeroize();
|
||||
s
|
||||
}
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use crypto_box::{
|
||||
aead::{AeadCore, OsRng},
|
||||
ChaChaBox, PublicKey as XPublic, SecretKey as XSecret,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAGIC: &[u8] = b"GLENC";
|
||||
const VERSION: u8 = 2;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Recipient {
|
||||
eph: String, // base64 ephemeral x25519 pubkey (32B)
|
||||
nonce: String, // base64 box nonce (24B)
|
||||
wrap: String, // base64 wrapped content key
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Header {
|
||||
alg: String,
|
||||
nonce: String, // base64 body nonce (24B)
|
||||
recipients: Vec<Recipient>,
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` so any of `recipients` (Ed25519 keys) can decrypt.
|
||||
pub fn seal_blob(plaintext: &[u8], recipients: &[VerifyingKey]) -> Result<Vec<u8>> {
|
||||
if recipients.is_empty() {
|
||||
return Err(anyhow::anyhow!("seal_blob: no recipients"));
|
||||
}
|
||||
let mut content_key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut content_key);
|
||||
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
|
||||
.map_err(|e| anyhow::anyhow!("content key: {e}"))?;
|
||||
let mut body_nonce = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut body_nonce);
|
||||
let body = body_cipher
|
||||
.encrypt(XNonce::from_slice(&body_nonce), plaintext)
|
||||
.map_err(|e| anyhow::anyhow!("body encrypt: {e}"))?;
|
||||
|
||||
let mut wrapped = Vec::with_capacity(recipients.len());
|
||||
for vk in recipients {
|
||||
let recip_x = XPublic::from(x25519_public(vk)?);
|
||||
let eph = XSecret::generate(&mut OsRng);
|
||||
let abox = ChaChaBox::new(&recip_x, &eph);
|
||||
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
|
||||
let ct = abox
|
||||
.encrypt(&nonce, &content_key[..])
|
||||
.map_err(|e| anyhow::anyhow!("wrap: {e}"))?;
|
||||
wrapped.push(Recipient {
|
||||
eph: B64.encode(eph.public_key().as_bytes()),
|
||||
nonce: B64.encode(nonce),
|
||||
wrap: B64.encode(ct),
|
||||
});
|
||||
}
|
||||
|
||||
let header = Header {
|
||||
alg: "xchacha20poly1305".into(),
|
||||
nonce: B64.encode(body_nonce),
|
||||
recipients: wrapped,
|
||||
};
|
||||
let header_json = serde_json::to_vec(&header).context("encode header")?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.push(VERSION);
|
||||
out.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&header_json);
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope with `keypair`. Errors if not a recipient or on auth fail.
|
||||
pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
|
||||
let mut p = 0;
|
||||
if envelope.len() < MAGIC.len() + 1 + 4 || &envelope[..MAGIC.len()] != MAGIC {
|
||||
return Err(anyhow::anyhow!("bad envelope magic"));
|
||||
}
|
||||
p += MAGIC.len();
|
||||
if envelope[p] != VERSION {
|
||||
return Err(anyhow::anyhow!("unsupported envelope version"));
|
||||
}
|
||||
p += 1;
|
||||
let hlen = u32::from_le_bytes(envelope[p..p + 4].try_into().unwrap()) as usize;
|
||||
p += 4;
|
||||
let header: Header =
|
||||
serde_json::from_slice(envelope.get(p..p + hlen).context("truncated header")?)
|
||||
.context("decode header")?;
|
||||
let body = &envelope[p + hlen..];
|
||||
|
||||
let my_x = XSecret::from(*x25519_secret_from_seed(&keypair.to_seed()));
|
||||
|
||||
// Identities are blinded: no entry says which recipient it belongs to, so
|
||||
// try each one. The ChaChaBox AEAD tag authenticates, so exactly the
|
||||
// reader's own entry unwraps; every other entry fails cleanly.
|
||||
let mut content_key: Option<Vec<u8>> = None;
|
||||
for entry in &header.recipients {
|
||||
let eph = match B64
|
||||
.decode(&entry.eph)
|
||||
.ok()
|
||||
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
|
||||
{
|
||||
Some(b) => XPublic::from(b),
|
||||
None => continue,
|
||||
};
|
||||
// from_slice panics on a wrong length, and the envelope is attacker
|
||||
// controlled, so validate the 24-byte box nonce before using it.
|
||||
let nonce = match B64
|
||||
.decode(&entry.nonce)
|
||||
.ok()
|
||||
.and_then(|n| <[u8; 24]>::try_from(n.as_slice()).ok())
|
||||
{
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let wrap = match B64.decode(&entry.wrap) {
|
||||
Ok(w) => w,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let abox = ChaChaBox::new(&eph, &my_x);
|
||||
if let Ok(ck) = abox.decrypt(
|
||||
crypto_box::aead::generic_array::GenericArray::from_slice(&nonce),
|
||||
wrap.as_slice(),
|
||||
) {
|
||||
content_key = Some(ck);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let content_key = content_key.context("not a recipient of this envelope")?;
|
||||
|
||||
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
|
||||
.map_err(|e| anyhow::anyhow!("content key: {e}"))?;
|
||||
let body_nonce = B64
|
||||
.decode(&header.nonce)
|
||||
.ok()
|
||||
.and_then(|n| <[u8; 24]>::try_from(n.as_slice()).ok())
|
||||
.context("invalid body nonce")?;
|
||||
body_cipher
|
||||
.decrypt(XNonce::from_slice(&body_nonce), body)
|
||||
.map_err(|_| anyhow::anyhow!("body decrypt failed"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::Keypair;
|
||||
|
||||
#[test]
|
||||
fn ed25519_to_x25519_keypair_agrees() {
|
||||
// The X25519 public derived from the Ed25519 public must equal the
|
||||
// X25519 public of the X25519 secret derived from the same seed.
|
||||
let kp = Keypair::generate();
|
||||
let seed = kp.to_seed();
|
||||
let xpub_from_public = x25519_public(&kp.verifying_key()).unwrap();
|
||||
let xsec = x25519_secret_from_seed(&seed);
|
||||
let xpub_from_secret = crypto_box::SecretKey::from(*xsec).public_key().to_bytes();
|
||||
assert_eq!(xpub_from_public, xpub_from_secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seal_open_round_trip_for_recipients() {
|
||||
let owner = Keypair::generate();
|
||||
let reader_a = Keypair::generate();
|
||||
let reader_b = Keypair::generate();
|
||||
let msg = b"private blob contents";
|
||||
|
||||
let env = seal_blob(msg, &[owner.verifying_key(), reader_a.verifying_key()]).unwrap();
|
||||
|
||||
assert_eq!(open_blob(&env, &owner).unwrap(), msg);
|
||||
assert_eq!(open_blob(&env, &reader_a).unwrap(), msg);
|
||||
assert!(
|
||||
open_blob(&env, &reader_b).is_err(),
|
||||
"non-recipient must fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_envelope_fails() {
|
||||
let owner = Keypair::generate();
|
||||
let mut env = seal_blob(b"hi", &[owner.verifying_key()]).unwrap();
|
||||
let last = env.len() - 1;
|
||||
env[last] ^= 0x01;
|
||||
assert!(open_blob(&env, &owner).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_header_contains_no_recipient_pubkey() {
|
||||
// The blinded envelope header must not carry any recipient's public key.
|
||||
let reader = Keypair::generate();
|
||||
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
|
||||
|
||||
// Slice out the header bytes using the envelope framing:
|
||||
// MAGIC | version(1B) | header_len(4B LE) | header_json | body
|
||||
let mut p = MAGIC.len() + 1; // skip MAGIC + version byte
|
||||
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
|
||||
p += 4;
|
||||
let header = &env[p..p + hlen];
|
||||
let header_str = String::from_utf8_lossy(header);
|
||||
|
||||
let pubkey_b64 = B64.encode(reader.verifying_key().as_bytes());
|
||||
assert!(
|
||||
!header_str.contains(&pubkey_b64),
|
||||
"recipient public key must not appear in the blinded header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_envelope_is_rejected() {
|
||||
let reader = Keypair::generate();
|
||||
let mut env = seal_blob(b"hi", &[reader.verifying_key()]).unwrap();
|
||||
// Flip the version byte (immediately after MAGIC) from 2 to 1.
|
||||
env[MAGIC.len()] = 1;
|
||||
let err = open_blob(&env, &reader).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("unsupported envelope version"),
|
||||
"expected version-rejection error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_nonce_returns_err_not_panic() {
|
||||
// from_slice panics on wrong-length input; a crafted envelope on the
|
||||
// public recovery path must surface an error, never panic.
|
||||
let reader = Keypair::generate();
|
||||
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
|
||||
|
||||
// Split the envelope framing into header JSON and body.
|
||||
let mut p = MAGIC.len() + 1;
|
||||
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
|
||||
p += 4;
|
||||
let header_bytes = &env[p..p + hlen];
|
||||
let body = &env[p + hlen..];
|
||||
|
||||
let reframe = |header: &serde_json::Value| -> Vec<u8> {
|
||||
let hj = serde_json::to_vec(header).unwrap();
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.push(VERSION);
|
||||
out.extend_from_slice(&(hj.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&hj);
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
};
|
||||
let bad_nonce = serde_json::Value::String(B64.encode([0u8; 12]));
|
||||
|
||||
// Corrupted per-recipient nonce: entry is skipped, no match.
|
||||
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
|
||||
header["recipients"][0]["nonce"] = bad_nonce.clone();
|
||||
assert!(open_blob(&reframe(&header), &reader).is_err());
|
||||
|
||||
// Corrupted body nonce: unwrap succeeds, body nonce is rejected.
|
||||
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
|
||||
header["nonce"] = bad_nonce;
|
||||
assert!(open_blob(&reframe(&header), &reader).is_err());
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ impl HttpSignature {
|
||||
pub fn missing_components(&self) -> Vec<&str> {
|
||||
COVERED_COMPONENTS
|
||||
.iter()
|
||||
.filter(|c| !self.components.contains(&c.to_string()))
|
||||
.filter(|c| !self.components.iter().any(|s| s.as_str() == **c))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ impl Keypair {
|
||||
URL_SAFE_NO_PAD.encode(sig.to_bytes())
|
||||
}
|
||||
|
||||
/// Export the signing key as raw 32-byte seed (wrapped in Zeroizing).
|
||||
/// The raw 32-byte Ed25519 seed, wrapped in `Zeroizing` so the copy is
|
||||
/// scrubbed on drop. This is the only seed accessor; it is used to derive
|
||||
/// the X25519 secret for envelope decryption (see `crate::encrypt`).
|
||||
pub fn to_seed(&self) -> Zeroizing<[u8; 32]> {
|
||||
Zeroizing::new(self.signing_key.to_bytes())
|
||||
}
|
||||
@@ -157,4 +159,80 @@ mod tests {
|
||||
let did = kp.did();
|
||||
assert!(did.to_string().starts_with("did:key:z6Mk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_seed_round_trip() {
|
||||
let kp = Keypair::generate();
|
||||
let seed = kp.to_seed();
|
||||
let kp2 = Keypair::from_seed(&seed).unwrap();
|
||||
assert_eq!(kp.verifying_key(), kp2.verifying_key());
|
||||
}
|
||||
|
||||
// `to_seed()` is the only seed accessor on `Keypair` (the raw, unzeroized
|
||||
// `seed_bytes()` was removed in #41). Pin its return type so a bare-array
|
||||
// accessor can't slip back in, and confirm it is deterministic. The
|
||||
// from_seed -> verifying_key round-trip is already covered by
|
||||
// `from_seed_round_trip`.
|
||||
#[test]
|
||||
fn to_seed_returns_zeroizing_and_is_deterministic() {
|
||||
let kp = Keypair::generate();
|
||||
// The type annotation is the regression guard: the seed must be handed
|
||||
// out wrapped in `Zeroizing`, never as a bare `[u8; 32]`.
|
||||
let seed: Zeroizing<[u8; 32]> = kp.to_seed();
|
||||
assert_eq!(*kp.to_seed(), *seed); // stable across calls
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_b64_decodes_to_valid_signature() {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let kp = Keypair::generate();
|
||||
let msg = b"test payload for b64 signing";
|
||||
let b64 = kp.sign_b64(msg);
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&b64)
|
||||
.expect("sign_b64 must produce valid base64url")
|
||||
.try_into()
|
||||
.expect("Ed25519 signature must be 64 bytes");
|
||||
verify(&kp.verifying_key(), msg, &sig_bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_key() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let msg = b"some message";
|
||||
let sig = kp1.sign(msg);
|
||||
let result = verify(&kp2.verifying_key(), msg, &sig.to_bytes());
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"signature from kp1 must not verify under kp2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_payload_round_trip() {
|
||||
let kp = Keypair::generate();
|
||||
let payload = serde_json::json!({"action": "push", "repo": "my-repo"});
|
||||
let signed = Signed::new(payload, &kp).unwrap();
|
||||
signed.verify(&kp.verifying_key()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_payload_wrong_key_fails() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let payload = serde_json::json!({"action": "push"});
|
||||
let signed = Signed::new(payload, &kp1).unwrap();
|
||||
let result = signed.verify(&kp2.verifying_key());
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Signed payload must not verify under a different key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_pem_invalid_input_fails() {
|
||||
let result = Keypair::from_pem("this is not valid PEM at all");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod cert;
|
||||
pub mod cid;
|
||||
pub mod did;
|
||||
pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
pub mod sanitize;
|
||||
pub mod ucan;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Text sanitization predicates shared across the workspace.
|
||||
//!
|
||||
//! Untrusted peer/remote bytes that reach an operator's terminal (node error
|
||||
//! bodies, node-advertised strings) must be stripped of BOTH `Cc` control bytes
|
||||
//! (`char::is_control()`) AND `Cf` bidi/format controls before display: a
|
||||
//! right-to-left override or directional isolate can visually reorder the line
|
||||
//! and spoof the text (Trojan Source, CWE-451). This is INV-6.
|
||||
//!
|
||||
//! `is_bidi_format` is the single workspace definition of the second half of
|
||||
//! that obligation. It was split out of `gl`'s local copy so the two terminal
|
||||
//! sanitizers that shipped with only the `Cc` half (#183) — and any future
|
||||
//! sanitizer — reuse one audited predicate instead of re-deriving the set.
|
||||
|
||||
/// True for the Unicode bidirectional/directional-isolate format characters
|
||||
/// (the reordering subset of category `Cf`) that can visually reorder a terminal
|
||||
/// line. These are NOT matched by [`char::is_control()`], which covers `Cc` only.
|
||||
///
|
||||
/// This is deliberately the reordering subset, not the whole `Cf` category:
|
||||
/// legitimate `Cf` characters such as ZWJ (`U+200D`, emoji sequences) and the
|
||||
/// soft hyphen (`U+00AD`) are NOT matched, so honest international text passes
|
||||
/// through unchanged. Widening this set is a behavior change for every consumer.
|
||||
///
|
||||
/// Pair it with `!c.is_control()` in a terminal-bound sanitizer (INV-6); see the
|
||||
/// wrappers in `gl`, `git-remote-gitlawb`, and `icaptcha-client`.
|
||||
pub fn is_bidi_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
|
||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
|
||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_every_reordering_code_point() {
|
||||
// Asserted individually so a partial predicate (e.g. one that covers the
|
||||
// marks but not the isolates) fails loudly rather than passing.
|
||||
for c in [
|
||||
'\u{200E}', '\u{200F}', '\u{061C}', // LRM, RLM, ALM
|
||||
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
|
||||
'\u{202E}', // LRE, RLE, PDF, LRO, RLO
|
||||
'\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // LRI, RLI, FSI, PDI
|
||||
] {
|
||||
assert!(
|
||||
is_bidi_format(c),
|
||||
"U+{:04X} must be a bidi-format char",
|
||||
c as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_legitimate_and_control_chars() {
|
||||
// Must NOT over-strip: plain text, a genuine RTL SCRIPT letter (Lo, not a
|
||||
// format char), ZWJ (legitimate Cf), and soft hyphen (legitimate Cf) are
|
||||
// not bidi-format. ESC is a Cc control char — owned by is_control(), not
|
||||
// this predicate, which is bidi-only.
|
||||
for c in ['a', '\u{0627}', '\u{200D}', '\u{00AD}', '\u{1b}'] {
|
||||
assert!(
|
||||
!is_bidi_format(c),
|
||||
"U+{:04X} must NOT be a bidi-format char",
|
||||
c as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,20 @@ impl Capability {
|
||||
self.constraints = Some(constraints);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns `true` if `self` is a valid attenuation of `parent`.
|
||||
///
|
||||
/// A delegated capability is only valid if it is at most as permissive as
|
||||
/// the parent capability backing it. `"*"` on the **parent**'s resource or
|
||||
/// action field and `repo/admin` in the parent's action position act as
|
||||
/// wildcards that cover any delegated value; wildcards on `self` carry no
|
||||
/// special meaning.
|
||||
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
|
||||
let resource_ok = parent.with == self.with || parent.with == "*";
|
||||
let action_ok =
|
||||
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
|
||||
resource_ok && action_ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known gitlawb capability strings.
|
||||
@@ -132,6 +146,26 @@ impl Ucan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this UCAN's not-before time is in the future (token not yet valid).
|
||||
pub fn is_before_valid(&self) -> bool {
|
||||
if let Some(nbf) = self.payload.nbf {
|
||||
Utc::now().timestamp() < nbf
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify this UCAN's audience matches `expected`.
|
||||
pub fn verify_audience(&self, expected: &Did) -> Result<()> {
|
||||
if &self.payload.aud != expected {
|
||||
return Err(Error::Ucan(format!(
|
||||
"audience mismatch: expected {expected}, got {}",
|
||||
self.payload.aud
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify the signature on this UCAN.
|
||||
pub fn verify_signature(&self) -> Result<()> {
|
||||
use crate::identity::verify;
|
||||
@@ -153,11 +187,17 @@ impl Ucan {
|
||||
}
|
||||
|
||||
/// Check if this UCAN grants a specific capability on a resource.
|
||||
///
|
||||
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
|
||||
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
|
||||
/// requested resource/action, since a valid delegation chain can produce
|
||||
/// exactly that capability (see `is_attenuated_by`).
|
||||
pub fn can(&self, resource: &str, action: &str) -> bool {
|
||||
self.payload
|
||||
.att
|
||||
.iter()
|
||||
.any(|cap| cap.with == resource && cap.can == action)
|
||||
self.payload.att.iter().any(|cap| {
|
||||
let resource_ok = cap.with == resource || cap.with == "*";
|
||||
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
|
||||
resource_ok && action_ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode to a compact JSON string (the wire format).
|
||||
@@ -217,6 +257,10 @@ impl Ucan {
|
||||
return Err(Error::Ucan("token is expired".to_string()));
|
||||
}
|
||||
|
||||
if self.is_before_valid() {
|
||||
return Err(Error::Ucan("token is not yet valid".to_string()));
|
||||
}
|
||||
|
||||
for proof_token in &self.payload.prf {
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
@@ -229,6 +273,17 @@ impl Ucan {
|
||||
)));
|
||||
}
|
||||
|
||||
// Every delegated capability must be covered by the proof (attenuation).
|
||||
for cap in &self.payload.att {
|
||||
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
|
||||
if !covered {
|
||||
return Err(Error::Ucan(format!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the proof's signature and chain recursively
|
||||
proof.verify_chain()?;
|
||||
}
|
||||
@@ -263,6 +318,63 @@ mod tests {
|
||||
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_honors_resource_wildcard() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
|
||||
let ucan = Ucan::issue(
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
|
||||
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
|
||||
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_honors_repo_admin_action_wildcard() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
|
||||
let ucan = Ucan::issue(
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new(
|
||||
"gitlawb://repos/test/repo",
|
||||
caps::REPO_ADMIN,
|
||||
)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_honors_action_wildcard() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
|
||||
let ucan = Ucan::issue(
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new("gitlawb://repos/test/repo", "*")],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
|
||||
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
|
||||
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_ucan() {
|
||||
let issuer = Keypair::generate();
|
||||
@@ -399,4 +511,157 @@ mod tests {
|
||||
let err = delegated.verify_chain().unwrap_err();
|
||||
assert!(err.to_string().contains("expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_before_valid_future_nbf() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
let nbf_future = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let payload = UcanPayload {
|
||||
ucan: "1.0.0".to_string(),
|
||||
iss: issuer.did(),
|
||||
aud: audience,
|
||||
att: vec![],
|
||||
exp: None,
|
||||
nbf: Some(nbf_future.timestamp()),
|
||||
prf: vec![],
|
||||
};
|
||||
let signing_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let sig = issuer.sign_b64(&signing_bytes);
|
||||
let ucan = Ucan { payload, s: sig };
|
||||
|
||||
assert!(ucan.is_before_valid());
|
||||
let err = ucan.verify_chain().unwrap_err();
|
||||
assert!(err.to_string().contains("not yet valid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_before_valid_past_nbf() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
let nbf_past = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||
|
||||
let payload = UcanPayload {
|
||||
ucan: "1.0.0".to_string(),
|
||||
iss: issuer.did(),
|
||||
aud: audience,
|
||||
att: vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
exp: None,
|
||||
nbf: Some(nbf_past.timestamp()),
|
||||
prf: vec![],
|
||||
};
|
||||
let signing_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let sig = issuer.sign_b64(&signing_bytes);
|
||||
let ucan = Ucan { payload, s: sig };
|
||||
|
||||
assert!(!ucan.is_before_valid());
|
||||
ucan.verify_chain().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_audience_matches() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
let ucan = Ucan::issue(&issuer, audience.clone(), vec![], None).unwrap();
|
||||
ucan.verify_audience(&audience).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_audience_mismatch() {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
let wrong = Keypair::generate().did();
|
||||
let ucan = Ucan::issue(&issuer, audience, vec![], None).unwrap();
|
||||
let err = ucan.verify_audience(&wrong).unwrap_err();
|
||||
assert!(err.to_string().contains("audience mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attenuation_valid_subset() {
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
let charlie = Keypair::generate();
|
||||
|
||||
// Alice grants Bob push on a specific repo
|
||||
let root = Ucan::issue(
|
||||
&alice,
|
||||
bob.did(),
|
||||
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Bob delegates the same capability (exact subset) to Charlie
|
||||
let delegated = Ucan::delegate(
|
||||
&bob,
|
||||
charlie.did(),
|
||||
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
|
||||
None,
|
||||
&root,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
delegated.verify_chain().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attenuation_exceeds_parent_is_rejected() {
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
let charlie = Keypair::generate();
|
||||
|
||||
// Alice grants Bob push on one repo only
|
||||
let root = Ucan::issue(
|
||||
&alice,
|
||||
bob.did(),
|
||||
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Bob tries to delegate merge (not in the original grant) to Charlie
|
||||
let delegated = Ucan::delegate(
|
||||
&bob,
|
||||
charlie.did(),
|
||||
vec![Capability::new("gitlawb://repos/org/repo", caps::PR_MERGE)],
|
||||
None,
|
||||
&root,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = delegated.verify_chain().unwrap_err();
|
||||
assert!(err.to_string().contains("attenuation violated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attenuation_repo_admin_covers_all() {
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
let charlie = Keypair::generate();
|
||||
|
||||
// Alice grants Bob repo/admin (superpower)
|
||||
let root = Ucan::issue(
|
||||
&alice,
|
||||
bob.did(),
|
||||
vec![Capability::new(
|
||||
"gitlawb://repos/org/repo",
|
||||
caps::REPO_ADMIN,
|
||||
)],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Bob delegates a more specific capability — covered by repo/admin
|
||||
let delegated = Ucan::delegate(
|
||||
&bob,
|
||||
charlie.did(),
|
||||
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
|
||||
None,
|
||||
&root,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
delegated.verify_chain().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "gitlawb-node"
|
||||
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
|
||||
version.workspace = true
|
||||
version = "0.7.1" # x-release-please-version
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -11,6 +12,8 @@ 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 }
|
||||
@@ -26,9 +29,10 @@ 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"] }
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "macros", "migrate"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
bytes = "1"
|
||||
libc = "0.2"
|
||||
cid = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -36,14 +40,28 @@ 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 = { version = "0.55", features = ["gossipsub", "kad", "mdns", "noise", "tcp", "tokio", "yamux", "identify", "macros"] }
|
||||
libp2p-core = "0.43"
|
||||
libp2p-gossipsub = "0.49.4"
|
||||
libp2p-identify = "0.47"
|
||||
libp2p-identity = { version = "0.2", features = ["ed25519", "peerid", "rand"] }
|
||||
libp2p-kad = "0.48"
|
||||
libp2p-quic = { version = "0.13", features = ["tokio"] }
|
||||
libp2p-swarm = { version = "0.47", features = ["macros", "tokio"] }
|
||||
multiaddr = "0.18"
|
||||
futures = "0.3"
|
||||
aws-sdk-s3 = "1"
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
aws-config = { version = "1", features = ["behavior-version-latest"] }
|
||||
async-compression = { version = "0.4", features = ["tokio", "zstd"] }
|
||||
tar = "0.4"
|
||||
zstd = "0.13"
|
||||
# Prometheus metrics. Used to expose a /metrics endpoint for ops/observability
|
||||
# on the opt-in GITLAWB_METRICS_ADDR listener. The crate is also the de-facto
|
||||
# exposition format encoder in the Rust ecosystem.
|
||||
prometheus = { version = "0.13", default-features = false }
|
||||
alloy = { version = "1", default-features = false, features = [
|
||||
"contract",
|
||||
"provider-http",
|
||||
@@ -54,6 +72,7 @@ alloy = { version = "1", default-features = false, features = [
|
||||
"network",
|
||||
"rpc-types-eth",
|
||||
] }
|
||||
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -20,6 +21,14 @@ 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() {
|
||||
@@ -71,6 +80,8 @@ 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
|
||||
@@ -87,6 +98,7 @@ 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 })))
|
||||
@@ -111,6 +123,7 @@ pub async fn show_agent(
|
||||
capabilities: agent.capabilities,
|
||||
registered_at: agent.registered_at,
|
||||
last_seen: agent.last_seen,
|
||||
status: agent.status,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -133,9 +146,97 @@ 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, normalize_agent_did};
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_agent_did_preserves_full_did() {
|
||||
|
||||
@@ -79,12 +79,9 @@ pub async fn create_bounty(
|
||||
return Err(AppError::BadRequest("amount must be positive".into()));
|
||||
}
|
||||
|
||||
// Verify repo exists
|
||||
let _ = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// 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?;
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let bounty = BountyRecord {
|
||||
@@ -113,19 +110,28 @@ pub async fn create_bounty(
|
||||
Ok((StatusCode::CREATED, Json(bounty)))
|
||||
}
|
||||
|
||||
const MAX_BOUNTY_LIMIT: i64 = 200;
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/bounties
|
||||
pub async fn list_repo_bounties(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Query(q): Query<ListBountiesQuery>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, MAX_BOUNTY_LIMIT);
|
||||
let bounties = state
|
||||
.db
|
||||
.list_bounties(
|
||||
Some(&owner),
|
||||
Some(&repo),
|
||||
q.status.as_deref(),
|
||||
q.limit.unwrap_or(50),
|
||||
limit,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -136,25 +142,92 @@ pub async fn list_repo_bounties(
|
||||
pub async fn list_all_bounties(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListBountiesQuery>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let bounties = state
|
||||
.db
|
||||
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
|
||||
.await?;
|
||||
let raw_limit = q.limit.unwrap_or(50);
|
||||
let limit = raw_limit.clamp(1, MAX_BOUNTY_LIMIT);
|
||||
|
||||
Ok(Json(serde_json::json!({ "bounties": bounties })))
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let mut memo = std::collections::HashMap::new();
|
||||
let mut allowed: Vec<BountyRecord> = Vec::new();
|
||||
let page_size = (limit * 5).clamp(1, 200);
|
||||
let max_scanned = 10_000i64;
|
||||
let mut total_scanned: i64 = 0;
|
||||
let mut cursor: Option<(String, String)> = None;
|
||||
|
||||
while (allowed.len() as i64) < limit {
|
||||
let after = cursor.as_ref().map(|(ts, id)| (ts.as_str(), id.as_str()));
|
||||
let bounties = state
|
||||
.db
|
||||
.list_bounties(
|
||||
None,
|
||||
None,
|
||||
q.status.as_deref(),
|
||||
page_size,
|
||||
after.map(|(ts, _)| ts),
|
||||
after.map(|(_, id)| id),
|
||||
)
|
||||
.await?;
|
||||
if bounties.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
total_scanned += bounties.len() as i64;
|
||||
if total_scanned > max_scanned {
|
||||
return Err(AppError::Incomplete(
|
||||
"too many rows scanned; refine your filters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let last = bounties.last().unwrap();
|
||||
for b in &bounties {
|
||||
if (allowed.len() as i64) >= limit {
|
||||
break;
|
||||
}
|
||||
let key = format!("{}/{}", b.repo_owner, b.repo_name);
|
||||
let admitted = if let Some(&result) = memo.get(&key) {
|
||||
result
|
||||
} else {
|
||||
let result = crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&b.repo_owner,
|
||||
&b.repo_name,
|
||||
caller,
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
memo.insert(key, result);
|
||||
result
|
||||
};
|
||||
if admitted {
|
||||
allowed.push(b.clone());
|
||||
}
|
||||
}
|
||||
cursor = Some((last.created_at.clone(), last.id.clone()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "bounties": allowed })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/bounties/{id}
|
||||
pub async fn get_bounty(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
if crate::api::authorize_repo_read(&state, &bounty.repo_owner, &bounty.repo_name, caller, "/")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
Ok(Json(bounty))
|
||||
}
|
||||
|
||||
@@ -165,11 +238,22 @@ pub async fn claim_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ClaimBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
|
||||
|
||||
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
|
||||
|
||||
if crate::api::authorize_repo_read(
|
||||
&state,
|
||||
&bounty.repo_owner,
|
||||
&bounty.repo_name,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
if bounty.status != "open" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
@@ -213,8 +297,12 @@ pub async fn submit_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if bounty.claimant_did.as_deref() != Some(&auth.0) {
|
||||
return Err(AppError::BadRequest("only the claimant can submit".into()));
|
||||
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()));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -249,8 +337,8 @@ pub async fn approve_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the bounty creator can approve".into(),
|
||||
));
|
||||
}
|
||||
@@ -296,8 +384,8 @@ pub async fn cancel_bounty(
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the bounty creator can cancel".into(),
|
||||
));
|
||||
}
|
||||
@@ -316,6 +404,7 @@ pub async fn cancel_bounty(
|
||||
/// POST /api/v1/bounties/{id}/dispute
|
||||
pub async fn dispute_bounty(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state
|
||||
@@ -331,6 +420,19 @@ 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) {
|
||||
@@ -344,7 +446,7 @@ pub async fn dispute_bounty(
|
||||
}
|
||||
|
||||
state.db.dispute_bounty(&id).await?;
|
||||
tracing::info!(bounty_id = %id, "bounty disputed — reopened");
|
||||
tracing::info!(bounty_id = %id, actor = %auth.0, "bounty disputed - reopened");
|
||||
|
||||
let updated = state
|
||||
.db
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
//! API handlers for ref certificates.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/certs
|
||||
/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50
|
||||
pub async fn list_certs(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.max(1))
|
||||
.unwrap_or(50)
|
||||
.min(200);
|
||||
|
||||
let certs = state.db.list_ref_certificates(&record.id).await?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
// When a prefix is given (short-ID resolution from the CLI) use a
|
||||
// generous limit and delegate to the prefix-matched query so the
|
||||
// caller can resolve IDs regardless of how many certs exist.
|
||||
let prefix = params.get("prefix").filter(|p| !p.is_empty());
|
||||
let certs = if let Some(prefix) = prefix {
|
||||
state
|
||||
.db
|
||||
.list_ref_certificates_by_prefix(&record.id, prefix, 200)
|
||||
.await?
|
||||
} else {
|
||||
state.db.list_ref_certificates(&record.id, limit).await?
|
||||
};
|
||||
let certs_json: Vec<serde_json::Value> = certs
|
||||
.iter()
|
||||
.map(|c| {
|
||||
@@ -35,20 +56,21 @@ pub async fn list_certs(
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "certificates": certs_json })))
|
||||
let count = certs_json.len();
|
||||
Ok(Json(
|
||||
serde_json::json!({ "certificates": certs_json, "count": count }),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/certs/{id}
|
||||
pub async fn get_cert(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, id)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
// Verify the repo exists
|
||||
let _record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let cert = state
|
||||
.db
|
||||
@@ -56,6 +78,10 @@ pub async fn get_cert(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
|
||||
|
||||
if cert.repo_id != record.id {
|
||||
return Err(AppError::NotFound(format!("certificate {id}")));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": cert.id,
|
||||
"repo_id": cert.repo_id,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Changelog endpoint — unified timeline of commits, merged PRs, and closed issues.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::git::store;
|
||||
use crate::state::AppState;
|
||||
@@ -27,12 +28,11 @@ pub async fn get_changelog(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Query(query): Query<ChangelogQuery>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let limit = query.limit.min(100);
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Authenticated discovery + fetch for encrypted withheld blobs (Option B1).
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
|
||||
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
|
||||
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
|
||||
/// so access control here is repo readability and decryption is gated by the
|
||||
/// envelope crypto (only a real recipient can open an envelope).
|
||||
pub async fn list_encrypted_blobs(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
|
||||
let blobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|(oid, cid)| serde_json::json!({ "oid": oid, "cid": cid }))
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "blobs": blobs })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blob/{oid}
|
||||
/// Returns raw envelope bytes to callers who can read the repo; the envelope
|
||||
/// crypto still ensures only true recipients can decrypt.
|
||||
pub async fn get_encrypted_blob(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo, oid)): Path<(String, String, String)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
|
||||
}
|
||||
let cid = state
|
||||
.db
|
||||
.encrypted_blob_cid(&record.id, &oid)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")))?;
|
||||
let bytes = crate::ipfs_pin::cat(&state.config.ipfs_api, &cid)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate
|
||||
/// Returns [{oid, cid}] for every encrypted blob in the repo, for peer-mirror
|
||||
/// replication (Option B2). Gated by repo readability, like discovery, so a
|
||||
/// non-readable repo does not expose its blob index; for the intended case (a
|
||||
/// public repo with withheld subtrees) the public root keeps this open to peers.
|
||||
/// Recipient identities are deliberately withheld: the v2 envelopes no longer
|
||||
/// carry recipient public keys, so peers must not learn the reader set either. A
|
||||
/// mirror detects a re-seal by the CID changing (the OID is stable across
|
||||
/// re-seals). Ciphertext metadata only, never plaintext.
|
||||
pub async fn replicate_encrypted_blobs(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
|
||||
let blobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
.map(|(oid, cid)| replicate_blob_json(oid, cid))
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "blobs": blobs })))
|
||||
}
|
||||
|
||||
/// Serialize one blob for the replication wire. Recipient identities are
|
||||
/// intentionally absent so a mirror never learns the reader set.
|
||||
fn replicate_blob_json(oid: String, cid: String) -> serde_json::Value {
|
||||
serde_json::json!({ "oid": oid, "cid": cid })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::replicate_blob_json;
|
||||
|
||||
#[test]
|
||||
fn replicate_blob_json_omits_recipients() {
|
||||
let v = replicate_blob_json("oid1".into(), "cidA".into());
|
||||
assert_eq!(v["oid"], "oid1");
|
||||
assert_eq!(v["cid"], "cidA");
|
||||
assert!(
|
||||
v.get("recipients").is_none(),
|
||||
"replication wire must not carry recipient identities"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,28 +9,55 @@
|
||||
//! `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},
|
||||
Json,
|
||||
Extension, 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::{allowed_blob_set_for_caller, has_path_scoped_rule};
|
||||
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. Returns the raw object content bytes with appropriate
|
||||
/// headers if found, or 404 if not found.
|
||||
/// the given CIDv1, returning its raw content if the caller may read it.
|
||||
///
|
||||
/// Visibility (#110, #126): 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). We check object existence via
|
||||
/// `store::object_type` *before* the expensive reachability walk so random-CID
|
||||
/// spray cannot trigger full-history git walks on repos that don't carry the
|
||||
/// object. When the row carries path-scoped rules (KTD4) the served object
|
||||
/// must be either a non-blob (trees/commits are structural; KTD3) OR a blob
|
||||
/// in the caller's *reachable* allowed-set (`allowed_blob_set_for_caller`).
|
||||
/// The reachable allowed-set excludes dangling blobs — a blob written via
|
||||
/// `git hash-object -w` and never committed has no path to gate, so it is
|
||||
/// fail-closed 404'd under path-scoped rules (#126). Denial and genuine
|
||||
/// not-found both fall through to an opaque 404.
|
||||
///
|
||||
/// Scope: this closes the direct unauthenticated scan, including the dangling
|
||||
/// case. A stale-public mirror row still serves withheld content (tracked
|
||||
/// separately, #124).
|
||||
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)
|
||||
@@ -46,6 +73,8 @@ 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
|
||||
@@ -54,39 +83,126 @@ 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 allowed-blob set (KTD1, #126). 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.
|
||||
//
|
||||
// We flipped from a deny-set (`withheld_blob_oids`) to an allowed-set
|
||||
// (`allowed_blob_set_for_caller`) so dangling blobs — never enumerated by
|
||||
// the reachable walk — fail closed instead of slipping through an empty
|
||||
// deny entry (#126).
|
||||
let mut allowed_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,
|
||||
};
|
||||
|
||||
match store::read_object(&repo_path, &sha256_hex) {
|
||||
Ok(Some((_obj_type, content))) => {
|
||||
// 3. Return the content with IPFS-style headers
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-content-cid"),
|
||||
HeaderValue::from_str(&cid_str)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-git-hash"),
|
||||
HeaderValue::from_str(&sha256_hex)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
|
||||
return Ok((StatusCode::OK, headers, content).into_response());
|
||||
}
|
||||
// Check whether the object exists in this repo before any expensive
|
||||
// reachability walk. This prevents random-CID spray from triggering
|
||||
// full-history git walks on repos that don't carry the object.
|
||||
let obj_type = match store::object_type(&repo_path, &sha256_hex) {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error reading git object");
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error checking git object type");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-blob gating only applies when a path-scoped rule exists (KTD4).
|
||||
// Without any path-scoped rule, the "/" gate above is the whole story.
|
||||
// Trees/commits are always served under path-scoped rules (KTD3).
|
||||
let path_scoped = has_path_scoped_rule(rules);
|
||||
if path_scoped && obj_type == "blob" {
|
||||
if !allowed_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 || {
|
||||
allowed_blob_set_for_caller(
|
||||
&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, "allowed-blob walk failed; skipping repo");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "allowed-blob walk task panicked; skipping repo");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
allowed_memo.insert(repo.id.clone(), set);
|
||||
}
|
||||
let in_allowed = allowed_memo
|
||||
.get(&repo.id)
|
||||
.is_some_and(|set| set.contains(&sha256_hex));
|
||||
if !in_allowed {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've passed the gate, read the content.
|
||||
let content = match store::read_object_content(&repo_path, &sha256_hex, &obj_type) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo.name, err = %e, "error reading git object content");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Return the content with IPFS-style headers
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-content-cid"),
|
||||
HeaderValue::from_str(&cid_str).unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-git-hash"),
|
||||
HeaderValue::from_str(&sha256_hex)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
|
||||
);
|
||||
|
||||
return Ok((StatusCode::OK, headers, content).into_response());
|
||||
}
|
||||
|
||||
// Not found in any repo
|
||||
|
||||
@@ -39,11 +39,11 @@ pub async fn create_issue(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<CreateIssueRequest>,
|
||||
) -> Result<(StatusCode, Json<IssueRecord>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// 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 issue_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -70,8 +70,8 @@ pub async fn create_issue(
|
||||
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
|
||||
// Always release the advisory lock — even on error
|
||||
guard.release().await;
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(create_result.is_ok()).await;
|
||||
|
||||
create_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
@@ -90,12 +90,11 @@ pub async fn create_issue(
|
||||
pub async fn list_issues(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
@@ -120,12 +119,11 @@ pub async fn list_issues(
|
||||
pub async fn get_issue(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
@@ -161,11 +159,9 @@ pub async fn create_issue_comment(
|
||||
));
|
||||
}
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// 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 disk_path = state
|
||||
.repo_store
|
||||
@@ -193,21 +189,38 @@ pub async fn create_issue_comment(
|
||||
pub async fn list_issue_comments(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let _record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let comments = state.db.list_issue_comments(&issue_id).await?;
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
// Resolve the full issue ID (accepts 8-char prefix) so the DB fetch
|
||||
// below uses the same canonical id as the git ref.
|
||||
let full_id = match git_issues::resolve_issue_id(&disk_path, &issue_id)
|
||||
.map_err(|e| AppError::Git(e.to_string()))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Err(AppError::RepoNotFound(format!(
|
||||
"issue {issue_id} not found"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let comments = state.db.list_issue_comments(&full_id).await?;
|
||||
Ok(Json(serde_json::json!({ "comments": 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
|
||||
@@ -223,10 +236,37 @@ 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
|
||||
guard.release().await;
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(close_result.is_ok()).await;
|
||||
|
||||
let updated = close_result
|
||||
.map_err(|e| AppError::Git(e.to_string()))?
|
||||
|
||||
@@ -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,6 +39,7 @@ 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 {
|
||||
@@ -55,7 +56,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
|
||||
@@ -63,21 +64,25 @@ 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 })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/labels
|
||||
///
|
||||
/// Read-visibility-gated (INV-2 root listing): a public repo's labels stay
|
||||
/// anonymously listable; a private repo's label names are hidden (404) from
|
||||
/// anyone who cannot read it at the root.
|
||||
pub async fn list_labels(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let labels = state.db.list_labels(&record.id).await?;
|
||||
Ok(Json(serde_json::json!({ "labels": labels })))
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use crate::db::{RepoRecord, VisibilityRule};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
pub mod agents;
|
||||
pub mod arweave;
|
||||
pub mod bounties;
|
||||
pub mod certs;
|
||||
pub mod changelog;
|
||||
pub mod encrypted;
|
||||
pub mod events;
|
||||
pub mod ipfs;
|
||||
pub mod issues;
|
||||
pub mod labels;
|
||||
pub mod peers;
|
||||
pub mod profiles;
|
||||
pub mod protect;
|
||||
pub mod pulls;
|
||||
pub mod register;
|
||||
@@ -15,5 +22,682 @@ pub mod replicas;
|
||||
pub mod repos;
|
||||
pub mod resolve;
|
||||
pub mod stars;
|
||||
pub mod status;
|
||||
pub mod tasks;
|
||||
pub mod visibility;
|
||||
pub mod webhooks;
|
||||
|
||||
/// Resolve a repo for a read request and enforce path-scoped visibility.
|
||||
///
|
||||
/// Returns 404 (`RepoNotFound`) if the repo does not exist or the caller may not
|
||||
/// read `path`, using the same opaque response the git serve path returns so
|
||||
/// existence is not confirmed. Returns the record and its visibility rules so a
|
||||
/// content handler can apply an extra per-path check without a second DB query.
|
||||
///
|
||||
/// Callers pass `"/"` for repo-level reads (listings); content endpoints pass the
|
||||
/// specific path so a withheld subtree is denied even on an otherwise-public repo.
|
||||
pub(crate) async fn authorize_repo_read(
|
||||
state: &AppState,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
caller: Option<&str>,
|
||||
path: &str,
|
||||
) -> Result<(RepoRecord, Vec<VisibilityRule>)> {
|
||||
let record = state
|
||||
.db
|
||||
.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
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
|
||||
}
|
||||
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.
|
||||
///
|
||||
/// The collapse itself lives in exactly one place, [`crate::db::normalize_owner_key`],
|
||||
/// which is also the function the stored `owner_did` / `authorizing_did` columns
|
||||
/// are normalized through and which `OWNER_KEY_CASE_SQL` mirrors byte for byte.
|
||||
/// Two identities match when they normalize to the same key; a second copy of the
|
||||
/// rule here is how the Rust gate and the SQL filters would drift apart.
|
||||
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
crate::db::normalize_owner_key(a) == crate::db::normalize_owner_key(b)
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_did_matches() {
|
||||
assert!(did_matches("", ""));
|
||||
assert!(did_matches("", "did:key:"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 events = include_str!("events.rs");
|
||||
let tasks = include_str!("tasks.rs");
|
||||
let stars = include_str!("stars.rs");
|
||||
let status = include_str!("status/mod.rs");
|
||||
let protect = include_str!("protect.rs");
|
||||
let visibility = include_str!("visibility.rs");
|
||||
let profiles = include_str!("profiles.rs");
|
||||
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("),
|
||||
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
|
||||
// authorize_repo_read 404s a non-reader of a private repo, then
|
||||
// require_repo_owner 403s a non-owner of a public one. Both halves
|
||||
// are pinned: the authorize_repo_read marker guards the read half
|
||||
// (existence hiding) and require_repo_owner guards the owner half.
|
||||
(webhooks, "list_webhooks", "authorize_repo_read("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
// Same two-half shape as list_webhooks: authorize_repo_read runs
|
||||
// first (a quarantined or unreadable repo gets the missing-repo
|
||||
// not-found, never a 403 that would confirm existence), then
|
||||
// require_repo_owner 403s a non-owner of a readable repo. Both halves
|
||||
// are pinned, because dropping either changes what a stranger learns.
|
||||
(status, "create_status", "authorize_repo_read("),
|
||||
(status, "create_status", "require_repo_owner("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
// Bucket A' — owner OR author (did_matches against the author)
|
||||
(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("),
|
||||
// #94 sibling read surfaces: gate private-repo metadata on read
|
||||
// visibility (public repos stay anonymous; private repos 404).
|
||||
(replicas, "list_replicas", "authorize_repo_read("),
|
||||
(protect, "list_protected_branches", "authorize_repo_read("),
|
||||
(labels, "list_labels", "authorize_repo_read("),
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// The catch-up poll surface gates before any push-event row is read,
|
||||
// so a stranger cannot use it to learn that a private repo exists or
|
||||
// is being pushed to.
|
||||
(events, "list_repo_push_events", "authorize_repo_read("),
|
||||
// The status read gates before any claim data is loaded, so its deny
|
||||
// is the repo's own not-found rather than an existence oracle over
|
||||
// which commits were reported on.
|
||||
(status, "commit_status", "authorize_repo_read("),
|
||||
// The rollup gates on the same helper before the pull request row is
|
||||
// loaded, so a caller who cannot read the repo cannot learn which
|
||||
// pull request numbers exist on it — and the fallback's branch
|
||||
// resolve and head persist sit behind that same gate.
|
||||
(status, "pull_request_status", "authorize_repo_read("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(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))
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect `.rs` source files under `dir`. Recursive so the completeness scan
|
||||
/// covers nested API modules (`api/<module>/mod.rs` and deeper), not only the
|
||||
/// immediate `api/*.rs` children.
|
||||
fn collect_rs_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).expect("read api dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
out.extend(collect_rs_files(&path));
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.rs` files under `api_root` (as paths RELATIVE to it) that the
|
||||
/// completeness scan must inspect: everything except the top-level `mod.rs` (the
|
||||
/// guard file itself) and the top-level files already covered by the per-handler
|
||||
/// `sources` loop. A nested `api/<module>/<name>.rs` is a distinct source file
|
||||
/// even when its basename matches a listed top-level file, so it stays in scope.
|
||||
fn unlisted_source_files(
|
||||
api_root: &std::path::Path,
|
||||
listed: &std::collections::HashSet<&str>,
|
||||
) -> Vec<String> {
|
||||
collect_rs_files(api_root)
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let rel = path
|
||||
.strip_prefix(api_root)
|
||||
.ok()?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if rel == "mod.rs" || listed.contains(rel.as_str()) {
|
||||
None
|
||||
} else {
|
||||
Some(rel)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_rs_files_recurses_subdirs() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("a.rs"), "").unwrap();
|
||||
std::fs::write(root.join("note.txt"), "").unwrap();
|
||||
std::fs::create_dir_all(root.join("sub/deep")).unwrap();
|
||||
std::fs::write(root.join("sub/mod.rs"), "").unwrap();
|
||||
std::fs::write(root.join("sub/deep/c.rs"), "").unwrap();
|
||||
let names: std::collections::HashSet<String> = collect_rs_files(root)
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
})
|
||||
.collect();
|
||||
assert!(names.contains("a.rs"));
|
||||
assert!(
|
||||
names.contains("sub/mod.rs"),
|
||||
"nested module file must be collected"
|
||||
);
|
||||
assert!(
|
||||
names.contains("sub/deep/c.rs"),
|
||||
"deeply nested file must be collected"
|
||||
);
|
||||
assert!(
|
||||
!names.iter().any(|n| n.ends_with(".txt")),
|
||||
"non-rs files excluded"
|
||||
);
|
||||
assert_eq!(names.len(), 3);
|
||||
}
|
||||
|
||||
// P3 (#119): the completeness scan must skip already-covered files by their path
|
||||
// RELATIVE to api_root, not by basename. A nested api/<module>/repos.rs is a
|
||||
// distinct source file from the covered top-level repos.rs and must still be
|
||||
// scanned, or a new nested module could smuggle in an ungated repo-scoped handler
|
||||
// behind a colliding filename.
|
||||
#[test]
|
||||
fn unlisted_source_files_scans_nested_file_with_colliding_basename() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("repos.rs"), "").unwrap();
|
||||
std::fs::write(root.join("mod.rs"), "").unwrap();
|
||||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||||
std::fs::write(root.join("sub/repos.rs"), "").unwrap();
|
||||
std::fs::write(root.join("sub/fresh.rs"), "").unwrap();
|
||||
let listed: std::collections::HashSet<&str> = ["repos.rs"].into_iter().collect();
|
||||
|
||||
let unlisted = unlisted_source_files(root, &listed);
|
||||
|
||||
assert!(
|
||||
unlisted.contains(&"sub/repos.rs".to_string()),
|
||||
"a nested file whose basename matches a listed top-level file must still be scanned"
|
||||
);
|
||||
assert!(
|
||||
unlisted.contains(&"sub/fresh.rs".to_string()),
|
||||
"a nested file with a unique name must be scanned"
|
||||
);
|
||||
assert!(
|
||||
!unlisted.contains(&"repos.rs".to_string()),
|
||||
"the listed top-level file is covered by the per-handler loop"
|
||||
);
|
||||
assert!(
|
||||
!unlisted.contains(&"mod.rs".to_string()),
|
||||
"the top-level guard file is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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!("status/mod.rs"), "status/mod.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"
|
||||
);
|
||||
}
|
||||
let api_root = std::path::Path::new(api_dir);
|
||||
for rel in unlisted_source_files(api_root, &listed) {
|
||||
let src = std::fs::read_to_string(api_root.join(&rel)).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/{rel} 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)] = &[];
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
//! Agent profile API handlers.
|
||||
//!
|
||||
//! - `PUT /api/v1/profile` — upsert the caller's profile (requires HTTP Signature)
|
||||
//! - `GET /api/v1/agents/{did}/profile` — read any agent's profile (public)
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::pinata;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetProfileRequest {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub website: Option<String>,
|
||||
pub socials: Option<SocialsInput>,
|
||||
pub pin_to_ipfs: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SocialsInput {
|
||||
pub twitter: Option<String>,
|
||||
pub github: Option<String>,
|
||||
pub farcaster: Option<String>,
|
||||
pub telegram: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn set_profile(
|
||||
State(state): State<AppState>,
|
||||
axum::Extension(auth): axum::Extension<AuthenticatedDid>,
|
||||
Json(req): Json<SetProfileRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
||||
let did = auth.0;
|
||||
|
||||
if let Some(ref bio) = req.bio {
|
||||
if bio.len() > 280 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "message": "bio must be 280 characters or fewer" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref name) = req.display_name {
|
||||
if name.len() > 50 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "message": "display_name must be 50 characters or fewer" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let socials_json = req
|
||||
.socials
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_string(s).unwrap_or_else(|_| "{}".to_string()));
|
||||
|
||||
let result = state
|
||||
.db
|
||||
.upsert_profile(
|
||||
&did,
|
||||
req.display_name.as_deref(),
|
||||
req.bio.as_deref(),
|
||||
req.avatar_url.as_deref(),
|
||||
req.website.as_deref(),
|
||||
socials_json.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(profile) => {
|
||||
let mut resp = json!({
|
||||
"did": profile.did,
|
||||
"display_name": profile.display_name,
|
||||
"bio": profile.bio,
|
||||
"avatar_url": profile.avatar_url,
|
||||
"website": profile.website,
|
||||
"updated_at": profile.updated_at,
|
||||
});
|
||||
|
||||
if let Some(ref socials_str) = profile.socials {
|
||||
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
|
||||
resp["socials"] = socials;
|
||||
}
|
||||
}
|
||||
|
||||
if req.pin_to_ipfs.unwrap_or(false) && !state.config.pinata_jwt.is_empty() {
|
||||
let profile_json = serde_json::to_vec(&resp).unwrap_or_default();
|
||||
match pinata::pin_object(
|
||||
&state.http_client,
|
||||
&state.config.pinata_upload_url,
|
||||
&state.config.pinata_jwt,
|
||||
&format!("profile-{}", did),
|
||||
&profile_json,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(cid) if !cid.is_empty() => {
|
||||
if let Err(e) = state.db.set_profile_cid(&did, &cid).await {
|
||||
tracing::warn!(did = %did, err = %e, "failed to store profile CID");
|
||||
}
|
||||
resp["profile_cid"] = json!(cid);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(did = %did, err = %e, "IPFS pin failed — profile saved without CID");
|
||||
}
|
||||
}
|
||||
} else if let Some(cid) = profile.profile_cid {
|
||||
resp["profile_cid"] = json!(cid);
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(resp)))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(did = %did, error = %e, "failed to upsert profile");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "message": "failed to save profile" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let profile = state.db.get_profile(&did).await.map_err(|e| {
|
||||
tracing::error!(did = %did, error = %e, "failed to fetch profile");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "message": "failed to fetch profile" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
match profile {
|
||||
Some(p) => {
|
||||
let mut resp = json!({
|
||||
"did": p.did,
|
||||
"display_name": p.display_name,
|
||||
"bio": p.bio,
|
||||
"avatar_url": p.avatar_url,
|
||||
"website": p.website,
|
||||
"profile_cid": p.profile_cid,
|
||||
"created_at": p.created_at,
|
||||
"updated_at": p.updated_at,
|
||||
});
|
||||
|
||||
if let Some(ref socials_str) = p.socials {
|
||||
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
|
||||
resp["socials"] = socials;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(resp))
|
||||
}
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "profile not found" })),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,10 @@ pub async fn protect_branch(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// Only the repo owner can protect branches
|
||||
// Only the repo owner can protect branches (DID-safe match, shared idiom).
|
||||
let caller = &auth.0;
|
||||
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(
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner can protect branches".into(),
|
||||
));
|
||||
}
|
||||
@@ -63,13 +58,8 @@ pub async fn unprotect_branch(
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let caller = &auth.0;
|
||||
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(
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner can unprotect branches".into(),
|
||||
));
|
||||
}
|
||||
@@ -89,12 +79,14 @@ pub async fn unprotect_branch(
|
||||
pub async fn list_protected_branches(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Read-visibility-gated (INV-2 root listing): a public repo's protected
|
||||
// branches stay anonymously listable; a private repo's branch names are
|
||||
// hidden (404) from anyone who cannot read it at the root.
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let branches = state.db.list_protected_branches(&record.id).await?;
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ pub async fn create_pr(
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<CreatePrRequest>,
|
||||
) -> Result<(StatusCode, Json<PullRequest>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// 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 author_did = auth.0;
|
||||
let target_branch = req
|
||||
@@ -65,6 +65,7 @@ pub async fn create_pr(
|
||||
status: "open".to_string(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
head_commit: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -101,12 +102,11 @@ pub async fn create_pr(
|
||||
pub async fn list_prs(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let prs = state.db.list_prs(&record.id).await?;
|
||||
Ok(Json(
|
||||
@@ -118,12 +118,11 @@ pub async fn list_prs(
|
||||
pub async fn get_pr(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<PullRequest>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -138,12 +137,11 @@ pub async fn get_pr(
|
||||
pub async fn get_pr_diff(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -156,6 +154,25 @@ pub async fn get_pr_diff(
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
// Withhold the entire diff if it touches a path the caller cannot read, so a
|
||||
// PR diff cannot leak private-subtree content of an otherwise-public repo.
|
||||
let touched = store::branch_diff_names(&disk_path, &pr.target_branch, &pr.source_branch)
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
for p in &touched {
|
||||
let gate = format!("/{}", p.trim_start_matches('/'));
|
||||
if crate::visibility::visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
caller,
|
||||
&gate,
|
||||
) == crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::NotFound(format!("PR #{number} not found")));
|
||||
}
|
||||
}
|
||||
|
||||
let diff = store::branch_diff(&disk_path, &pr.target_branch, &pr.source_branch)
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
@@ -178,6 +195,11 @@ 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)
|
||||
@@ -195,6 +217,19 @@ pub async fn merge_pr(
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
let merger_did = auth.0;
|
||||
|
||||
// The source head this merge is about to consume, read under the write lock.
|
||||
// The PR row above was loaded before the lock, so a push landing in between
|
||||
// already moved the branch and the row's own `head_commit` is stale; the
|
||||
// value frozen on the merged PR has to be what was merged. `None` when the
|
||||
// ref cannot be resolved, which leaves whatever the push path last stored.
|
||||
let merged_source_head = store::list_refs(&disk_path).ok().and_then(|refs| {
|
||||
let want = format!("refs/heads/{}", pr.source_branch);
|
||||
refs.into_iter()
|
||||
.find(|(name, _)| *name == want)
|
||||
.map(|(_, sha)| sha)
|
||||
});
|
||||
|
||||
let merge_result = store::merge_branch(
|
||||
&disk_path,
|
||||
&pr.target_branch,
|
||||
@@ -203,12 +238,15 @@ pub async fn merge_pr(
|
||||
&pr.title,
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error
|
||||
guard.release().await;
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
state.db.merge_pr(&pr.id, &merger_did).await?;
|
||||
state
|
||||
.db
|
||||
.merge_pr(&pr.id, &merger_did, merged_source_head.as_deref())
|
||||
.await?;
|
||||
let _ = state.db.touch_repo(&record.id).await;
|
||||
|
||||
webhooks::fire_event(
|
||||
@@ -236,6 +274,7 @@ 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
|
||||
@@ -250,6 +289,15 @@ 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(
|
||||
@@ -274,11 +322,9 @@ pub async fn create_review(
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
Json(req): Json<CreateReviewRequest>,
|
||||
) -> Result<(StatusCode, Json<PrReview>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// 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 pr = state
|
||||
.db
|
||||
@@ -324,12 +370,11 @@ pub async fn create_review(
|
||||
pub async fn list_reviews(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -354,11 +399,9 @@ pub async fn create_comment(
|
||||
));
|
||||
}
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// 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 pr = state
|
||||
.db
|
||||
@@ -383,12 +426,11 @@ pub async fn create_comment(
|
||||
pub async fn list_comments(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -399,3 +441,159 @@ pub async fn list_comments(
|
||||
let comments = state.db.list_pr_comments(&pr.id).await?;
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use std::process::Command;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const OWNER_DID: &str = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
|
||||
const STALE_SHA: &str = "9999999999999999999999999999999999999999";
|
||||
|
||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "t")
|
||||
.env("GIT_AUTHOR_EMAIL", "[email protected]")
|
||||
.env("GIT_COMMITTER_NAME", "t")
|
||||
.env("GIT_COMMITTER_EMAIL", "[email protected]")
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// A bare repo with `main` and a `feature` branch one commit ahead.
|
||||
/// Returns the bare path and the real `feature` tip.
|
||||
fn bare_repo_with_feature(
|
||||
root: &std::path::Path,
|
||||
owner_slug: &str,
|
||||
) -> (std::path::PathBuf, String) {
|
||||
let work = root.join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
git(&work, &["init", "-q", "-b", "main", "."]);
|
||||
std::fs::write(work.join("a.txt"), "one").unwrap();
|
||||
git(&work, &["add", "."]);
|
||||
git(&work, &["commit", "-qm", "one"]);
|
||||
git(&work, &["checkout", "-qb", "feature"]);
|
||||
std::fs::write(work.join("b.txt"), "two").unwrap();
|
||||
git(&work, &["add", "."]);
|
||||
git(&work, &["commit", "-qm", "two"]);
|
||||
git(&work, &["checkout", "-q", "main"]);
|
||||
|
||||
let bare_dir = root.join(owner_slug);
|
||||
std::fs::create_dir_all(&bare_dir).unwrap();
|
||||
let bare = bare_dir.join("demo.git");
|
||||
git(
|
||||
root,
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
"--bare",
|
||||
work.to_str().unwrap(),
|
||||
bare.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
|
||||
let tip = Command::new("git")
|
||||
.args(["rev-parse", "refs/heads/feature"])
|
||||
.current_dir(&bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
let tip = String::from_utf8_lossy(&tip.stdout).trim().to_string();
|
||||
assert_eq!(tip.len(), 40, "feature tip must resolve");
|
||||
(bare, tip)
|
||||
}
|
||||
|
||||
/// Merging freezes the stored head at the source commit the merge actually
|
||||
/// consumed. The PR row is loaded before the write lock is taken, so a push
|
||||
/// that lands in between leaves the row's own `head_commit` stale; seeding a
|
||||
/// value that matches nothing on disk stands in for that race, and the
|
||||
/// merged PR must still come out pointing at the real `feature` tip.
|
||||
#[sqlx::test]
|
||||
async fn merge_stamps_the_source_head_it_merged_over_a_racing_value(pool: sqlx::PgPool) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let owner_slug = OWNER_DID.replace([':', '/'], "_");
|
||||
let (bare, feature_tip) = bare_repo_with_feature(tmp.path(), &owner_slug);
|
||||
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store =
|
||||
crate::git::repo_store::RepoStore::for_testing(tmp.path().to_path_buf(), pool);
|
||||
|
||||
let now = Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "demo".into(),
|
||||
owner_did: OWNER_DID.into(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: bare.to_string_lossy().into_owned(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
|
||||
let pr = PullRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo.id.clone(),
|
||||
number: 1,
|
||||
title: "add b".into(),
|
||||
body: None,
|
||||
author_did: OWNER_DID.into(),
|
||||
source_branch: "feature".into(),
|
||||
target_branch: "main".into(),
|
||||
status: "open".into(),
|
||||
merged_by_did: None,
|
||||
merged_at: None,
|
||||
head_commit: None,
|
||||
created_at: now.to_rfc3339(),
|
||||
updated_at: now.to_rfc3339(),
|
||||
};
|
||||
state.db.create_pr(&pr).await.unwrap();
|
||||
// The stale value the racing push scenario leaves on the row.
|
||||
state
|
||||
.db
|
||||
.set_open_pr_heads(&repo.id, "feature", STALE_SHA)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let db = state.db.clone();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
|
||||
post(merge_pr),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(crate::test_support::signed_request_as(
|
||||
OWNER_DID,
|
||||
Method::POST,
|
||||
"/api/v1/repos/z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH/demo/pulls/1/merge",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let merged = db.get_pr(&repo.id, 1).await.unwrap().unwrap();
|
||||
assert_eq!(merged.status, "merged");
|
||||
assert_eq!(
|
||||
merged.head_commit,
|
||||
Some(feature_tip),
|
||||
"the merge must stamp the source head it consumed, not the racing value"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,32 @@ 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,8 +47,9 @@ pub async fn register_replica(
|
||||
|
||||
let replica_did = &auth.0;
|
||||
|
||||
// Don't let an owner register themselves as a replica of their own repo.
|
||||
if replica_did == &record.owner_did {
|
||||
// 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) {
|
||||
return Err(AppError::BadRequest(
|
||||
"the repo owner is not a replica of their own repo".into(),
|
||||
));
|
||||
@@ -112,16 +113,17 @@ pub async fn unregister_replica(
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/replicas
|
||||
/// Public — returns the list of replicas (DID + URL + registration timestamp).
|
||||
/// Read-visibility-gated: a PUBLIC repo's replica list stays anonymously
|
||||
/// listable (mirror-discovery), but a PRIVATE repo's replica URLs are hidden
|
||||
/// (404) from anyone who cannot read the repo at its root.
|
||||
pub async fn list_replicas(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let replicas = state.db.list_replicas(&record.id).await?;
|
||||
|
||||
|
||||
+3153
-267
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,14 @@
|
||||
//!
|
||||
//! Any authenticated agent can star or unstar a repo.
|
||||
//! One agent = one star per repo (enforced by UNIQUE constraint in repo_stars).
|
||||
//! Star count is public — no auth required on GET.
|
||||
//! Star count is gated on repo read access.
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// PUT /api/v1/repos/:owner/:repo/star
|
||||
@@ -19,11 +19,8 @@ pub async fn star_repo(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let caller = &auth.0;
|
||||
let inserted = state.db.star_repo(&record.id, caller).await?;
|
||||
@@ -54,11 +51,8 @@ pub async fn unstar_repo(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let caller = &auth.0;
|
||||
state.db.unstar_repo(&record.id, caller).await?;
|
||||
@@ -74,16 +68,15 @@ pub async fn unstar_repo(
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/star
|
||||
/// Returns star count — unauthenticated.
|
||||
/// Returns star count for callers who can read the repo.
|
||||
pub async fn get_star_status(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let count = state.db.count_stars(&record.id).await?;
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
//! Commit status claims: the write path and the combined read.
|
||||
//!
|
||||
//! POST /api/v1/repos/:owner/:repo/statuses/:sha — append one claim (owner only)
|
||||
//! GET /api/v1/repos/:owner/:repo/commits/:sha/status — the combined projection
|
||||
//!
|
||||
//! Claims are append-only: a producer reporting twice for the same context
|
||||
//! leaves both rows and the visible status is a projection over the history.
|
||||
//! Reporting twice means two REQUESTS, though. An exact repeat of one already
|
||||
//! accepted is answered with 200 and the row it wrote, not a second row: the
|
||||
//! projection elects the highest `seq`, so a replayed claim would not duplicate
|
||||
//! a verdict but overturn the one that superseded it.
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::StatusClaim;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Claims for one (repo, commit, producer, context). Generous for a real
|
||||
/// producer; a producer past it is misbehaving.
|
||||
const MAX_CLAIMS_PER_TUPLE: i64 = 100;
|
||||
/// Distinct contexts for one (repo, commit). The context string is caller-chosen
|
||||
/// and free-form, so the tuple cap alone bounds nothing.
|
||||
const MAX_CONTEXTS_PER_COMMIT: i64 = 50;
|
||||
/// Claim rows for one repo within the trailing window the db layer counts over.
|
||||
/// The commit SHA is caller-chosen and never existence-checked, so a writer at
|
||||
/// the two caps above can still fan out over fresh 40-hex strings without this
|
||||
/// one. It bounds the RATE of that fan-out rather than a lifetime total, because
|
||||
/// nothing prunes the table: a lifetime bound would close the surface on a repo
|
||||
/// permanently while answering with a status every client retries.
|
||||
const MAX_CLAIMS_PER_REPO_PER_WINDOW: i64 = 10_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateStatusRequest {
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// The wire state set (KTD-1): GitHub's four commit-status states, so absence
|
||||
/// stays distinguishable without a fifth value no client understands.
|
||||
const CLAIM_STATES: [&str; 4] = ["error", "failure", "pending", "success"];
|
||||
|
||||
/// Bounds on the signature material this write path persists verbatim. Every one
|
||||
/// of the four is caller-influenced: the headers come off the request, the
|
||||
/// signing string grows with the component list the caller's Signature-Input
|
||||
/// chose, and the body is the caller's. Without a bound the claim log's row size
|
||||
/// is set by whoever writes to it, so these are explicit and generous rather than
|
||||
/// implicit and absent. A conforming `sign_request` produces roughly 100, 200 and
|
||||
/// 400 bytes for the first three.
|
||||
const MAX_SIGNATURE_CHARS: usize = 512;
|
||||
const MAX_SIGNATURE_INPUT_CHARS: usize = 1024;
|
||||
const MAX_SIGNING_STRING_CHARS: usize = 4096;
|
||||
/// The body is a `CreateStatusRequest`, whose own fields are already capped at
|
||||
/// roughly 3.3 KB in total by the three limits below; this leaves room for JSON
|
||||
/// framing and nothing more.
|
||||
///
|
||||
/// Enforced twice, at two different moments, because the handler's check alone
|
||||
/// is not the bound it advertises: `require_signature` buffers the whole body
|
||||
/// before any handler runs, so by the time [`create_status`] measures it the
|
||||
/// allocation has already happened. `build_router` applies the same number as a
|
||||
/// `RequestBodyLimitLayer` at the transport, outside the auth layers, which is
|
||||
/// what makes it a bound on what is ALLOCATED and not only on what is STORED.
|
||||
/// The handler keeps its own check regardless — it is the one that holds for a
|
||||
/// handler reached by any other route table, and it is what the material size
|
||||
/// tests drive.
|
||||
pub(crate) const MAX_REQUEST_BODY_BYTES: usize = 8192;
|
||||
|
||||
const MAX_CONTEXT_CHARS: usize = 255;
|
||||
const MAX_TARGET_URL_CHARS: usize = 2048;
|
||||
const MAX_DESCRIPTION_CHARS: usize = 1024;
|
||||
|
||||
/// The 201 body for an accepted claim: what the client needs to identify the row
|
||||
/// it just wrote, and nothing else.
|
||||
///
|
||||
/// Deliberately not the stored [`StatusClaim`]. That struct carries the signature
|
||||
/// material, and serializing it echoed the signature, the signing string and the
|
||||
/// whole request body back on every write — the body as a JSON array of integers.
|
||||
/// Write-time provenance belongs in the row, not on the wire, which is the same
|
||||
/// line [`StatusEntry`] draws on the read side.
|
||||
#[derive(Serialize)]
|
||||
pub struct CreatedStatus {
|
||||
pub id: String,
|
||||
/// The database-assigned ordering key, so the client can name its own row.
|
||||
pub seq: i64,
|
||||
pub repo_id: String,
|
||||
pub commit_sha: String,
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub producer_did: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl CreatedStatus {
|
||||
fn from_claim(claim: StatusClaim, seq: i64) -> Self {
|
||||
Self {
|
||||
id: claim.id,
|
||||
seq,
|
||||
repo_id: claim.repo_id,
|
||||
commit_sha: claim.commit_sha,
|
||||
state: claim.state,
|
||||
context: claim.context,
|
||||
target_url: claim.target_url,
|
||||
description: claim.description,
|
||||
producer_did: claim.producer_did,
|
||||
created_at: claim.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/statuses/:sha
|
||||
pub async fn create_status(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, sha)): Path<(String, String, String)>,
|
||||
material: Option<Extension<crate::auth::SignatureMaterial>>,
|
||||
Json(req): Json<CreateStatusRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatedStatus>)> {
|
||||
// Read-visibility first, then owner, and the order is the security property.
|
||||
// authorize_repo_read denies a quarantined repo before the visibility gate and
|
||||
// answers with the repo's own not-found, byte-identical to a missing repo, so
|
||||
// a caller who cannot read the repo cannot learn it exists. Loading the repo
|
||||
// and comparing the owner would answer 403 there, turning this endpoint into
|
||||
// an existence oracle. require_repo_owner then 403s a non-owner of a repo the
|
||||
// caller can read, where existence is not secret.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(&auth.0), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let commit_sha = normalize_sha(&sha)?;
|
||||
let claim_state = validate_state(&req.state)?;
|
||||
let context = validate_context(&req.context)?;
|
||||
let target_url = validate_target_url(req.target_url.as_deref())?;
|
||||
let description = validate_description(req.description.as_deref())?;
|
||||
|
||||
// KTD-5: the signature material is captured here because the request carries
|
||||
// the only copy, and `signing_string` is the exact byte sequence the
|
||||
// middleware verified. Its absence means this handler was reached without
|
||||
// `require_signature` — a server misconfiguration (a route group that lost
|
||||
// its auth layer, a handler mounted somewhere new), not anything the client
|
||||
// did, so it is a 500 and no row is written. Storing an empty payload instead
|
||||
// would leave every claim from that moment on unverifiable with nothing going
|
||||
// red, which is exactly the absence-renders-as-success shape.
|
||||
let Some(Extension(material)) = material else {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"status claim write reached without verified signature material — \
|
||||
the route is not behind require_signature"
|
||||
)));
|
||||
};
|
||||
// The body is carried only behind the `PersistsSignedBody` marker, which
|
||||
// this route's group applies outside the auth layers. Its absence means the
|
||||
// marker did not reach the middleware, because the layer was dropped or
|
||||
// reordered. That is the same class of misconfiguration as the branch above
|
||||
// and gets the same answer. Storing an empty body instead would write claims
|
||||
// that carry a signature over a digest of bytes nobody kept, which is
|
||||
// exactly the case `stored_claim_re_verifies_and_a_tampered_row_does_not`
|
||||
// exists to make impossible.
|
||||
let Some(body) = material.body.clone() else {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"status claim write reached with signature material carrying no request body: \
|
||||
the route lost its persist-body marker or applied it inside the auth layers"
|
||||
)));
|
||||
};
|
||||
// Bounded before anything is written. The material is verified, which says
|
||||
// the caller holds the key, not that what they signed is a reasonable size.
|
||||
bound("signature", material.signature.len(), MAX_SIGNATURE_CHARS)?;
|
||||
bound(
|
||||
"signature_input",
|
||||
material.signature_input.len(),
|
||||
MAX_SIGNATURE_INPUT_CHARS,
|
||||
)?;
|
||||
bound(
|
||||
"signing_string",
|
||||
material.signing_string.len(),
|
||||
MAX_SIGNING_STRING_CHARS,
|
||||
)?;
|
||||
bound("request body", body.len(), MAX_REQUEST_BODY_BYTES)?;
|
||||
// After the bounds, so the scan below walks a signing string of known size.
|
||||
require_body_bound_signature(&material, &body)?;
|
||||
|
||||
let digest = request_digest(&material, &body);
|
||||
let (signature, signature_input, signing_string, request_body) = (
|
||||
material.signature,
|
||||
material.signature_input,
|
||||
material.signing_string,
|
||||
body.to_vec(),
|
||||
);
|
||||
|
||||
let claim = StatusClaim {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
// Ignored on insert: the database assigns the ordering key (KTD-3).
|
||||
seq: 0,
|
||||
repo_id: record.id.clone(),
|
||||
commit_sha,
|
||||
state: claim_state,
|
||||
context,
|
||||
target_url,
|
||||
description,
|
||||
// Both are the owner identity today (KTD-5); they split when delegated
|
||||
// capabilities land.
|
||||
producer_did: auth.0.clone(),
|
||||
authorizing_did: auth.0,
|
||||
signature,
|
||||
signature_input,
|
||||
signing_string,
|
||||
request_body,
|
||||
request_digest: digest,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
let caps = crate::db::ClaimCaps {
|
||||
per_tuple: MAX_CLAIMS_PER_TUPLE,
|
||||
contexts_per_commit: MAX_CONTEXTS_PER_COMMIT,
|
||||
per_repo_window: MAX_CLAIMS_PER_REPO_PER_WINDOW,
|
||||
};
|
||||
match state.db.insert_status_claim_capped(&claim, &caps).await? {
|
||||
crate::db::ClaimInsert::Inserted(seq) => Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatedStatus::from_claim(claim, seq)),
|
||||
)),
|
||||
// Already recorded, so 200 and the original row rather than 201 and a
|
||||
// second one. The claim the caller gets back is the stored one, id and
|
||||
// seq included, which is what makes a retry safe to treat as success.
|
||||
crate::db::ClaimInsert::AlreadyRecorded(existing) => {
|
||||
let seq = existing.seq;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(CreatedStatus::from_claim(*existing, seq)),
|
||||
))
|
||||
}
|
||||
crate::db::ClaimInsert::CapExceeded(which) => Err(AppError::TooManyRequests(format!(
|
||||
"claim limit reached for {which}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a claim whose signature does not cover the body about to be stored.
|
||||
///
|
||||
/// A claim IS provenance, so the signature stored beside it has to bind the
|
||||
/// bytes stored beside it. It does not do that directly: `signing_string`
|
||||
/// reaches the body only through a `content-digest` line. And a caller can sign
|
||||
/// a content-digest the request then never carries. `require_signature` rebuilds
|
||||
/// the covered values from the request, so an ABSENT `Content-Digest` header
|
||||
/// makes the covered digest the empty string on the signing side and the
|
||||
/// verifying side alike; the Ed25519 check passes over method, path and an empty
|
||||
/// digest, and any body at all rides along. The row that write produces asserts
|
||||
/// a provenance that cannot be re-verified: the signing string covers a digest
|
||||
/// of bytes that are not the ones in `request_body`.
|
||||
///
|
||||
/// The predicate here is the write-time form of the one `re_verify` runs against
|
||||
/// a stored row in the tests — recompute the digest of the bytes about to be
|
||||
/// persisted and require the signing string to carry exactly that line — so what
|
||||
/// this route accepts and what a third party can later re-verify are the same
|
||||
/// question, asked at both ends.
|
||||
///
|
||||
/// This is a route property, checked where the claim becomes history, and it is
|
||||
/// deliberately not the only line of defence: requiring the header in
|
||||
/// `require_signature` closes the same hole one layer earlier and for every
|
||||
/// signed route, and that change belongs there rather than here because no other
|
||||
/// route's correctness should depend on the status handler. The two agree and
|
||||
/// neither substitutes for the other — without the middleware's requirement the
|
||||
/// empty digest is still accepted everywhere else, and without this one a later
|
||||
/// relaxation of the middleware would silently resume writing unverifiable
|
||||
/// history.
|
||||
///
|
||||
/// A 400, not a 500: the material is exactly what the client signed, and what
|
||||
/// they signed does not cover what they sent.
|
||||
fn require_body_bound_signature(
|
||||
material: &crate::auth::SignatureMaterial,
|
||||
body: &[u8],
|
||||
) -> Result<()> {
|
||||
let covered = format!(
|
||||
"\"content-digest\": {}",
|
||||
gitlawb_core::http_sig::compute_content_digest(body)
|
||||
);
|
||||
// Line-exact rather than a substring search: `build_signing_string` emits one
|
||||
// component per line, and a `contains` would also be satisfied by the digest
|
||||
// appearing INSIDE some other covered component's value, which proves
|
||||
// nothing about what the signature covered.
|
||||
if material.signing_string.lines().any(|line| line == covered) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::BadRequest(
|
||||
"the signature does not cover this request body: send a Content-Digest header \
|
||||
computed over the body and name \"content-digest\" among the signature's covered \
|
||||
components"
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The identity of one signed write, as a hex sha-256.
|
||||
///
|
||||
/// All four inputs together, because a replay is the same bytes arriving twice:
|
||||
/// the signature, the input that names what it covers, the canonical string it
|
||||
/// was verified over, and the body that string covers through a content-digest.
|
||||
/// The signature alone would nearly do (it covers the other three transitively),
|
||||
/// but hashing what is actually stored means the digest describes the row rather
|
||||
/// than a claim about it.
|
||||
///
|
||||
/// Each field is length-prefixed so no two different requests can concatenate to
|
||||
/// the same bytes: without it, a signature ending in some prefix of the next
|
||||
/// field would collide with the shorter signature that absorbed it.
|
||||
///
|
||||
/// This is not a nonce, and deliberately so. A nonce table needs pruning and a
|
||||
/// pruning window is a second replay window; the claim row IS the record of what
|
||||
/// was accepted, so uniqueness on it is self-maintaining.
|
||||
fn request_digest(material: &crate::auth::SignatureMaterial, body: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut h = Sha256::new();
|
||||
for part in [
|
||||
material.signature.as_bytes(),
|
||||
material.signature_input.as_bytes(),
|
||||
material.signing_string.as_bytes(),
|
||||
body,
|
||||
] {
|
||||
h.update((part.len() as u64).to_be_bytes());
|
||||
h.update(part);
|
||||
}
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
|
||||
/// One reported context in the combined response. Signature material is
|
||||
/// deliberately absent: it is write-time provenance, not read-surface data.
|
||||
#[derive(Serialize)]
|
||||
pub struct StatusEntry {
|
||||
pub state: String,
|
||||
pub context: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub producer_did: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// The combined commit status. `state` never leaves the four-value set (KTD-1),
|
||||
/// so absence is carried by `total_count` 0 with the pending state rather than a
|
||||
/// fifth value, and `reported_only` (R19) says out loud that the state covers the
|
||||
/// contexts that reported, not every check a caller expected.
|
||||
#[derive(Serialize)]
|
||||
pub struct CombinedStatus {
|
||||
pub state: String,
|
||||
pub sha: String,
|
||||
pub total_count: usize,
|
||||
pub statuses: Vec<StatusEntry>,
|
||||
pub reported_only: bool,
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/commits/:sha/status
|
||||
///
|
||||
/// The auth extension is optional and MUST be last in the extractor list: the
|
||||
/// route group carries `optional_signature`, so an unsigned caller reaches here
|
||||
/// with no `AuthenticatedDid` at all and a public repo still answers.
|
||||
pub async fn commit_status(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, sha)): Path<(String, String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<CombinedStatus>> {
|
||||
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
|
||||
// The gate runs first and on the requested path, before any claim data is
|
||||
// touched. Its deny is the repo's own not-found, byte-identical to a missing
|
||||
// repo, so the status surface cannot answer "does this repo exist".
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let commit_sha = normalize_sha(&sha)?;
|
||||
let statuses = project_claims(&state, &record, &commit_sha).await?;
|
||||
|
||||
Ok(Json(CombinedStatus {
|
||||
state: combined_state(&statuses).to_string(),
|
||||
sha: commit_sha,
|
||||
total_count: statuses.len(),
|
||||
statuses,
|
||||
reported_only: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The projection, in one place. Both read surfaces call it, so R3's "the rollup
|
||||
/// is derived by the same projection as the commit read" holds by construction
|
||||
/// rather than by two implementations happening to agree.
|
||||
///
|
||||
/// KTD-5: current authorization, not write-time authorization. An ownership
|
||||
/// transfer drops the prior owner's claims from the projection while the
|
||||
/// append-only history keeps them.
|
||||
async fn project_claims(
|
||||
state: &AppState,
|
||||
record: &crate::db::RepoRecord,
|
||||
commit_sha: &str,
|
||||
) -> Result<Vec<StatusEntry>> {
|
||||
let claims = state
|
||||
.db
|
||||
.latest_status_claims(&record.id, commit_sha, &record.owner_did)
|
||||
.await?;
|
||||
Ok(claims
|
||||
.into_iter()
|
||||
.map(|c| StatusEntry {
|
||||
state: c.state,
|
||||
context: c.context,
|
||||
target_url: c.target_url,
|
||||
description: c.description,
|
||||
producer_did: c.producer_did,
|
||||
created_at: c.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The pull request head rollup (R11). `state` stays inside the four wire values
|
||||
/// whatever happened to the head (KTD-1): an unresolvable head is carried by
|
||||
/// `head_resolved`, alongside the pull request's own state, so a client can tell
|
||||
/// "the head could not be resolved" from "the head resolved and nothing reported"
|
||||
/// without a fifth state value no client understands.
|
||||
#[derive(Serialize)]
|
||||
pub struct PullRequestStatus {
|
||||
pub number: i64,
|
||||
/// The pull request's own state: open, closed, or merged.
|
||||
pub pull_request_state: String,
|
||||
pub head_resolved: bool,
|
||||
/// The target commit, absent exactly when `head_resolved` is false.
|
||||
pub sha: Option<String>,
|
||||
pub state: String,
|
||||
pub total_count: usize,
|
||||
pub statuses: Vec<StatusEntry>,
|
||||
pub reported_only: bool,
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/pulls/:number/status
|
||||
///
|
||||
/// Same optional-auth shape as the commit read, and the auth extension MUST stay
|
||||
/// last in the extractor list.
|
||||
pub async fn pull_request_status(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<PullRequestStatus>> {
|
||||
let caller = auth.as_ref().map(|Extension(a)| a.0.as_str());
|
||||
// The gate runs first, on the requested path, before the pull request row is
|
||||
// loaded. Its deny is the repo's own not-found, byte-identical to a missing
|
||||
// repo. Once it passes, a missing pull request number is the plain not-found:
|
||||
// existence is no longer secret.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
let head = rollup_head(&state, &record, &pr).await?;
|
||||
|
||||
let statuses = match &head {
|
||||
Some(sha) => project_claims(&state, &record, sha).await?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(Json(PullRequestStatus {
|
||||
number: pr.number,
|
||||
pull_request_state: pr.status,
|
||||
head_resolved: head.is_some(),
|
||||
sha: head,
|
||||
state: combined_state(&statuses).to_string(),
|
||||
total_count: statuses.len(),
|
||||
statuses,
|
||||
reported_only: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Branch-head lookups performed by the rollup fallback, counted so a test can
|
||||
/// assert WORK DONE rather than only the answer returned. A fallback that
|
||||
/// re-resolved on every read and then discarded the answer would be invisible in
|
||||
/// the response and visible here.
|
||||
#[cfg(test)]
|
||||
pub(crate) static BRANCH_RESOLVES: std::sync::atomic::AtomicUsize =
|
||||
std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// The rollup's target commit (KTD-4).
|
||||
///
|
||||
/// The stored head wins whenever it is set. It is frozen at close or merge, so a
|
||||
/// closed or merged pull request keeps the commit the decision was made against,
|
||||
/// and only an OPEN pull request with no stored head falls back to the branch.
|
||||
///
|
||||
/// The fallback is one database read: the latest push this node recorded for the
|
||||
/// pull request's source branch, from `repo_push_events`. It deliberately does
|
||||
/// NOT go through `git::store` ref listing, which needs an acquired repository
|
||||
/// path — on a cold node that downloads the whole repository from object storage,
|
||||
/// and this read group carries no rate limiter, so an anonymous caller could
|
||||
/// drive repeated downloads with a URL.
|
||||
///
|
||||
/// `repo_push_events` is the source rather than `branch_cids` because
|
||||
/// `record_push_events` writes it for every ref update unconditionally, whereas
|
||||
/// the sole writer of `branch_cids` sits behind a pin CID. A node with no object
|
||||
/// pinning configured never writes that table, so a resolve keyed on it could
|
||||
/// never answer there and every open pull request without a stored head reported
|
||||
/// `head_resolved: false` forever. The residual limit is that only pushes taken
|
||||
/// after this shipped have rows, so a branch last pushed before then still does
|
||||
/// not resolve.
|
||||
///
|
||||
/// The resolved head is persisted, which makes an unauthenticated GET write. That
|
||||
/// is only acceptable because the write is self-limiting: it fires exactly when
|
||||
/// `head_commit` is absent and it fills it, and the fill is conditioned on that
|
||||
/// same absence in SQL, so the PERSIST happens at most once per pull request.
|
||||
/// The resolve attempt itself is not once-only — a pull request whose branch does
|
||||
/// not resolve stores nothing, so there is nothing to short-circuit on and the
|
||||
/// lookup runs again on every read until it succeeds.
|
||||
async fn rollup_head(
|
||||
state: &AppState,
|
||||
record: &crate::db::RepoRecord,
|
||||
pr: &crate::db::PullRequest,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(stored) = pr.head_commit.clone() {
|
||||
return Ok(Some(stored));
|
||||
}
|
||||
if pr.status != "open" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// The push path records the full ref while a pull request stores the bare
|
||||
// branch name, the same mismatch `crate::api::repos::branch_from_ref`
|
||||
// reconciles on the write side; this is that mapping run backwards. The empty
|
||||
// branch is excluded for the same reason it is there: `refs/heads/` is not a
|
||||
// branch and nothing can have written it.
|
||||
if pr.source_branch.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ref_name = format!("refs/heads/{}", pr.source_branch);
|
||||
#[cfg(test)]
|
||||
BRANCH_RESOLVES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let resolved = state
|
||||
.db
|
||||
.latest_push_sha_for_ref(&record.id, &ref_name)
|
||||
.await?
|
||||
// A recorded head that is not a commit SHA is no target at all, and
|
||||
// storing it would leave the rollup pointing at nothing forever.
|
||||
.and_then(|sha| normalize_sha(&sha).ok());
|
||||
|
||||
let Some(sha) = resolved else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Best effort, and deliberately not `?`. The head is already resolved and the
|
||||
// response does not depend on this write landing, so a transient database
|
||||
// failure here must not turn a served read into a 500; the next read resolves
|
||||
// again and re-attempts the fill. Same catch-and-log the sibling writes on the
|
||||
// push path use (`update_open_pr_heads`, `record_push_events`).
|
||||
if let Err(e) = state.db.set_pr_head_if_absent(&pr.id, &sha).await {
|
||||
tracing::warn!(
|
||||
err = %e,
|
||||
pr_id = %pr.id,
|
||||
"failed to persist a resolved pull request head; serving the resolved head anyway"
|
||||
);
|
||||
}
|
||||
Ok(Some(sha))
|
||||
}
|
||||
|
||||
/// KTD-1's fold: any error or failure yields failure, else any pending yields
|
||||
/// pending, else success. An empty set is pending, never success — total absence
|
||||
/// of a verdict is its own state and must not read as a pass (R10).
|
||||
fn combined_state(statuses: &[StatusEntry]) -> &'static str {
|
||||
if statuses.is_empty() {
|
||||
return "pending";
|
||||
}
|
||||
if statuses
|
||||
.iter()
|
||||
.any(|s| s.state == "error" || s.state == "failure")
|
||||
{
|
||||
"failure"
|
||||
} else if statuses.iter().any(|s| s.state == "pending") {
|
||||
"pending"
|
||||
} else {
|
||||
"success"
|
||||
}
|
||||
}
|
||||
|
||||
/// 40 hex characters, lowercased. The SHA is never existence-checked (KTD-7):
|
||||
/// a claim describes a commit object, and the object may not have arrived yet.
|
||||
fn normalize_sha(sha: &str) -> Result<String> {
|
||||
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Ok(sha.to_ascii_lowercase())
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
"commit sha must be exactly 40 hexadecimal characters".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// One size bound on persisted signature material, measured in bytes.
|
||||
fn bound(what: &str, len: usize, max: usize) -> Result<()> {
|
||||
if len > max {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{what} must be at most {max} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_state(state: &str) -> Result<String> {
|
||||
if CLAIM_STATES.contains(&state) {
|
||||
Ok(state.to_string())
|
||||
} else {
|
||||
Err(AppError::BadRequest(format!(
|
||||
"state must be one of {}",
|
||||
CLAIM_STATES.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The context is the projection key, so it is trimmed, bounded, and required to
|
||||
/// be free of control characters.
|
||||
fn validate_context(context: &str) -> Result<String> {
|
||||
let trimmed = context.trim();
|
||||
if trimmed.is_empty() || trimmed.chars().count() > MAX_CONTEXT_CHARS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"context must be 1 to {MAX_CONTEXT_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(AppError::BadRequest(
|
||||
"context must not contain control characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn validate_target_url(url: Option<&str>) -> Result<Option<String>> {
|
||||
let Some(url) = url else { return Ok(None) };
|
||||
if url.chars().count() > MAX_TARGET_URL_CHARS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"target_url must be at most {MAX_TARGET_URL_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
Ok(Some(url.to_string()))
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
"target_url must be an http or https URL".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_description(description: Option<&str>) -> Result<Option<String>> {
|
||||
match description {
|
||||
Some(d) if d.chars().count() > MAX_DESCRIPTION_CHARS => Err(AppError::BadRequest(format!(
|
||||
"description must be at most {MAX_DESCRIPTION_CHARS} characters"
|
||||
))),
|
||||
other => Ok(other.map(str::to_string)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@
|
||||
//! POST /api/v1/tasks/{id}/fail — fail task
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
@@ -18,9 +18,18 @@ 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)]
|
||||
@@ -55,13 +64,11 @@ 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 {
|
||||
@@ -87,15 +94,20 @@ 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: body.delegator_did,
|
||||
delegator_did: auth.0,
|
||||
assignee_did: body.assignee_did,
|
||||
capability: body.capability,
|
||||
ucan_token: body.ucan_token,
|
||||
@@ -154,24 +166,25 @@ 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>)> {
|
||||
let task = state
|
||||
.db
|
||||
.claim_task(&id, &body.assignee_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
// 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 _ = state.task_event_tx.send(TaskEventBroadcast {
|
||||
task_id: id,
|
||||
old_status: "pending".to_string(),
|
||||
new_status: "claimed".to_string(),
|
||||
by_did: body.assignee_did,
|
||||
by_did: auth.0,
|
||||
at: Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(Json(task_to_json(&task)))
|
||||
@@ -180,17 +193,44 @@ 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>)> {
|
||||
let by_did = body.by_did.unwrap_or_default();
|
||||
// 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 task = state
|
||||
.db
|
||||
.finish_task(&id, "completed", body.result.as_deref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
@@ -207,10 +247,36 @@ 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>)> {
|
||||
let by_did = body.by_did.unwrap_or_default();
|
||||
// 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 reason = body.reason.unwrap_or_default();
|
||||
let task = state
|
||||
.db
|
||||
@@ -218,7 +284,7 @@ pub async fn fail_task(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Path-scoped visibility management API. Owner-only, mirrors `api/protect.rs`.
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::VisibilityMode;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetVisibilityRequest {
|
||||
pub path_glob: String,
|
||||
/// "a" or "b"; defaults to "b" if absent or unrecognized.
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reader_dids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveVisibilityRequest {
|
||||
pub path_glob: String,
|
||||
}
|
||||
|
||||
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(
|
||||
"only the repo owner can manage visibility".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject malformed globs before they reach the store, where they would
|
||||
/// silently misconfigure access (an empty glob behaves like "/", and a glob
|
||||
/// without a leading "/" never matches a real repo path). The accepted forms
|
||||
/// match what `visibility_check` understands: "/", "/prefix", or "/prefix/**".
|
||||
fn validate_path_glob(path_glob: &str) -> Result<()> {
|
||||
if !path_glob.starts_with('/') {
|
||||
return Err(AppError::BadRequest("path_glob must start with '/'".into()));
|
||||
}
|
||||
if path_glob == "/**" {
|
||||
return Err(AppError::BadRequest(
|
||||
"use '/' for whole-repo scope, not '/**'".into(),
|
||||
));
|
||||
}
|
||||
if path_glob != "/" && path_glob.ends_with('/') {
|
||||
return Err(AppError::BadRequest(
|
||||
"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("//") {
|
||||
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(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PUT /api/v1/repos/{owner}/{repo}/visibility
|
||||
pub async fn set_visibility(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<SetVisibilityRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
validate_path_glob(&req.path_glob)?;
|
||||
|
||||
let mode = match req.mode.as_deref() {
|
||||
Some("a") => VisibilityMode::A,
|
||||
_ => VisibilityMode::B,
|
||||
};
|
||||
|
||||
// Mode A hides existence and is only coherent for the whole repo; a subtree
|
||||
// cannot hide its existence without rewriting git history (see spec).
|
||||
if mode == VisibilityMode::A && req.path_glob != "/" {
|
||||
return Err(AppError::BadRequest(
|
||||
"mode 'a' (hide) is only allowed for the whole repo (path_glob '/'); use mode 'b' for subtrees".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// An empty reader_dids list is valid and intentional: the owner is always
|
||||
// allowed by visibility_check, so a "/" rule with no readers is exactly the
|
||||
// whole-repo "private to owner only" case.
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(&record.id, &req.path_glob, mode, &req.reader_dids, &auth.0)
|
||||
.await?;
|
||||
|
||||
let subtree_warning = req.path_glob != "/";
|
||||
tracing::info!(
|
||||
repo = %repo, caller = %auth.0, path_glob = %req.path_glob, mode = %mode.as_str(),
|
||||
subtree_pending = subtree_warning,
|
||||
"visibility rule set"
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
"status": "set",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"path_glob": req.path_glob,
|
||||
"mode": mode.as_str(),
|
||||
"reader_dids": req.reader_dids,
|
||||
"subtree_clone_enforcement_pending": subtree_warning,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/repos/{owner}/{repo}/visibility
|
||||
pub async fn remove_visibility(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<RemoveVisibilityRequest>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
|
||||
state
|
||||
.db
|
||||
.remove_visibility_rule(&record.id, &req.path_glob)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
repo = %repo, caller = %auth.0, path_glob = %req.path_glob,
|
||||
"visibility rule removed"
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "removed",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"path_glob": req.path_glob,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/visibility
|
||||
pub async fn list_visibility(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let rules_json: Vec<_> = rules
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"path_glob": r.path_glob,
|
||||
"mode": r.mode.as_str(),
|
||||
"reader_dids": r.reader_dids,
|
||||
"created_by": r.created_by,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"count": rules_json.len(),
|
||||
"rules": rules_json,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/withheld-paths
|
||||
///
|
||||
/// Returns the path globs the (optionally authenticated) caller is denied
|
||||
/// (`withheld`) plus any more-specific globs that are allowed underneath a
|
||||
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
|
||||
/// denied subtrees while re-including the allowed nested paths. Unlike
|
||||
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
|
||||
pub async fn withheld_paths(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
|
||||
// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
|
||||
// matching the git read endpoints, so this never discloses a private repo's
|
||||
// existence or its path layout to an unauthorized caller.
|
||||
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
|
||||
== crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
|
||||
let withheld =
|
||||
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
|
||||
let reinclude =
|
||||
crate::visibility::reincluded_globs(&rules, record.is_public, &record.owner_did, caller);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"withheld": withheld,
|
||||
"reinclude": reinclude,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_path_glob;
|
||||
|
||||
#[test]
|
||||
fn accepts_supported_globs() {
|
||||
for g in ["/", "/secret", "/secret/**", "/a/b/c", "/a/b/**"] {
|
||||
assert!(validate_path_glob(g).is_ok(), "{g} should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
#[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/**",
|
||||
] {
|
||||
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Webhook CRUD API.
|
||||
//!
|
||||
//! POST /api/v1/repos/:owner/:repo/hooks — create (auth required)
|
||||
//! GET /api/v1/repos/:owner/:repo/hooks — list
|
||||
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (auth required)
|
||||
//! POST /api/v1/repos/:owner/:repo/hooks — create (owner only)
|
||||
//! GET /api/v1/repos/:owner/:repo/hooks — list (owner only; auth required)
|
||||
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (owner only)
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -38,11 +38,16 @@ 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)?;
|
||||
|
||||
// Validate URL is http/https
|
||||
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
|
||||
// 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) {
|
||||
return Err(AppError::BadRequest(
|
||||
"webhook URL must be http:// or https://".into(),
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -68,12 +73,26 @@ pub async fn create_webhook(
|
||||
pub async fn list_webhooks(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
// This route sits on `optional_signature`, so the DID is optional. Webhook
|
||||
// callback URLs are owner-secret config and there is no anonymous form, so a
|
||||
// headerless caller is rejected before any lookup (401, which fires for an
|
||||
// existing-private and an absent repo alike, so it leaks no existence).
|
||||
let Some(Extension(AuthenticatedDid(caller))) = auth else {
|
||||
return Err(AppError::Unauthorized(
|
||||
"listing webhooks requires authentication".into(),
|
||||
));
|
||||
};
|
||||
|
||||
// Read-visibility first, then owner. authorize_repo_read returns 404 on a
|
||||
// visibility deny, so a non-reader of a private repo cannot tell it exists
|
||||
// (uniform with the sibling read surfaces); require_repo_owner then yields
|
||||
// 403 only for a non-owner of a public/readable repo, where existence is not
|
||||
// secret.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(&caller), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &caller)?;
|
||||
|
||||
let hooks = state.db.list_webhooks(&record.id).await?;
|
||||
// Redact secrets in list response
|
||||
@@ -95,6 +114,7 @@ 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
|
||||
@@ -102,6 +122,7 @@ 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
|
||||
@@ -117,3 +138,35 @@ 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/"));
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,108 @@ pub async fn anchor_ref_update(
|
||||
Ok(tx_id)
|
||||
}
|
||||
|
||||
/// A per-push manifest of the blobs encrypted this push (Option B3). The
|
||||
/// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON
|
||||
/// body so the discovery index survives total node loss. Recipient identities are
|
||||
/// never part of the manifest.
|
||||
pub struct EncryptedManifest<'a> {
|
||||
pub repo: &'a str,
|
||||
pub owner_did: &'a str,
|
||||
pub node_did: &'a str,
|
||||
pub timestamp: &'a str,
|
||||
pub blobs: &'a [(String, String)],
|
||||
}
|
||||
|
||||
/// Anchor a per-push encrypted-blob manifest to Arweave via Irys. The manifest
|
||||
/// JSON body is the payload (not a CID pointer to IPFS), so the index is
|
||||
/// permanent and self-contained. Recipient identities are deliberately omitted:
|
||||
/// the anchor is permanent and public, and the v2 envelopes no longer expose
|
||||
/// recipients, so the reader set must not be written to Arweave either.
|
||||
///
|
||||
/// Returns the Irys/Arweave transaction ID, or `Ok("")` when `irys_url` is empty
|
||||
/// (anchoring disabled) or there are no blobs to anchor.
|
||||
pub async fn anchor_encrypted_manifest(
|
||||
client: &reqwest::Client,
|
||||
irys_url: &str,
|
||||
manifest: &EncryptedManifest<'_>,
|
||||
) -> Result<String> {
|
||||
if irys_url.is_empty() || manifest.blobs.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let blobs_json: Vec<serde_json::Value> = manifest
|
||||
.blobs
|
||||
.iter()
|
||||
.map(|(oid, cid)| manifest_blob_json(oid, cid))
|
||||
.collect();
|
||||
|
||||
let payload = json!({
|
||||
"schema": "gitlawb/encrypted-manifest/v1",
|
||||
"repo": manifest.repo,
|
||||
"owner_did": manifest.owner_did,
|
||||
"node_did": manifest.node_did,
|
||||
"timestamp": manifest.timestamp,
|
||||
"blobs": blobs_json,
|
||||
});
|
||||
|
||||
let body = serde_json::to_vec(&payload)?;
|
||||
let url = format!("{}/upload", irys_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("x-irys-tags", build_manifest_tags_header(manifest))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!("Irys returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?;
|
||||
|
||||
let tx_id = json["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))?
|
||||
.to_string();
|
||||
|
||||
tracing::info!(
|
||||
repo = %manifest.repo,
|
||||
tx_id = %tx_id,
|
||||
blobs = manifest.blobs.len(),
|
||||
"anchored encrypted manifest to Arweave"
|
||||
);
|
||||
|
||||
Ok(tx_id)
|
||||
}
|
||||
|
||||
/// Serialize one blob for the Arweave manifest. Recipient identities are
|
||||
/// intentionally absent so the permanent public anchor never records who can
|
||||
/// read a blob.
|
||||
fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value {
|
||||
json!({ "oid": oid, "cid": cid })
|
||||
}
|
||||
|
||||
/// Build the Irys tag header for an encrypted-blob manifest. `Repo` and `Schema`
|
||||
/// are the tags the `gl` recovery query filters on.
|
||||
fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String {
|
||||
[
|
||||
"App-Name:gitlawb".to_string(),
|
||||
"Schema:gitlawb/encrypted-manifest/v1".to_string(),
|
||||
format!("Repo:{}", sanitize_tag(manifest.repo)),
|
||||
format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)),
|
||||
format!("Node-DID:{}", sanitize_tag(manifest.node_did)),
|
||||
]
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// Arweave permanent URL for a given Irys transaction ID.
|
||||
pub fn arweave_url(tx_id: &str) -> String {
|
||||
format!("https://arweave.net/{tx_id}")
|
||||
@@ -141,7 +243,7 @@ mod tests {
|
||||
repo: "alice/myrepo".into(),
|
||||
owner_did: "did:key:z6Mk...".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000000000000000000000000000".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000".into(),
|
||||
new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
|
||||
cid: Some("bafyreib5...".into()),
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
@@ -168,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(64),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a1b2c3d4".repeat(8),
|
||||
cid: None,
|
||||
timestamp: "2026-03-14T00:00:00Z".into(),
|
||||
@@ -184,6 +286,44 @@ 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");
|
||||
@@ -193,6 +333,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_manifest_anchor_noop_when_url_empty() {
|
||||
let client = reqwest::Client::new();
|
||||
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
|
||||
let m = EncryptedManifest {
|
||||
repo: "alice/r",
|
||||
owner_did: "did:key:zO",
|
||||
node_did: "did:key:zN",
|
||||
timestamp: "2026-06-11T00:00:00Z",
|
||||
blobs: &blobs,
|
||||
};
|
||||
assert_eq!(
|
||||
anchor_encrypted_manifest(&client, "", &m).await.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_manifest_anchor_noop_when_no_blobs() {
|
||||
let client = reqwest::Client::new();
|
||||
let blobs: Vec<(String, String)> = vec![];
|
||||
let m = EncryptedManifest {
|
||||
repo: "alice/r",
|
||||
owner_did: "did:key:zO",
|
||||
node_did: "did:key:zN",
|
||||
timestamp: "2026-06-11T00:00:00Z",
|
||||
blobs: &blobs,
|
||||
};
|
||||
// Non-empty URL, but no blobs: still a no-op.
|
||||
assert_eq!(
|
||||
anchor_encrypted_manifest(&client, "https://example.invalid", &m)
|
||||
.await
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_manifest_anchor_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _mock = server
|
||||
.mock("POST", "/upload")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"id":"MANIFESTTX123","timestamp":1710000000000,"version":"1.0.0"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
|
||||
let m = EncryptedManifest {
|
||||
repo: "alice/r",
|
||||
owner_did: "did:key:zO",
|
||||
node_did: "did:key:zN",
|
||||
timestamp: "2026-06-11T00:00:00Z",
|
||||
blobs: &blobs,
|
||||
};
|
||||
let r = anchor_encrypted_manifest(&client, &server.url(), &m).await;
|
||||
assert_eq!(r.unwrap(), "MANIFESTTX123");
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_blob_json_omits_recipients() {
|
||||
let v = manifest_blob_json("oid1", "cidA");
|
||||
assert_eq!(v["oid"], "oid1");
|
||||
assert_eq!(v["cid"], "cidA");
|
||||
assert!(
|
||||
v.get("recipients").is_none(),
|
||||
"Arweave manifest must not anchor recipient identities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_tag() {
|
||||
assert_eq!(sanitize_tag("alice/myrepo"), "alice/myrepo");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::Request;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -8,10 +8,74 @@ use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gitlawb_core::did::Did;
|
||||
use gitlawb_core::ucan::Ucan;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// The authenticated agent's DID, injected into request extensions by `require_signature`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// The RFC 9421 material this request was actually verified against, injected
|
||||
/// alongside [`AuthenticatedDid`] by `require_signature`.
|
||||
///
|
||||
/// A handler that records a signed claim as history has to store it: the header
|
||||
/// values and the canonical signing string are gone once the request ends, and a
|
||||
/// stored claim nobody can re-verify is not history. `signing_string` is the exact
|
||||
/// byte sequence the Ed25519 verification succeeded over, not a rebuild — a
|
||||
/// handler reconstructing it from headers would be verifying a different string
|
||||
/// than the middleware did.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SignatureMaterial {
|
||||
/// The `Signature` header value.
|
||||
pub signature: String,
|
||||
/// The `Signature-Input` header value.
|
||||
pub signature_input: String,
|
||||
/// The canonical bytes the signature covered.
|
||||
pub signing_string: String,
|
||||
/// The buffered request body, on the routes that persist it. The signing
|
||||
/// string covers it only through the content-digest, so without these bytes
|
||||
/// a stored claim can be shown to carry *a* valid signature over *some*
|
||||
/// digest and nothing more. Carried here because the body is consumed
|
||||
/// downstream by the extractor and cannot be recovered afterwards.
|
||||
///
|
||||
/// `Option`, and populated only behind [`PersistsSignedBody`], because a
|
||||
/// route that never stores the body has no use for a second handle to it.
|
||||
/// The unlike-sized field is this one: the three above are header-derived
|
||||
/// and bounded by the server's header limit, while a receive-pack POST
|
||||
/// carries up to GITLAWB_MAX_PACK_BYTES (2 GB by default). `Bytes`, not
|
||||
/// `Vec<u8>`, so the handle the marked routes do take is a refcount bump
|
||||
/// over the buffer the middleware already holds rather than a copy.
|
||||
pub body: Option<axum::body::Bytes>,
|
||||
}
|
||||
|
||||
/// Marks a route group whose handler persists the signed request body.
|
||||
///
|
||||
/// Applied as an extension layer OUTSIDE the auth layers, so it is on the
|
||||
/// request before [`require_signature`] decides whether to carry the body. A
|
||||
/// marker applied inside them is never seen, and the failure is silent: the
|
||||
/// handler still compiles, tests that inject material by hand still pass, and
|
||||
/// production stores nothing. The status write handler refuses an absent body
|
||||
/// for that reason.
|
||||
///
|
||||
/// A marker rather than a path check because `require_signature` must not learn
|
||||
/// which URL persists claims; that knowledge belongs at the route table.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PersistsSignedBody;
|
||||
|
||||
/// Whether `caller` is authorized to push to `record`.
|
||||
///
|
||||
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
|
||||
/// [`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,
|
||||
};
|
||||
@@ -221,10 +285,149 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
|
||||
tracing::info!(did = %sig.key_id, "✓ authenticated request");
|
||||
|
||||
// The body travels only where it is stored. On every other signed route the
|
||||
// middleware's handle would keep the buffer alive for the rest of the layer
|
||||
// chain, until the handler's own extractor consumes the request.
|
||||
let body = parts
|
||||
.extensions
|
||||
.get::<PersistsSignedBody>()
|
||||
.map(|_| body_bytes.clone());
|
||||
|
||||
let material = SignatureMaterial {
|
||||
signature: sig_header,
|
||||
signature_input: sig_input,
|
||||
signing_string,
|
||||
body,
|
||||
};
|
||||
|
||||
let mut request = Request::from_parts(parts, Body::from(body_bytes));
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedDid(sig.key_id.to_string()));
|
||||
// Carry the verified material forward for handlers that persist a signed
|
||||
// claim; none of it can be recovered once the request is gone.
|
||||
request.extensions_mut().insert(material);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
|
||||
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
|
||||
/// continue when no signature attempt was made.
|
||||
pub async fn optional_signature(request: Request, next: Next) -> Response {
|
||||
let has_signature_headers = request.headers().contains_key("signature-input")
|
||||
|| request.headers().contains_key("signature");
|
||||
if has_signature_headers {
|
||||
return require_signature(request, next).await;
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Validate a raw UCAN token string supplied in `X-Ucan`.
|
||||
///
|
||||
/// Checks performed:
|
||||
/// 1. The token decodes to a valid [`Ucan`] structure.
|
||||
/// 2. The UCAN issuer (`iss`) matches `signer_did` — the DID that signed the
|
||||
/// HTTP request — preventing replay of another agent's UCAN.
|
||||
/// 3. The UCAN audience (`aud`) matches `expected_aud` — the node's own DID.
|
||||
/// 4. The full proof chain is cryptographically valid (signatures, expiry,
|
||||
/// not-before, chain linkage, and capability attenuation).
|
||||
fn validate_ucan_chain(
|
||||
token: &str,
|
||||
expected_aud: &Did,
|
||||
signer_did: &Did,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
let ucan = Ucan::decode(token).map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
if &ucan.payload.iss != signer_did {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "invalid_ucan",
|
||||
"message": format!(
|
||||
"UCAN issuer {} does not match request signer {}",
|
||||
ucan.payload.iss, signer_did
|
||||
),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
ucan.verify_audience(expected_aud).map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
ucan.verify_chain().map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
|
||||
///
|
||||
/// Must be layered so that it runs after [`require_signature`], which sets the
|
||||
/// [`AuthenticatedDid`] extension consumed here.
|
||||
///
|
||||
/// When `X-Ucan` is absent the request passes through unchanged, preserving
|
||||
/// backward compatibility for agents that pre-date UCAN delegation. When the
|
||||
/// header is present the full chain is validated: the UCAN issuer must match
|
||||
/// the HTTP Signature identity, the audience must be this node's DID, and
|
||||
/// every proof in the chain must be cryptographically sound with no capability
|
||||
/// escalation.
|
||||
pub async fn require_ucan_chain(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let token = match request
|
||||
.headers()
|
||||
.get("x-ucan")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
{
|
||||
Some(t) => t,
|
||||
None => return next.run(request).await,
|
||||
};
|
||||
|
||||
let signer_did: Did = match request.extensions().get::<AuthenticatedDid>() {
|
||||
Some(a) => match a.0.parse() {
|
||||
Ok(did) => did,
|
||||
Err(e) => {
|
||||
tracing::warn!(raw_did = %a.0, err = %e, "failed to parse DID from authenticated identity");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_identity", "message": "invalid DID in token" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "invalid_ucan",
|
||||
"message": "UCAN validation requires a valid HTTP Signature",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
return (status, body).into_response();
|
||||
}
|
||||
|
||||
tracing::debug!(did = %signer_did, "UCAN chain validated");
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -246,3 +449,324 @@ fn human_detected(message: &str) -> impl IntoResponse {
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{middleware, Router};
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn bootstrap_ucan(node: &Keypair, agent_did: Did) -> Ucan {
|
||||
Ucan::bootstrap(node, agent_did).unwrap()
|
||||
}
|
||||
|
||||
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
|
||||
Ucan::delegate(
|
||||
agent,
|
||||
node_did,
|
||||
vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)],
|
||||
None,
|
||||
proof,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ucan_chain_valid() {
|
||||
let node = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node_did = node.did();
|
||||
let agent_did = agent.did();
|
||||
|
||||
let proof = bootstrap_ucan(&node, agent_did.clone());
|
||||
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
|
||||
let token = delegation.encode().unwrap();
|
||||
|
||||
assert!(validate_ucan_chain(&token, &node_did, &agent_did).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ucan_chain_wrong_issuer() {
|
||||
let node = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let other = Keypair::generate();
|
||||
let node_did = node.did();
|
||||
let agent_did = agent.did();
|
||||
|
||||
let proof = bootstrap_ucan(&node, agent_did.clone());
|
||||
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
|
||||
let token = delegation.encode().unwrap();
|
||||
|
||||
// signer_did is `other` but UCAN iss is `agent` — must be rejected
|
||||
let err = validate_ucan_chain(&token, &node_did, &other.did()).unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
let body = err.1 .0.to_string();
|
||||
assert!(body.contains("does not match request signer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ucan_chain_wrong_audience() {
|
||||
let node = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let other_node = Keypair::generate();
|
||||
let node_did = node.did();
|
||||
let agent_did = agent.did();
|
||||
|
||||
let proof = bootstrap_ucan(&node, agent_did.clone());
|
||||
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
|
||||
let token = delegation.encode().unwrap();
|
||||
|
||||
// expected_aud is a different node — must be rejected
|
||||
let err = validate_ucan_chain(&token, &other_node.did(), &agent_did).unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
let body = err.1 .0.to_string();
|
||||
assert!(body.contains("audience mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ucan_chain_expired_proof() {
|
||||
let node = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node_did = node.did();
|
||||
let agent_did = agent.did();
|
||||
|
||||
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||
let proof = Ucan::issue(
|
||||
&node,
|
||||
agent_did.clone(),
|
||||
vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)],
|
||||
Some(exp),
|
||||
)
|
||||
.unwrap();
|
||||
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
|
||||
let token = delegation.encode().unwrap();
|
||||
|
||||
let err = validate_ucan_chain(&token, &node_did, &agent_did).unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
let body = err.1 .0.to_string();
|
||||
assert!(body.contains("expired"));
|
||||
}
|
||||
|
||||
fn make_test_state(node_did: gitlawb_core::did::Did) -> crate::state::AppState {
|
||||
use crate::{config::Config, graphql, rate_limit::RateLimiter};
|
||||
use clap::Parser;
|
||||
|
||||
let keypair = Keypair::generate();
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
|
||||
.expect("lazy pool creation should not fail");
|
||||
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
|
||||
let schema = Arc::new(graphql::build_schema(
|
||||
db.clone(),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
));
|
||||
crate::state::AppState {
|
||||
config: Arc::new(Config::parse_from(["gitlawb-node"])),
|
||||
db,
|
||||
node_did,
|
||||
node_keypair: Arc::new(keypair),
|
||||
p2p: None,
|
||||
http_client: Arc::new(reqwest::Client::new()),
|
||||
ref_update_tx: ref_tx,
|
||||
task_event_tx: task_tx,
|
||||
graphql_schema: schema,
|
||||
machine_id: None,
|
||||
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
|
||||
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
|
||||
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
shutdown_tx: tokio::sync::watch::channel(false).0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports what the signature middleware put in `SignatureMaterial.body`,
|
||||
/// so a test can tell "captured" from "deliberately not captured" apart
|
||||
/// from "the middleware never ran".
|
||||
async fn report_body(material: Option<axum::Extension<SignatureMaterial>>) -> String {
|
||||
match material {
|
||||
Some(axum::Extension(m)) => match m.body {
|
||||
Some(bytes) => format!("captured:{}", bytes.len()),
|
||||
None => "absent".to_string(),
|
||||
},
|
||||
None => "no-material".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_post(path: &str, body: &[u8]) -> Request {
|
||||
let kp = Keypair::generate();
|
||||
let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", path, body);
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(axum::body::Body::from(body.to_vec()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The body handle travels only on a route that asked for it.
|
||||
///
|
||||
/// Both arms drive a genuinely signed request through `require_signature`;
|
||||
/// the only difference is the [`PersistsSignedBody`] marker layer. A
|
||||
/// middleware that captured unconditionally would make both arms read
|
||||
/// `captured`, and one that never read the marker would make both read
|
||||
/// `absent`, so the pair pins the decision rather than the mechanism.
|
||||
#[tokio::test]
|
||||
async fn the_body_is_carried_only_when_the_route_marks_itself_as_persisting_it() {
|
||||
let body = br#"{"state":"success"}"#;
|
||||
|
||||
// The marker layer is added AFTER the middleware layer, which is what
|
||||
// makes it the outer of the two and therefore the one that runs first.
|
||||
// This is the same relative position `build_router` uses on the status
|
||||
// write group, where the marker sits outside `add_auth_layers`.
|
||||
let marked = Router::new()
|
||||
.route("/", axum::routing::post(report_body))
|
||||
.layer(middleware::from_fn(require_signature))
|
||||
.layer(axum::Extension(PersistsSignedBody));
|
||||
let resp = marked.oneshot(signed_post("/", body)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(seen.to_vec()).unwrap(),
|
||||
format!("captured:{}", body.len()),
|
||||
"a route carrying the persist marker must receive the buffered body"
|
||||
);
|
||||
|
||||
let unmarked = Router::new()
|
||||
.route("/", axum::routing::post(report_body))
|
||||
.layer(middleware::from_fn(require_signature));
|
||||
let resp = unmarked.oneshot(signed_post("/", body)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(seen.to_vec()).unwrap(),
|
||||
"absent",
|
||||
"a signed route that never persists the body must not be handed a handle to it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The marker is read from the request the middleware was given, and the
|
||||
/// request it hands downstream still carries it. Both halves matter: the
|
||||
/// first is the capture decision, the second is that rebuilding the request
|
||||
/// from its parts does not drop extensions a later layer may want.
|
||||
#[tokio::test]
|
||||
async fn the_marker_survives_the_request_rebuild() {
|
||||
async fn report_marker(marker: Option<axum::Extension<PersistsSignedBody>>) -> String {
|
||||
match marker {
|
||||
Some(_) => "marked".to_string(),
|
||||
None => "unmarked".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::post(report_marker))
|
||||
.layer(middleware::from_fn(require_signature))
|
||||
.layer(axum::Extension(PersistsSignedBody));
|
||||
let resp = app.oneshot(signed_post("/", b"{}")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let seen = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(seen.to_vec()).unwrap(),
|
||||
"marked",
|
||||
"the marker must reach the handler, so the middleware saw it too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn require_ucan_chain_no_header_passes_through() {
|
||||
let state = make_test_state(Keypair::generate().did());
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(|| async { StatusCode::OK }))
|
||||
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn require_ucan_chain_missing_did_returns_401() {
|
||||
let state = make_test_state(Keypair::generate().did());
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(|| async { StatusCode::OK }))
|
||||
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
|
||||
|
||||
// x-ucan present but no AuthenticatedDid extension → 401
|
||||
let req = Request::builder()
|
||||
.uri("/")
|
||||
.header("x-ucan", "any-token")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn require_ucan_chain_wrong_issuer_returns_401() {
|
||||
let node = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let other = Keypair::generate();
|
||||
let node_did = node.did();
|
||||
let agent_did = agent.did();
|
||||
|
||||
// Build a valid token where iss = agent, but supply `other` as the signer.
|
||||
let proof = bootstrap_ucan(&node, agent_did.clone());
|
||||
let token = delegation_ucan(&agent, node_did.clone(), &proof)
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
let state = make_test_state(node_did);
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(|| async { StatusCode::OK }))
|
||||
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
|
||||
|
||||
// AuthenticatedDid is `other`, UCAN iss is `agent` → issuer mismatch → 401
|
||||
let req = Request::builder()
|
||||
.uri("/")
|
||||
.header("x-ucan", token)
|
||||
.extension(AuthenticatedDid(other.did().to_string()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn require_ucan_chain_malformed_token_returns_401() {
|
||||
let state = make_test_state(Keypair::generate().did());
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(|| async { StatusCode::OK }))
|
||||
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
|
||||
|
||||
// Malformed x-ucan (invalid JSON)
|
||||
let req = Request::builder()
|
||||
.uri("/")
|
||||
.header("x-ucan", "invalid-token-structure")
|
||||
.extension(AuthenticatedDid(Keypair::generate().did().to_string()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let body_bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(body_json["error"], "invalid_ucan");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use libp2p::Multiaddr;
|
||||
use libp2p_core::Multiaddr;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ pub async fn issue_ref_certificate(
|
||||
issued_at,
|
||||
};
|
||||
|
||||
state.db.insert_ref_certificate(&cert).await?;
|
||||
Ok(cert)
|
||||
// Persist and return the row as it exists in the database (on a
|
||||
// conflict the existing row survives when it is newer).
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
@@ -28,7 +28,7 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_KEY", default_value = "~/.gitlawb/identity.pem")]
|
||||
pub key_path: String,
|
||||
|
||||
/// Allow unauthenticated reads (default: true for public repos)
|
||||
/// Reserved for private-read mode; per-repo read enforcement is not wired in alpha
|
||||
#[arg(long, env = "GITLAWB_PUBLIC_READ", default_value_t = true)]
|
||||
pub public_read: bool,
|
||||
|
||||
@@ -40,6 +40,23 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
|
||||
pub bootstrap_peers: Vec<String>,
|
||||
|
||||
/// Require RFC 9421 signatures on peer announce/sync write routes.
|
||||
/// Keep false during rolling upgrades so existing live nodes can still gossip.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_REQUIRE_SIGNED_PEER_WRITES",
|
||||
default_value_t = false
|
||||
)]
|
||||
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,
|
||||
@@ -56,12 +73,12 @@ pub struct Config {
|
||||
)]
|
||||
pub pinata_upload_url: String,
|
||||
|
||||
/// libp2p TCP port (0 = disabled)
|
||||
/// libp2p QUIC/UDP port (0 = disabled)
|
||||
#[arg(long, env = "GITLAWB_P2P_PORT", default_value_t = 7546)]
|
||||
pub p2p_port: u16,
|
||||
|
||||
/// libp2p bootstrap multiaddrs (comma-separated)
|
||||
/// Example: /ip4/1.2.3.4/tcp/7546/p2p/12D3KooW...
|
||||
/// Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
|
||||
#[arg(long, env = "GITLAWB_P2P_BOOTSTRAP", value_delimiter = ',')]
|
||||
pub p2p_bootstrap: Vec<String>,
|
||||
|
||||
@@ -122,6 +139,101 @@ pub struct Config {
|
||||
/// Default: 2 GB. Set lower on resource-constrained nodes.
|
||||
#[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)]
|
||||
pub max_pack_bytes: usize,
|
||||
|
||||
/// Per-client-IP rate limit for `POST /api/v1/sync/trigger`, in requests per
|
||||
/// hour. `/sync/trigger` requires a signature and drives an O(peers) outbound
|
||||
/// fan-out per call, so it gets a tight bucket. `0` disables. Default: 60.
|
||||
#[arg(long, env = "GITLAWB_SYNC_TRIGGER_RATE_LIMIT", default_value_t = 60)]
|
||||
pub sync_trigger_rate_limit: usize,
|
||||
|
||||
/// Per-client-IP rate limit for the peer-write routes (`/peers/announce`,
|
||||
/// `/sync/notify`), in requests per hour. These accept unsigned requests from
|
||||
/// known peers and run at higher frequency, so the bucket is generous. Keeping
|
||||
/// it separate from the trigger bucket stops an unsigned notify flood from
|
||||
/// draining the signed trigger caller's quota. `0` disables. Default: 600.
|
||||
#[arg(long, env = "GITLAWB_PEER_WRITE_RATE_LIMIT", default_value_t = 600)]
|
||||
pub peer_write_rate_limit: usize,
|
||||
|
||||
/// Optional address to bind a Prometheus `/metrics` exposition endpoint on.
|
||||
/// Example: `127.0.0.1:9091`. Leave empty (default) to disable.
|
||||
/// Bind to localhost or a private interface — the metrics endpoint is
|
||||
/// unauthenticated.
|
||||
#[arg(long, env = "GITLAWB_METRICS_ADDR", default_value = "")]
|
||||
pub metrics_addr: String,
|
||||
|
||||
/// Maximum time to wait for in-flight requests to drain on shutdown, in
|
||||
/// seconds. After this elapses, the server returns 503 to anything still
|
||||
/// in flight and exits. Default: 30s.
|
||||
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
|
||||
pub shutdown_grace_secs: u64,
|
||||
|
||||
/// Maximum wall-clock time a single served git operation (upload-pack /
|
||||
/// receive-pack through `run_git_service`) may run before it is aborted and
|
||||
/// its process group torn down, in seconds. Bounds a git that neither
|
||||
/// finishes nor disconnects. Must be positive; set it very large to
|
||||
/// effectively disable the bound. Default: 600s (10 min), generous for large
|
||||
/// clones. Does not cover the ref advertisement (`info/refs`) or the
|
||||
/// withheld-blob fetch path (`upload_pack_excluding`, a blocking
|
||||
/// `spawn_blocking` a tokio timeout cannot cancel); both remain unbounded.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_GIT_SERVICE_TIMEOUT_SECS",
|
||||
default_value_t = 600,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub git_service_timeout_secs: u64,
|
||||
|
||||
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
|
||||
/// (connections open lazily). Size against the database server's
|
||||
/// max_connections, remembering admin tooling opens its own pool.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_MAX_CONNECTIONS",
|
||||
default_value_t = 20,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub db_max_connections: u32,
|
||||
|
||||
/// Maximum time a request waits for a pool connection before failing with
|
||||
/// 503, in seconds. Bounds queueing when the database is slow or down.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_ACQUIRE_TIMEOUT_SECS",
|
||||
default_value_t = 5,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_acquire_timeout_secs: u64,
|
||||
|
||||
/// Upper bound on each startup connect-and-migrate attempt, in seconds.
|
||||
/// Migrations wait on a cross-instance advisory lock, so this must be
|
||||
/// generous enough for a peer instance to finish migrating; on expiry the
|
||||
/// attempt is retried (migrations are idempotent).
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_CONNECT_TIMEOUT_SECS",
|
||||
default_value_t = 60,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_connect_timeout_secs: u64,
|
||||
|
||||
/// First retry delay when the database is unavailable at startup, in
|
||||
/// seconds. Doubles each attempt up to --db-retry-max-secs.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_RETRY_INITIAL_SECS",
|
||||
default_value_t = 5,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_retry_initial_secs: u64,
|
||||
|
||||
/// Ceiling on the startup retry delay, in seconds.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_RETRY_MAX_SECS",
|
||||
default_value_t = 60,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub db_retry_max_secs: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -139,3 +251,25 @@ impl Config {
|
||||
PathBuf::from(&self.key_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).git_service_timeout_secs,
|
||||
600
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--git-service-timeout-secs", "30"])
|
||||
.git_service_timeout_secs,
|
||||
30
|
||||
);
|
||||
// 0 is a footgun (immediate-504 on every request); clap must reject it.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--git-service-timeout-secs", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
+6294
-167
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,362 @@
|
||||
//! Encrypt-then-pin for withheld blobs (Option B1). Each withheld blob is sealed
|
||||
//! to its recipient DIDs and the envelope pinned to IPFS, recorded in
|
||||
//! `encrypted_blobs`. Best-effort per blob: a failure is logged and skipped,
|
||||
//! never pinned in plaintext.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use gitlawb_core::did::Did;
|
||||
use gitlawb_core::encrypt::seal_blob;
|
||||
|
||||
use crate::db::Db;
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Opaque, node-keyed fingerprint of a blob's recipient set. Stored in place of
|
||||
/// the cleartext DID list so a DB compromise cannot reveal the reader set; used
|
||||
/// only to detect a recipient-set change so an unchanged blob is not re-sealed.
|
||||
/// Order-insensitive (the input `BTreeSet` is already sorted).
|
||||
pub fn recipients_tag(node_seed: &[u8; 32], dids: &BTreeSet<String>) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
|
||||
mac.update(b"gitlawb/recipients-tag/v1");
|
||||
for did in dids {
|
||||
mac.update(b"\n");
|
||||
mac.update(did.as_bytes());
|
||||
}
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Resolve a DID string to its Ed25519 verifying key, or None if it carries no
|
||||
/// inline key (e.g. did:web / did:gitlawb).
|
||||
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
|
||||
/// to anchor a manifest. Recipient identities are never stored or returned.
|
||||
pub async fn encrypt_and_pin(
|
||||
ipfs_api: &str,
|
||||
repo_path: &Path,
|
||||
db: &Db,
|
||||
repo_id: &str,
|
||||
node_seed: &[u8; 32],
|
||||
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,
|
||||
Err(e) => {
|
||||
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 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;
|
||||
}
|
||||
};
|
||||
let envelope = match seal_blob(&data, &keys) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(oid = %oid, err = %e, "seal_blob failed; skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
};
|
||||
if let Err(e) = db.record_encrypted_blob(repo_id, oid, &cid, &tag).await {
|
||||
tracing::warn!(oid = %oid, err = %e, "record_encrypted_blob failed");
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_is_order_insensitive() {
|
||||
let seed = [7u8; 32];
|
||||
let a = recipients_tag(&seed, &set(&["did:key:zA", "did:key:zB"]));
|
||||
let b = recipients_tag(&seed, &set(&["did:key:zB", "did:key:zA"]));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_differs_for_different_sets() {
|
||||
let seed = [7u8; 32];
|
||||
let a = recipients_tag(&seed, &set(&["did:key:zA"]));
|
||||
let b = recipients_tag(&seed, &set(&["did:key:zA", "did:key:zB"]));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_is_keyed_by_node_seed() {
|
||||
let dids = set(&["did:key:zA", "did:key:zB"]);
|
||||
let a = recipients_tag(&[1u8; 32], &dids);
|
||||
let b = recipients_tag(&[2u8; 32], &dids);
|
||||
assert_ne!(
|
||||
a, b,
|
||||
"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,21 +23,98 @@ pub enum AppError {
|
||||
#[allow(dead_code)]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("icaptcha proof required: {message}")]
|
||||
IcaptchaProofRequired {
|
||||
message: String,
|
||||
/// iCaptcha service base URL the client should solve against.
|
||||
url: String,
|
||||
/// Minimum proof level this node requires.
|
||||
level: u32,
|
||||
},
|
||||
|
||||
#[error("invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("too many requests: {0}")]
|
||||
TooManyRequests(String),
|
||||
|
||||
#[error("incomplete: {0}")]
|
||||
Incomplete(String),
|
||||
|
||||
#[error("git error: {0}")]
|
||||
Git(String),
|
||||
|
||||
#[error("git service timed out: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
/// Shared error code/message for "the database is unreachable", also used by
|
||||
/// the degraded startup server (main.rs) and the readiness probe (server.rs)
|
||||
/// so clients see one vocabulary for the condition.
|
||||
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
|
||||
pub const DB_UNAVAILABLE_MESSAGE: &str = "database is temporarily unavailable";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
sqlx::Error::PoolTimedOut
|
||||
| sqlx::Error::PoolClosed
|
||||
| sqlx::Error::Io(_)
|
||||
| sqlx::Error::Tls(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// The db layer returns `anyhow::Result`, so sqlx errors reach handlers inside
|
||||
/// anyhow chains. Downcast them back out so the status mapping below can see
|
||||
/// them — without this, every database outage surfaces as a 500 instead of a
|
||||
/// 503. anyhow preserves downcastability through `.context()` layers.
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
match err.downcast::<sqlx::Error>() {
|
||||
Ok(sql) => AppError::Db(sql),
|
||||
Err(err) => AppError::Internal(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
// iCaptcha challenges carry structured discovery so clients don't have to
|
||||
// scrape the message: the service URL and required level are returned as
|
||||
// both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers
|
||||
// (mirroring the header-bearing `human_detected` response in auth/mod.rs).
|
||||
if let AppError::IcaptchaProofRequired {
|
||||
message,
|
||||
url,
|
||||
level,
|
||||
} = &self
|
||||
{
|
||||
use axum::http::HeaderValue;
|
||||
let body = Json(json!({
|
||||
"error": "icaptcha_proof_required",
|
||||
"message": message,
|
||||
"icaptcha_url": url,
|
||||
"required_level": level,
|
||||
}));
|
||||
let mut resp = (StatusCode::FORBIDDEN, body).into_response();
|
||||
let headers = resp.headers_mut();
|
||||
if let Ok(v) = HeaderValue::from_str(url) {
|
||||
headers.insert("x-icaptcha-url", v);
|
||||
}
|
||||
if let Ok(v) = HeaderValue::from_str(&level.to_string()) {
|
||||
headers.insert("x-icaptcha-level", v);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
let (status, code, message) = match &self {
|
||||
AppError::RepoNotFound(r) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -52,8 +129,24 @@ 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()),
|
||||
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
|
||||
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
|
||||
AppError::TooManyRequests(msg) => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
|
||||
}
|
||||
AppError::Incomplete(msg) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
|
||||
}
|
||||
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
|
||||
// 504, distinct from the 500 git_error and from the read-gate's 404 /
|
||||
// the auth 401, so the client can tell a deadline from a failure.
|
||||
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
|
||||
AppError::Db(e) if db_unavailable(e) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
DB_UNAVAILABLE_CODE,
|
||||
DB_UNAVAILABLE_MESSAGE.into(),
|
||||
),
|
||||
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
|
||||
AppError::Internal(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -72,3 +165,21 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504_distinct_from_git_500() {
|
||||
assert_eq!(
|
||||
AppError::Timeout("x".into()).into_response().status(),
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
);
|
||||
// Guard against a swap with the generic git failure (500).
|
||||
assert_eq!(
|
||||
AppError::Git("x".into()).into_response().status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,18 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
|
||||
use std::io::Write;
|
||||
let mut child = hash_output;
|
||||
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")?;
|
||||
}
|
||||
// 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",
|
||||
)),
|
||||
};
|
||||
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,7 @@
|
||||
pub mod issues;
|
||||
pub mod push_delta;
|
||||
pub mod repo_store;
|
||||
pub mod smart_http;
|
||||
pub mod store;
|
||||
pub mod tigris;
|
||||
pub mod visibility_pack;
|
||||
@@ -0,0 +1,663 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,16 @@ pub struct RepoStore {
|
||||
}
|
||||
|
||||
impl RepoStore {
|
||||
#[cfg(test)]
|
||||
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
|
||||
Self {
|
||||
repos_dir,
|
||||
tigris: None,
|
||||
pool,
|
||||
migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, pool: PgPool) -> Self {
|
||||
Self {
|
||||
repos_dir,
|
||||
@@ -120,10 +130,19 @@ impl RepoStore {
|
||||
if let Some(ref tigris) = self.tigris {
|
||||
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
|
||||
debug!(repo = %repo_name, "acquire_fresh: downloading latest from tigris");
|
||||
tigris
|
||||
.download(&owner_slug, repo_name, &local_path)
|
||||
.await
|
||||
.context("downloading repo from tigris (fresh)")?;
|
||||
if let Err(e) = tigris.download(&owner_slug, repo_name, &local_path).await {
|
||||
// The Tigris archive is present (HEAD ok) but unreadable — a
|
||||
// corrupt/partial upload, or a transient GET failure. If we have a
|
||||
// valid local copy, proceed with it rather than blocking the write;
|
||||
// the post-write upload re-syncs (self-heals) Tigris. Only hard-fail
|
||||
// when there is no local copy to fall back to.
|
||||
if local_path.exists() {
|
||||
warn!(repo = %repo_name, err = %e,
|
||||
"acquire_fresh: tigris download failed — falling back to local copy");
|
||||
return Ok(local_path);
|
||||
}
|
||||
return Err(e).context("downloading repo from tigris (fresh)");
|
||||
}
|
||||
return Ok(local_path);
|
||||
}
|
||||
}
|
||||
@@ -164,10 +183,17 @@ impl RepoStore {
|
||||
if let Some(ref tigris) = self.tigris {
|
||||
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
|
||||
debug!(repo = %repo_name, "write acquire: downloading latest from tigris");
|
||||
tigris
|
||||
.download(&owner_slug, repo_name, &local_path)
|
||||
.await
|
||||
.context("downloading repo from tigris for write")?;
|
||||
if let Err(e) = tigris.download(&owner_slug, repo_name, &local_path).await {
|
||||
// Same self-healing fallback as acquire_fresh: a corrupt/unreadable
|
||||
// Tigris archive must not block a write when a valid local copy
|
||||
// exists — release(success) will re-upload a good archive.
|
||||
if local_path.exists() {
|
||||
warn!(repo = %repo_name, err = %e,
|
||||
"write acquire: tigris download failed — falling back to local copy");
|
||||
} else {
|
||||
return Err(e).context("downloading repo from tigris for write");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +303,131 @@ fn validate_path_components(owner_did: &str, repo_name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a peer-supplied `owner/name` sync slug and return its two halves.
|
||||
///
|
||||
/// The sync queue carries a single `repo` string that peers control, and the
|
||||
/// worker turns it into a filesystem path. `PathBuf::join` does not normalize,
|
||||
/// and an absolute second component replaces the accumulated path, so an
|
||||
/// unvalidated `a//tmp/x` resolved to `/tmp/x.git` outside `repos_dir` (#272).
|
||||
///
|
||||
/// The halves are checked with the same validators that guard
|
||||
/// `RepoStore::local_path`, so there is one owner rule and one name rule in the
|
||||
/// crate. The one rule added here is the leading `.`/`-` check on the owner
|
||||
/// half: `validate_owner_did` has no such rule (it also serves full DIDs, which
|
||||
/// always start with `d`), and without it an owner half of `.` puts a
|
||||
/// peer-controlled mirror at the `repos_dir` root, which canonicalizes back
|
||||
/// inside the root and so passes containment.
|
||||
pub(crate) fn validate_repo_slug(slug: &str) -> Result<(&str, &str)> {
|
||||
let mut parts = slug.split('/');
|
||||
let (Some(owner), Some(name)) = (parts.next(), parts.next()) else {
|
||||
anyhow::bail!("repo slug must be 'owner/name'");
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
anyhow::bail!("repo slug must contain exactly one '/'");
|
||||
}
|
||||
if owner.is_empty() || name.is_empty() {
|
||||
anyhow::bail!("repo slug has an empty owner or name");
|
||||
}
|
||||
if owner.starts_with('.') || owner.starts_with('-') {
|
||||
anyhow::bail!("repo slug owner must not start with '.' or '-'");
|
||||
}
|
||||
// The owner half becomes one path component, so it is bounded by NAME_MAX
|
||||
// (255), not by the DID column's 256. The two differ by exactly one, and
|
||||
// that one length is the gap that matters: validate_owner_did accepts 256,
|
||||
// create_dir_all then fails with ENAMETOOLONG on every attempt, and the
|
||||
// worker leaves such a row pending, so it is re-picked forever. Rejecting
|
||||
// it here means an undeliverable slug never enters the queue at all.
|
||||
if owner.len() > 255 {
|
||||
anyhow::bail!("repo slug owner exceeds 255 chars");
|
||||
}
|
||||
validate_owner_did(owner)?;
|
||||
validate_repo_name(name)?;
|
||||
Ok((owner, name))
|
||||
}
|
||||
|
||||
/// The answer from [`path_within_root`].
|
||||
///
|
||||
/// Three-valued rather than a bool because the two negative answers call for
|
||||
/// opposite handling. `Outside` is a deterministic verdict about a hostile or
|
||||
/// misconfigured path: the same input fails the same way forever, so the caller
|
||||
/// can retire the work. `IoError` says the question could not be answered at
|
||||
/// all (EACCES, an unmounted root), which is transient, so the caller must keep
|
||||
/// the work and try again rather than permanently retire a legitimate repo.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Containment {
|
||||
/// The candidate resolves inside the root.
|
||||
Contained,
|
||||
/// The candidate resolves outside the root.
|
||||
Outside,
|
||||
/// The filesystem could not answer the question.
|
||||
IoError(std::io::Error),
|
||||
}
|
||||
|
||||
/// Does `candidate` canonically resolve inside `root`?
|
||||
///
|
||||
/// The third layer of path defence, after the character allowlist and the
|
||||
/// component walk on `RepoStore::local_path`. Those two read the path as text
|
||||
/// and cannot see a symlink standing between the root and the target (#272).
|
||||
///
|
||||
/// One contract covers both the clone and the fetch branch. `symlink_metadata`
|
||||
/// decides which:
|
||||
///
|
||||
/// * The candidate exists (including as a symlink), so the candidate itself is
|
||||
/// canonicalized. That resolves the link and catches a mirror path that is a
|
||||
/// symlink to a bare repo outside the root, which a parent-only check misses
|
||||
/// entirely: the parent canonicalizes clean, `exists()` follows the link, and
|
||||
/// the fetch then writes through it.
|
||||
/// * The candidate does not exist, so its parent is canonicalized instead.
|
||||
/// This is the first-clone case. Canonicalizing the candidate unconditionally
|
||||
/// would reject every first clone, since `canonicalize` errors on a path that
|
||||
/// does not exist.
|
||||
///
|
||||
/// Pure: it reads the filesystem and never creates, moves, or removes anything.
|
||||
/// Callers that need the parent directory to exist create it themselves before
|
||||
/// asking, because a predicate that created a directory as a side effect of
|
||||
/// being asked would be wrong for a caller asking about a path it is about to
|
||||
/// delete.
|
||||
pub(crate) fn path_within_root(candidate: &Path, root: &Path) -> Containment {
|
||||
let root = match root.canonicalize() {
|
||||
Ok(p) => p,
|
||||
// A root that cannot be resolved is an operator condition, never a
|
||||
// verdict on the candidate, so every error kind is retryable here.
|
||||
Err(e) => return Containment::IoError(e),
|
||||
};
|
||||
|
||||
let resolved = match std::fs::symlink_metadata(candidate) {
|
||||
// The candidate exists as an entry: resolve it, links and all. A failure
|
||||
// now (a dangling symlink, a permission change mid-flight) is an I/O
|
||||
// answer, since the entry was there a moment ago.
|
||||
Ok(_) => match candidate.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Containment::IoError(e),
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let Some(parent) = candidate.parent() else {
|
||||
return Containment::Outside;
|
||||
};
|
||||
match parent.canonicalize() {
|
||||
Ok(p) => p,
|
||||
// A parent that is not there is a real answer about where this
|
||||
// path sits; anything else is the filesystem failing to answer.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Containment::Outside;
|
||||
}
|
||||
Err(e) => return Containment::IoError(e),
|
||||
}
|
||||
}
|
||||
// Neither "it is there" nor "it is not there": we cannot tell.
|
||||
Err(e) => return Containment::IoError(e),
|
||||
};
|
||||
|
||||
if resolved.starts_with(&root) {
|
||||
Containment::Contained
|
||||
} else {
|
||||
Containment::Outside
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_owner_did(owner_did: &str) -> Result<()> {
|
||||
if owner_did.is_empty() {
|
||||
anyhow::bail!("owner_did is empty");
|
||||
@@ -338,16 +489,24 @@ impl RepoWriteGuard {
|
||||
&self.local_path
|
||||
}
|
||||
|
||||
/// Upload to Tigris and release the advisory lock. Call this when the write is done.
|
||||
pub async fn release(self) {
|
||||
// Upload to Tigris
|
||||
if let Some(ref tigris) = self.tigris {
|
||||
if let Err(e) = tigris
|
||||
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
|
||||
.await
|
||||
{
|
||||
warn!(repo = %self.repo_name, err = %e, "failed to upload repo to tigris after write");
|
||||
/// Upload to Tigris (only when the write succeeded) and release the advisory
|
||||
/// lock. Pass `success = false` when the write operation failed — uploading a
|
||||
/// half-applied or otherwise inconsistent repo would propagate corruption to
|
||||
/// Tigris (and to every node that later downloads it). The lock is always
|
||||
/// released regardless, to avoid stale locks blocking future writes.
|
||||
pub async fn release(self, success: bool) {
|
||||
// Upload to Tigris only on success.
|
||||
if success {
|
||||
if let Some(ref tigris) = self.tigris {
|
||||
if let Err(e) = tigris
|
||||
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
|
||||
.await
|
||||
{
|
||||
warn!(repo = %self.repo_name, err = %e, "failed to upload repo to tigris after write");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(repo = %self.repo_name, "write failed — skipping tigris upload to avoid propagating an inconsistent repo");
|
||||
}
|
||||
|
||||
// Release advisory lock
|
||||
@@ -371,6 +530,251 @@ fn advisory_lock_key(owner_slug: &str, repo_name: &str) -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── sync slug validation (#272) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn slug_accepts_owner_and_name() {
|
||||
let (owner, name) = validate_repo_slug("z6Mkfoo/hello").expect("valid slug");
|
||||
assert_eq!(owner, "z6Mkfoo");
|
||||
assert_eq!(name, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_traversal_in_owner_half() {
|
||||
assert!(validate_repo_slug("../hello").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_owner_half_only_the_did_validator_catches() {
|
||||
// These are the cases that isolate the `validate_owner_did` delegation.
|
||||
// `../hello` does NOT: the leading-character rule above rejects it
|
||||
// first, so deleting the delegation leaves that case green. Each owner
|
||||
// half here has exactly one separator, a non-empty name, and a leading
|
||||
// character the slug rules allow, so only the delegation can reject it.
|
||||
for bad in [
|
||||
"a..b/hello", // interior `..` sequence
|
||||
"a%2e%2e/hello", // percent-encoded, disallowed `%`
|
||||
"own\\er/hello", // backslash
|
||||
] {
|
||||
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_extra_separator() {
|
||||
// The verified #272 escape: `a//tmp/x` joined to an absolute
|
||||
// `/tmp/x.git` outside repos_dir.
|
||||
assert!(validate_repo_slug("a//tmp/gitlawb-probe").is_err());
|
||||
assert!(validate_repo_slug("../../etc/evil").is_err());
|
||||
assert!(validate_repo_slug("a/../../x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_trailing_segment_only_the_separator_count_catches() {
|
||||
// The case that isolates the separator-count rule. Every slug in
|
||||
// `slug_rejects_extra_separator` is caught by some earlier rule
|
||||
// instead: `a//tmp/...` has an empty name half, `../../etc/evil` trips
|
||||
// the leading-character rule, and `a/../../x` has `..` as its name. Here
|
||||
// both halves are individually valid, so only the count can reject it.
|
||||
// It matters because the worker would otherwise join
|
||||
// `repos_dir/z6Mkfoo/hello.git` while composing the remote URL from the
|
||||
// full three-segment slug, silently mirroring one repo under another's
|
||||
// path.
|
||||
assert!(validate_repo_slug("z6Mkfoo/hello/extra").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_missing_separator() {
|
||||
for bad in ["..", "demo", ""] {
|
||||
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_empty_half() {
|
||||
for bad in ["/hello", "z6Mkfoo/"] {
|
||||
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_leading_dot_or_dash_owner() {
|
||||
// `./hello` would otherwise resolve to a mirror at the repos_dir root,
|
||||
// which the containment check would approve.
|
||||
for bad in ["./hello", "-owner/hello"] {
|
||||
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_bad_name_half() {
|
||||
for bad in [
|
||||
"z6Mkfoo/he\0llo",
|
||||
"z6Mkfoo/.hidden",
|
||||
"z6Mkfoo/-dash",
|
||||
"z6Mkfoo/a..b",
|
||||
] {
|
||||
assert!(validate_repo_slug(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_overlong_halves() {
|
||||
let long_owner = format!("{}/hello", "z".repeat(257));
|
||||
let long_name = format!("z6Mkfoo/{}", "n".repeat(101));
|
||||
assert!(validate_repo_slug(&long_owner).is_err());
|
||||
assert!(validate_repo_slug(&long_name).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_rejects_owner_half_at_the_filesystem_name_limit() {
|
||||
// The owner half becomes a single path component, and Linux NAME_MAX is
|
||||
// 255, so 256 is accepted by validate_owner_did (which bails only above
|
||||
// 256) but can never be created on disk. That made the sync row
|
||||
// permanently un-runnable: create_dir_all failed with ENAMETOOLONG on
|
||||
// every pass and the worker left the row pending, so ten unsigned
|
||||
// requests could hold the whole oldest-first batch forever.
|
||||
assert!(validate_repo_slug(&format!("{}/hello", "z".repeat(256))).is_err());
|
||||
// 255 is the largest creatable component and must still be accepted, so
|
||||
// the bound is not quietly over-tightened.
|
||||
assert!(validate_repo_slug(&format!("{}/hello", "z".repeat(255))).is_ok());
|
||||
}
|
||||
|
||||
// ── canonical containment (#272) ───────────────────────────────────────
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn containment_accepts_a_path_inside_the_root() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let inside = root.path().join("z6Mkfoo");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
assert!(matches!(
|
||||
path_within_root(&inside.join("hello.git"), root.path()),
|
||||
Containment::Contained
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn containment_rejects_a_sibling_outside_the_root() {
|
||||
let base = TempDir::new().unwrap();
|
||||
let root = base.path().join("root");
|
||||
let sibling = base.path().join("other");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
assert!(matches!(
|
||||
path_within_root(&sibling, &root),
|
||||
Containment::Outside
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn containment_rejects_a_symlinked_directory_inside_the_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let base = TempDir::new().unwrap();
|
||||
let root = base.path().join("root");
|
||||
let outside = base.path().join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let link = root.join("owner");
|
||||
symlink(&outside, &link).unwrap();
|
||||
assert!(matches!(
|
||||
path_within_root(&link, &root),
|
||||
Containment::Outside
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn containment_rejects_a_symlinked_file_inside_the_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let base = TempDir::new().unwrap();
|
||||
let root = base.path().join("root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let outside = base.path().join("secret.txt");
|
||||
std::fs::write(&outside, b"x").unwrap();
|
||||
let link = root.join("hello.git");
|
||||
symlink(&outside, &link).unwrap();
|
||||
assert!(matches!(
|
||||
path_within_root(&link, &root),
|
||||
Containment::Outside
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn containment_accepts_a_missing_candidate_whose_parent_is_inside() {
|
||||
// The first-clone case: the mirror path does not exist yet, so only the
|
||||
// parent can be canonicalized. Rejecting this is total loss of mirroring.
|
||||
let root = TempDir::new().unwrap();
|
||||
let owner = root.path().join("z6Mkfoo");
|
||||
std::fs::create_dir_all(&owner).unwrap();
|
||||
let candidate = owner.join("hello.git");
|
||||
assert!(!candidate.exists());
|
||||
assert!(matches!(
|
||||
path_within_root(&candidate, root.path()),
|
||||
Containment::Contained
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn containment_rejects_a_missing_candidate_under_a_symlinked_parent() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let base = TempDir::new().unwrap();
|
||||
let root = base.path().join("root");
|
||||
let outside = base.path().join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
symlink(&outside, root.join("z6Mkfoo")).unwrap();
|
||||
let candidate = root.join("z6Mkfoo").join("hello.git");
|
||||
assert!(matches!(
|
||||
path_within_root(&candidate, &root),
|
||||
Containment::Outside
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn containment_reports_io_error_for_a_dangling_symlink() {
|
||||
// The link entry exists, so the candidate is the thing to resolve, and
|
||||
// resolving it fails. That is an I/O answer, not a verdict of Outside:
|
||||
// the worker must retry rather than permanently retire the row.
|
||||
use std::os::unix::fs::symlink;
|
||||
let root = TempDir::new().unwrap();
|
||||
let link = root.path().join("hello.git");
|
||||
symlink(root.path().join("nothing-here"), &link).unwrap();
|
||||
assert!(matches!(
|
||||
path_within_root(&link, root.path()),
|
||||
Containment::IoError(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn containment_reports_io_error_for_an_uncanonicalizable_root() {
|
||||
// A repos_dir that cannot be resolved is an operator condition (an
|
||||
// unmounted volume, a bad config), not a hostile path.
|
||||
let base = TempDir::new().unwrap();
|
||||
let root = base.path().join("not-mounted");
|
||||
let candidate = base.path().join("not-mounted").join("hello.git");
|
||||
assert!(matches!(
|
||||
path_within_root(&candidate, &root),
|
||||
Containment::IoError(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn containment_creates_nothing_on_disk() {
|
||||
// The predicate is pure: the admin purge path asks it about directories
|
||||
// it is about to delete, so creating one as a side effect would be wrong.
|
||||
let root = TempDir::new().unwrap();
|
||||
let candidate = root.path().join("z6Mkfoo").join("hello.git");
|
||||
let _ = path_within_root(&candidate, root.path());
|
||||
assert!(!candidate.exists());
|
||||
assert!(!root.path().join("z6Mkfoo").exists());
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
// ── repo_name validation ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -271,9 +271,8 @@ pub struct TreeEntry {
|
||||
/// `/ipfs/<cid>` is computed from these same content bytes via
|
||||
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
|
||||
///
|
||||
/// Returns `None` if the object does not exist in this repo.
|
||||
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||
// First check if the object exists and get its type
|
||||
/// Get just the object type. Returns `None` if the object doesn't exist.
|
||||
pub fn object_type(repo_path: &Path, sha256_hex: &str) -> Result<Option<String>> {
|
||||
let type_output = Command::new("git")
|
||||
.args(["cat-file", "-t", sha256_hex])
|
||||
.current_dir(repo_path)
|
||||
@@ -284,13 +283,17 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let obj_type = String::from_utf8_lossy(&type_output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(Some(
|
||||
String::from_utf8_lossy(&type_output.stdout)
|
||||
.trim()
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
// Read the raw content bytes
|
||||
/// Read an object's content if its type is already known.
|
||||
pub fn read_object_content(repo_path: &Path, sha256_hex: &str, obj_type: &str) -> Result<Vec<u8>> {
|
||||
let content_output = Command::new("git")
|
||||
.args(["cat-file", &obj_type, sha256_hex])
|
||||
.args(["cat-file", obj_type, sha256_hex])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git cat-file <type>")?;
|
||||
@@ -300,7 +303,24 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
|
||||
bail!("git cat-file failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(Some((obj_type, content_output.stdout)))
|
||||
Ok(content_output.stdout)
|
||||
}
|
||||
|
||||
/// Read a git object by its SHA-256 hex object ID.
|
||||
///
|
||||
/// Returns `(object_type, content_bytes)` where `content_bytes` is the raw
|
||||
/// object content (without the git framing header). The CID served over
|
||||
/// `/ipfs/<cid>` is computed from these same content bytes via
|
||||
/// `gitlawb_core::cid::Cid::from_git_object_bytes`.
|
||||
///
|
||||
/// Returns `None` if the object does not exist in this repo.
|
||||
pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let obj_type = match object_type(repo_path, sha256_hex)? {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let content = read_object_content(repo_path, sha256_hex, &obj_type)?;
|
||||
Ok(Some((obj_type, content)))
|
||||
}
|
||||
|
||||
/// Get the diff between two branches: changes on source_branch not in target_branch.
|
||||
@@ -314,6 +334,39 @@ pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
/// The repo-relative paths changed by `git diff target...source` (the same range
|
||||
/// as `branch_diff`). Used to enforce per-path visibility on a PR diff: if the
|
||||
/// caller cannot read one of these paths, the diff is withheld.
|
||||
pub fn branch_diff_names(
|
||||
repo_path: &Path,
|
||||
target_branch: &str,
|
||||
source_branch: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
&format!("{target_branch}...{source_branch}"),
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git diff --name-only")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git diff --name-only failed: {stderr}");
|
||||
}
|
||||
// Split on NUL (`-z`) so paths containing newlines keep their exact bytes;
|
||||
// `--name-only` without `-z` would quote/escape such paths and they would no
|
||||
// longer match the visibility globs in get_pr_diff, leaking the diff.
|
||||
Ok(output
|
||||
.stdout
|
||||
.split(|b| *b == b'\0')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| String::from_utf8_lossy(s).into_owned())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Merge source_branch into target_branch in a bare repo using a temporary worktree.
|
||||
/// Returns the new merge commit hash.
|
||||
pub fn merge_branch(
|
||||
@@ -399,3 +452,53 @@ pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> Pat
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
repos_dir.join(owner_slug).join(format!("{repo_name}.git"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::branch_diff_names;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn branch_diff_names_lists_changed_paths() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let work: &Path = td.path();
|
||||
let g = |args: &[&str]| {
|
||||
assert!(Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(work)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
};
|
||||
g(&["init", "-q"]);
|
||||
g(&["config", "user.email", "t@t"]);
|
||||
g(&["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("base.txt"), b"base\n").unwrap();
|
||||
g(&["add", "."]);
|
||||
g(&["commit", "-qm", "base"]);
|
||||
let main = {
|
||||
let o = Command::new("git")
|
||||
.args(["symbolic-ref", "--short", "HEAD"])
|
||||
.current_dir(work)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
||||
};
|
||||
g(&["checkout", "-q", "-b", "feature"]);
|
||||
std::fs::create_dir_all(work.join("secret")).unwrap();
|
||||
std::fs::write(work.join("secret/x.txt"), b"secret\n").unwrap();
|
||||
g(&["add", "."]);
|
||||
g(&["commit", "-qm", "feat"]);
|
||||
|
||||
let names = branch_diff_names(work, &main, "feature").unwrap();
|
||||
assert!(
|
||||
names.iter().any(|p| p == "secret/x.txt"),
|
||||
"expected secret/x.txt in changed paths, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.iter().any(|p| p == "base.txt"),
|
||||
"unchanged file must not appear: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
//! Repos are stored as `repos/v1/{owner_slug}/{repo_name}.tar.zst` — a
|
||||
//! zstd-compressed tar archive of the bare repo directory.
|
||||
|
||||
use std::path::Path;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
@@ -110,8 +112,7 @@ impl TigrisClient {
|
||||
.collect()
|
||||
.await
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes()
|
||||
.to_vec();
|
||||
.into_bytes();
|
||||
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
@@ -156,17 +157,70 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
Ok(compressed)
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = local_path.parent() {
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
}
|
||||
std::fs::create_dir_all(local_path).context("creating repo dir")?;
|
||||
/// 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()
|
||||
}
|
||||
|
||||
let decoder = zstd::stream::Decoder::new(data)?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(local_path).context("unpacking tar.zst")?;
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
/// into a sibling temp directory first, and only swapped into place once it
|
||||
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
|
||||
/// good existing copy at `local_path` — on failure we discard the temp dir and
|
||||
/// leave `local_path` exactly as it was.
|
||||
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
let parent = local_path.parent().context("repo path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
|
||||
let file_name = local_path
|
||||
.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()));
|
||||
|
||||
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
|
||||
// touching local_path.
|
||||
let unpack = (|| -> Result<()> {
|
||||
let decoder = zstd::stream::Decoder::new(data)?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = unpack {
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 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");
|
||||
if local_path.exists() {
|
||||
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
|
||||
}
|
||||
std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,11 +3,21 @@ 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]
|
||||
@@ -18,6 +28,13 @@ 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 {
|
||||
@@ -47,6 +64,13 @@ 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
|
||||
@@ -70,8 +94,27 @@ 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
|
||||
@@ -93,8 +136,26 @@ 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))
|
||||
@@ -110,3 +171,123 @@ 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,11 +12,29 @@ impl QueryRoot {
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let repos = db
|
||||
.list_all_repos()
|
||||
.list_all_repos_deduped()
|
||||
.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,
|
||||
@@ -31,16 +49,49 @@ impl QueryRoot {
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
repo: Option<String>,
|
||||
#[graphql(default = 20)] limit: i64,
|
||||
#[graphql(
|
||||
default = 20,
|
||||
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<RefUpdateType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let updates = db
|
||||
.list_ref_updates_filtered(repo.as_deref(), limit)
|
||||
|
||||
// Gate each row on the same "/" visibility decision the repos resolver
|
||||
// uses, so anonymous callers get no row for a local repo they can't read
|
||||
// (#112). The shared collector applies the fail-closed gate *before* the
|
||||
// limit (paging past dropped private rows) so a small limit still returns
|
||||
// the latest visible events, and keeps this surface byte-identical to the
|
||||
// REST feed (#114). The row slug is peer-supplied, so the pure filter
|
||||
// treats it as untrusted input; remote (no local match) rows pass.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let updates =
|
||||
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
// Resolve the trusted display owner_did per row, identical to the REST
|
||||
// feed: the stored wire value is untrusted, so it is echoed only when it
|
||||
// matches the canonical owner of the local repo the slug names (#P1);
|
||||
// legacy None rows are attributed via an exact unique local match (#P3).
|
||||
// The batch resolver issues at most one query per distinct local repo
|
||||
// rather than one per event row (#P2).
|
||||
let pairs: Vec<(&str, Option<&str>)> = updates
|
||||
.iter()
|
||||
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
|
||||
.collect();
|
||||
let owner_dids = db
|
||||
.resolve_ref_update_owner_dids(&pairs)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
Ok(updates
|
||||
|
||||
let resolved: Vec<RefUpdateType> = updates
|
||||
.into_iter()
|
||||
.map(|u| RefUpdateType {
|
||||
.zip(owner_dids)
|
||||
.map(|(u, owner_did)| RefUpdateType {
|
||||
repo: u.repo,
|
||||
ref_name: u.ref_name,
|
||||
old_sha: u.old_sha,
|
||||
@@ -48,8 +99,10 @@ impl QueryRoot {
|
||||
pusher_did: u.pusher_did,
|
||||
node_did: u.node_did,
|
||||
timestamp: u.timestamp,
|
||||
owner_did,
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn tasks(
|
||||
@@ -76,3 +129,294 @@ impl QueryRoot {
|
||||
Ok(t.map(AgentTaskType::from))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OWNER: &str = "did:key:z6MkOwner";
|
||||
|
||||
async fn db(pool: PgPool) -> Arc<Db> {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
Arc::new(db)
|
||||
}
|
||||
|
||||
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
super::super::build_schema(db, ref_tx, task_tx)
|
||||
}
|
||||
|
||||
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
|
||||
RepoRecord {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
owner_did: owner_did.into(),
|
||||
description: None,
|
||||
is_public,
|
||||
default_branch: "main".into(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
disk_path: format!("/srv/{id}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ref_row(id: &str, slug: &str) -> ReceivedRefUpdate {
|
||||
ReceivedRefUpdate {
|
||||
id: id.into(),
|
||||
node_did: "did:key:z6MkNode".into(),
|
||||
pusher_did: "did:key:z6MkPusher".into(),
|
||||
repo: slug.into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a".repeat(40),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
cert_id: None,
|
||||
received_at: Utc::now().to_rfc3339(),
|
||||
from_peer: "peer1".into(),
|
||||
owner_did: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Count `refUpdates` rows in a GraphQL response.
|
||||
fn count(resp: &async_graphql::Response) -> usize {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("refUpdates").expect("refUpdates key")
|
||||
else {
|
||||
panic!("refUpdates not a list");
|
||||
};
|
||||
rows.len()
|
||||
}
|
||||
|
||||
async fn anon(schema: &super::super::GitlawbSchema, query: &str) -> async_graphql::Response {
|
||||
schema.execute(async_graphql::Request::new(query)).await
|
||||
}
|
||||
|
||||
async fn authed(
|
||||
schema: &super::super::GitlawbSchema,
|
||||
query: &str,
|
||||
did: &str,
|
||||
) -> async_graphql::Response {
|
||||
schema
|
||||
.execute(
|
||||
async_graphql::Request::new(query)
|
||||
.data(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Scenario 1 — anon must not get a private local repo's row on the
|
||||
// repo:Some branch. This is the load-bearing RED→GREEN case.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_private_repo_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
// The GraphQL `repo` arg is the raw slug DB filter, so it must equal the
|
||||
// stored slug to select the row at all — this is the exact leak path.
|
||||
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName newSha pusherDid } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 2 — owner still sees their own private repo's row.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_private_repo_kept_for_owner(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName } }"#;
|
||||
assert_eq!(count(&authed(&schema, q, OWNER).await), 1);
|
||||
}
|
||||
|
||||
// Scenario 3 — unfiltered (repo:None): anon gets only the public row.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_unfiltered_anon_gets_only_public(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("pub", OWNER, "openrepo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("priv", OWNER, "secret", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u_pub", "z6MkOwner/openrepo"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u_priv", "z6MkOwner/secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo refName ownerDid } }"#;
|
||||
let resp = anon(&schema, q).await;
|
||||
assert_eq!(count(&resp), 1);
|
||||
// The one row returned must be the public repo's with owner_did echoed.
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
unreachable!()
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
let async_graphql::Value::Object(row) = &rows[0] else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
row.get("repo").unwrap(),
|
||||
&async_graphql::Value::from("z6MkOwner/openrepo")
|
||||
);
|
||||
assert_eq!(
|
||||
row.get("ownerDid").unwrap(),
|
||||
&async_graphql::Value::from(OWNER),
|
||||
"ownerDid must fall back to the local record's owner for legacy rows"
|
||||
);
|
||||
}
|
||||
|
||||
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_full_did_slug_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", "did:key:zABC", "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "did:key:zABC/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
// repo:None so the slug is not the DB filter key (which is verbatim);
|
||||
// the gate must still drop it.
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 5 — truncated-key fail-closed: 8-char-prefix owner form.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_truncated_key_slug_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", "did:key:zABCDEFGH", "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "zABCDEF/widget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 6 — remote slug (no local match) is returned to anon.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_remote_slug_kept_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", false))
|
||||
.await
|
||||
.unwrap();
|
||||
// Row whose slug matches no local repo (different owner + name).
|
||||
db.insert_ref_update(&ref_row("u1", "zZZZOTHER/gadget"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 1);
|
||||
}
|
||||
|
||||
// Scenario 7 (#114 P2) — a small limit must page past the newest rows when
|
||||
// they are private, so the older public rows are still returned. Before the
|
||||
// gate moved ahead of the limit this returned 0 (the newest `limit` rows were
|
||||
// all private and got filtered after the SQL LIMIT). RED→GREEN.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_small_limit_pages_past_newest_private(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("pub", OWNER, "openrepo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("priv", OWNER, "secret", false))
|
||||
.await
|
||||
.unwrap();
|
||||
// 3 older PUBLIC rows …
|
||||
for i in 0..3 {
|
||||
let mut r = ref_row(&format!("pub{i}"), "z6MkOwner/openrepo");
|
||||
r.timestamp = format!("2026-07-01T10:00:0{i}+00:00");
|
||||
db.insert_ref_update(&r).await.unwrap();
|
||||
}
|
||||
// … then 5 NEWER PRIVATE rows (the newest in the feed).
|
||||
for i in 0..5 {
|
||||
let mut r = ref_row(&format!("priv{i}"), "z6MkOwner/secret");
|
||||
r.timestamp = format!("2026-07-01T10:00:1{i}+00:00");
|
||||
db.insert_ref_update(&r).await.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
// limit 3 < the 5 newest (all private): anon must still get 3 public rows.
|
||||
let q = r#"{ refUpdates(limit: 3) { repo } }"#;
|
||||
let resp = anon(&schema, q).await;
|
||||
assert_eq!(count(&resp), 3, "paging must reach the older public rows");
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
unreachable!()
|
||||
};
|
||||
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
for row in rows {
|
||||
let async_graphql::Value::Object(r) = row else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
r.get("repo").unwrap(),
|
||||
&async_graphql::Value::from("z6MkOwner/openrepo"),
|
||||
"every returned row must be the public repo's"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 8 — a quarantined mirror is withheld on the GraphQL surface too.
|
||||
// Guards that the resolver keeps delegating to the shared collector (where the
|
||||
// quarantine fold lives); a REST-only test would miss a resolver that stopped.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_quarantined_mirror_dropped_for_anon(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.upsert_mirror_repo("z6MkQuar", "secret", "/tmp/q", None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&anon(&schema, q).await), 0);
|
||||
}
|
||||
|
||||
// Scenario 8b — the GraphQL surface also withholds a quarantined repo from an
|
||||
// authenticated OWNER, not just anon. Without the collector's quarantine drop
|
||||
// the owner short-circuit in visibility_check keeps the row on this surface
|
||||
// too, so the REST owner test alone would not guard the resolver.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_quarantined_repo_dropped_for_owner(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("q1", "did:key:z6MkQuar", "secret", false))
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_repo_quarantine("q1", true).await.unwrap();
|
||||
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let q = r#"{ refUpdates { repo } }"#;
|
||||
assert_eq!(count(&authed(&schema, q, "did:key:z6MkQuar").await), 0);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,15 @@ pub struct SubscriptionRoot;
|
||||
|
||||
#[Subscription]
|
||||
impl SubscriptionRoot {
|
||||
/// Live ref-update stream. `/graphql/ws` is mounted outside the
|
||||
/// `optional_signature` layer, so this resolver has NO caller identity and
|
||||
/// cannot gate per-subscriber — it relays whatever enters the broadcast
|
||||
/// channel to any anonymous client. Its visibility safety therefore rests
|
||||
/// entirely on the WRITE side: the push handler only sends a
|
||||
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
|
||||
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
|
||||
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
|
||||
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -30,6 +39,7 @@ impl SubscriptionRoot {
|
||||
pusher_did: ev.pusher_did,
|
||||
node_did: ev.node_did,
|
||||
timestamp: ev.timestamp,
|
||||
owner_did: Some(ev.owner_did),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ pub struct RefUpdateType {
|
||||
pub pusher_did: String,
|
||||
pub node_did: String,
|
||||
pub timestamp: String,
|
||||
pub owner_did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
//! 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? {
|
||||
let (url, level) = url_and_level();
|
||||
return Err(AppError::IcaptchaProofRequired {
|
||||
message: "iCaptcha proof already used (replay); solve a fresh challenge"
|
||||
.to_string(),
|
||||
url,
|
||||
level,
|
||||
});
|
||||
}
|
||||
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 {
|
||||
message: format!(
|
||||
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
|
||||
v.url, v.required_level, PROOF_HEADER
|
||||
),
|
||||
url: v.url.clone(),
|
||||
level: v.required_level,
|
||||
}
|
||||
}
|
||||
|
||||
/// iCaptcha service url + required level for error responses, read from the
|
||||
/// initialized verifier (falls back to defaults if somehow uninitialized).
|
||||
fn url_and_level() -> (String, u32) {
|
||||
VERIFIER
|
||||
.get()
|
||||
.map(|v| (v.url.clone(), v.required_level))
|
||||
.unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3))
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -70,28 +70,40 @@ pub async fn pin_git_object(ipfs_api: &str, sha256_hex: &str, data: &[u8]) -> Re
|
||||
Ok(cid)
|
||||
}
|
||||
|
||||
/// List all git objects in the given bare repo and pin any that are not yet
|
||||
/// recorded in `pinned_cids`.
|
||||
/// Fetch raw bytes for a CID from the local Kubo node (`/api/v0/cat`).
|
||||
pub async fn cat(ipfs_api: &str, cid: &str) -> Result<Vec<u8>> {
|
||||
if ipfs_api.is_empty() {
|
||||
return Err(anyhow::anyhow!("IPFS not configured"));
|
||||
}
|
||||
let url = format!("{}/api/v0/cat?arg={}", ipfs_api.trim_end_matches('/'), cid);
|
||||
let resp = reqwest::Client::new().post(&url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow::anyhow!("ipfs cat {cid}: {}", resp.status()));
|
||||
}
|
||||
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.
|
||||
///
|
||||
/// 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,
|
||||
) -> 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 mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
@@ -132,31 +144,3 @@ 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)
|
||||
}
|
||||
+780
-47
@@ -5,30 +5,59 @@ mod bootstrap;
|
||||
mod cert;
|
||||
mod config;
|
||||
mod db;
|
||||
mod encrypted_pin;
|
||||
mod error;
|
||||
mod git;
|
||||
mod graphql;
|
||||
mod icaptcha;
|
||||
mod ipfs_pin;
|
||||
mod metrics;
|
||||
mod operator;
|
||||
mod p2p;
|
||||
mod pinata;
|
||||
mod rate_limit;
|
||||
mod server;
|
||||
mod state;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod visibility;
|
||||
mod webhooks;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use clap::Parser;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
use config::Config;
|
||||
use db::Db;
|
||||
use state::AppState;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DegradedState {
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
}
|
||||
|
||||
/// Two independent counters with no cross-field invariant — atomics, not a
|
||||
/// lock, so the retry loop and the degraded handlers never contend.
|
||||
#[derive(Default)]
|
||||
struct DbStartupStatus {
|
||||
attempts: AtomicU64,
|
||||
next_retry_secs: AtomicU64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -45,25 +74,123 @@ async fn main() -> Result<()> {
|
||||
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
|
||||
bootstrap::merge_seeds(&mut config);
|
||||
|
||||
if !config.public_read {
|
||||
warn!(
|
||||
"GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha"
|
||||
);
|
||||
}
|
||||
|
||||
// Load or generate the node's identity keypair
|
||||
let keypair = load_or_create_keypair(&config)?;
|
||||
let node_did = keypair.did();
|
||||
|
||||
// One-time metrics init. Must run before any handler that calls into
|
||||
// `metrics::record_*` so the registry exists when the first event fires.
|
||||
// Safe to call even when GITLAWB_METRICS_ADDR is unset — those helpers
|
||||
// are simply no-ops until something reads from the registry.
|
||||
metrics::init(env!("CARGO_PKG_VERSION"), &node_did.to_string());
|
||||
|
||||
info!("╔══════════════════════════════════════════╗");
|
||||
info!(
|
||||
"║ gitlawb node v{} ║",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
info!("╚══════════════════════════════════════════╝");
|
||||
info!(did = %node_did, "node identity");
|
||||
info!(addr = %config.bind_addr(), "listening");
|
||||
// Process-wide shutdown signal. One sender lives in AppState (cloned
|
||||
// into every handler); main() keeps a clone and flips it on SIGINT
|
||||
// or SIGTERM. Tasks that hold a watch::Receiver get notified at
|
||||
// their next await point.
|
||||
let (shutdown_tx, _shutdown_rx_for_main) = watch::channel(false);
|
||||
spawn_shutdown_signal(shutdown_tx.clone());
|
||||
|
||||
// Connect to PostgreSQL database
|
||||
let db = Arc::new(
|
||||
Db::connect(&config.database_url)
|
||||
.await
|
||||
.context("failed to connect to database")?,
|
||||
);
|
||||
info!(did = %node_did, "node identity");
|
||||
info!(addr = %config.bind_addr(), "binding HTTP listener");
|
||||
|
||||
// Bind HTTP once, before dependency initialization, and keep this socket
|
||||
// for the life of the process. The degraded server accepts on a dup of the
|
||||
// same socket, so the degraded→full handoff never closes the port: while
|
||||
// the full server initializes, connections queue in the shared backlog
|
||||
// instead of being refused.
|
||||
let listener = TcpListener::bind(config.bind_addr())
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
|
||||
let full_std_listener = listener.into_std()?;
|
||||
let degraded_listener = TcpListener::from_std(
|
||||
full_std_listener
|
||||
.try_clone()
|
||||
.context("failed to clone HTTP listener for degraded server")?,
|
||||
)?;
|
||||
|
||||
// Metrics must stay observable during a database outage — the degraded
|
||||
// window is exactly when dashboards need data — so this listener starts
|
||||
// before the DB connects.
|
||||
let metrics_handle = if !config.metrics_addr.is_empty() {
|
||||
match spawn_metrics_server(&config.metrics_addr, shutdown_tx.subscribe()).await {
|
||||
Ok(handle) => {
|
||||
info!(addr = %config.metrics_addr, "metrics endpoint listening");
|
||||
Some(handle)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(err = %e, addr = %config.metrics_addr, "failed to start metrics endpoint — continuing without");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("metrics endpoint disabled (GITLAWB_METRICS_ADDR not set)");
|
||||
None
|
||||
};
|
||||
|
||||
let db_startup = Arc::new(DbStartupStatus::default());
|
||||
let (db_ready_tx, db_ready_rx) = watch::channel(false);
|
||||
let mut degraded_handle = tokio::spawn(run_degraded_server(
|
||||
degraded_listener,
|
||||
node_did.to_string(),
|
||||
Arc::clone(&db_startup),
|
||||
db_ready_rx,
|
||||
shutdown_tx.subscribe(),
|
||||
));
|
||||
|
||||
// Connect to PostgreSQL database. A transient outage or bad secret should
|
||||
// not crash-loop the process and hammer the database provider; permanent
|
||||
// misconfiguration surfaces through error-level logs and the /ready check.
|
||||
let db = tokio::select! {
|
||||
db = connect_db_with_retry(&config, Arc::clone(&db_startup), shutdown_tx.subscribe()) => {
|
||||
match db {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
// Shutdown requested while waiting for the database. The
|
||||
// degraded server only serves one-shot 503s — abort it
|
||||
// rather than drain, so a slow client can't stall exit.
|
||||
degraded_handle.abort();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
degraded = &mut degraded_handle => {
|
||||
if *shutdown_tx.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
return match degraded {
|
||||
Ok(Ok(())) => Err(anyhow!("degraded HTTP server stopped before database became ready")),
|
||||
Ok(Err(err)) => Err(err.context("degraded HTTP server failed")),
|
||||
Err(err) => Err(anyhow!("degraded HTTP server task failed: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Flip the degraded server into graceful shutdown, but do NOT await the
|
||||
// drain: one slow in-flight request must not delay the full server, and
|
||||
// the shared socket means there is no port gap to cover. The drain
|
||||
// finishes (and logs) in the background.
|
||||
db_ready_tx.send(true).ok();
|
||||
tokio::spawn(async move {
|
||||
match degraded_handle.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => warn!(err = %err, "degraded HTTP server exited with error"),
|
||||
Err(err) => warn!(err = %err, "degraded HTTP server task failed"),
|
||||
}
|
||||
});
|
||||
info!(addr = %config.bind_addr(), "database ready; starting full HTTP server");
|
||||
|
||||
// Prune peer rows that point back at this node (stale self-loop entries)
|
||||
if let Some(public_url) = config.public_url.as_deref() {
|
||||
@@ -74,6 +201,15 @@ 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")?;
|
||||
|
||||
@@ -84,12 +220,14 @@ async fn main() -> Result<()> {
|
||||
.iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let shutdown_rx = shutdown_tx.subscribe();
|
||||
match p2p::start(
|
||||
&node_did.to_string(),
|
||||
config.p2p_port,
|
||||
bootstrap_addrs,
|
||||
Arc::clone(&db),
|
||||
config.auto_sync,
|
||||
shutdown_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -107,11 +245,8 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let http_client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?,
|
||||
);
|
||||
// Shared no-redirect HTTP client. See build_http_client for the SSRF rationale.
|
||||
let http_client = Arc::new(build_http_client()?);
|
||||
|
||||
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
|
||||
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
|
||||
@@ -147,6 +282,83 @@ async fn main() -> Result<()> {
|
||||
let repo_store =
|
||||
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
let rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
|
||||
|
||||
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
|
||||
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
|
||||
// exactly how the recurring spam-repo floods get past both it and the
|
||||
// iCaptcha gate. Keyed on the resolved client IP so a single-source flood is
|
||||
// capped regardless of how many identities it mints. Sized well above any
|
||||
// legitimate per-IP creation rate; GITLAWB_CREATE_RATE_LIMIT overrides, 0
|
||||
// disables. Bounded key set — the key is a client-influenced IP.
|
||||
let create_limit = std::env::var("GITLAWB_CREATE_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(120);
|
||||
let create_ip_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
create_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if create_limit == 0 {
|
||||
tracing::warn!("GITLAWB_CREATE_RATE_LIMIT=0 — per-IP creation rate limiting disabled");
|
||||
}
|
||||
|
||||
// Push-path flood brake: max git-receive-pack requests per client IP per
|
||||
// hour (counts both the info/refs advertisement and the push POST). Sized
|
||||
// for heavy agent automation while still stopping flood traffic (the June
|
||||
// 2026 attack pushed several times per second per IP). GITLAWB_PUSH_RATE_LIMIT
|
||||
// overrides; 0 disables. Bounded key set — the key is a client-influenced IP.
|
||||
let push_limit = std::env::var("GITLAWB_PUSH_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(600);
|
||||
let push_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
push_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if push_limit == 0 {
|
||||
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
|
||||
}
|
||||
|
||||
// Which forwarded header the edge is trusted to set. Default None (trust
|
||||
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
|
||||
// a node behind Caddy/NGINX sets it to x-forwarded-for.
|
||||
let push_limiter_trust = rate_limit::TrustedProxy::from_env_value(
|
||||
&std::env::var("GITLAWB_TRUSTED_PROXY").unwrap_or_default(),
|
||||
);
|
||||
tracing::info!(trust = ?push_limiter_trust, push_limit, "push rate limiter configured");
|
||||
|
||||
// Peer-sync flood brakes, keyed on the resolved client IP (per-DID is useless
|
||||
// here — a did:key farm self-registers). Two buckets so an unsigned notify
|
||||
// flood can't drain the signed trigger caller's quota (#82). Bounded key sets
|
||||
// (the key is a client-influenced IP); 0 disables each.
|
||||
let sync_trigger_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
config.sync_trigger_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
let peer_write_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
config.peer_write_rate_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if config.sync_trigger_rate_limit == 0 {
|
||||
tracing::warn!(
|
||||
"GITLAWB_SYNC_TRIGGER_RATE_LIMIT=0 — /sync/trigger IP rate limiting disabled"
|
||||
);
|
||||
}
|
||||
if config.peer_write_rate_limit == 0 {
|
||||
tracing::warn!("GITLAWB_PEER_WRITE_RATE_LIMIT=0 — peer-write IP rate limiting disabled");
|
||||
}
|
||||
|
||||
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
|
||||
icaptcha::init().await;
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(config.clone()),
|
||||
db,
|
||||
@@ -159,12 +371,82 @@ async fn main() -> Result<()> {
|
||||
graphql_schema,
|
||||
machine_id,
|
||||
repo_store,
|
||||
rate_limiter,
|
||||
create_ip_rate_limiter,
|
||||
push_rate_limiter,
|
||||
push_limiter_trust,
|
||||
sync_trigger_rate_limiter,
|
||||
peer_write_rate_limiter,
|
||||
shutdown_tx: shutdown_tx.clone(),
|
||||
};
|
||||
|
||||
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
|
||||
// we still set the gauge to 0 so dashboards don't show "no data".
|
||||
{
|
||||
let p2p_for_metrics = state.p2p.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let count = match &p2p_for_metrics {
|
||||
Some(h) => h.status().await.map(|s| s.connected_peers).unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
metrics::set_peers_connected(count as i64);
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let rl = state.rate_limiter.clone();
|
||||
let create_ip_rl = state.create_ip_rate_limiter.clone();
|
||||
let push_rl = state.push_rate_limiter.clone();
|
||||
let sync_trigger_rl = state.sync_trigger_rate_limiter.clone();
|
||||
let peer_write_rl = state.peer_write_rate_limiter.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
rl.cleanup().await;
|
||||
create_ip_rl.cleanup().await;
|
||||
push_rl.cleanup().await;
|
||||
sync_trigger_rl.cleanup().await;
|
||||
peer_write_rl.cleanup().await;
|
||||
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() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let router = server::build_router(state.clone());
|
||||
let listener = TcpListener::bind(config.bind_addr())
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
|
||||
// Re-register the socket bound at startup — same fd, so there was never a
|
||||
// moment with the port closed between the degraded and full servers.
|
||||
let listener = TcpListener::from_std(full_std_listener)
|
||||
.context("failed to re-register HTTP listener with the runtime")?;
|
||||
|
||||
info!("✓ node started — did:{}", node_did);
|
||||
info!(" repos dir: {}", config.repos_dir.display());
|
||||
@@ -183,9 +465,13 @@ async fn main() -> Result<()> {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let p2p_clone = Arc::clone(p2p);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
// Small delay so Kademlia can find peers first
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
}
|
||||
p2p_clone.put_did(did_record).await;
|
||||
info!("DID record published to Kademlia DHT");
|
||||
});
|
||||
@@ -195,14 +481,20 @@ async fn main() -> Result<()> {
|
||||
{
|
||||
let gossip_state = state.clone();
|
||||
let bootstrap_peers = config.bootstrap_peers.clone();
|
||||
let shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
gossip_task(gossip_state, bootstrap_peers).await;
|
||||
gossip_task(gossip_state, bootstrap_peers, shutdown_rx).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Start multi-node sync worker if auto_sync is enabled
|
||||
if config.auto_sync {
|
||||
sync::start(Arc::clone(&state.db), Arc::clone(&state.config));
|
||||
sync::start(
|
||||
Arc::clone(&state.db),
|
||||
Arc::clone(&state.config),
|
||||
Arc::clone(&state.node_keypair),
|
||||
state.subscribe_shutdown(),
|
||||
);
|
||||
info!("auto-sync worker started");
|
||||
}
|
||||
|
||||
@@ -214,7 +506,7 @@ async fn main() -> Result<()> {
|
||||
Ok(client) => match operator::startup_check(&client).await {
|
||||
Ok(_) => {
|
||||
let arc_client = Arc::new(client);
|
||||
arc_client.spawn_heartbeat_loop();
|
||||
arc_client.spawn_heartbeat_loop(state.subscribe_shutdown());
|
||||
}
|
||||
Err(e) => {
|
||||
if state.config.operator_strict_mode {
|
||||
@@ -234,10 +526,307 @@ async fn main() -> Result<()> {
|
||||
info!("on-chain PoS disabled (GITLAWB_CONTRACT_NODE_STAKING or GITLAWB_OPERATOR_PRIVATE_KEY unset)");
|
||||
}
|
||||
|
||||
axum::serve(listener, router).await?;
|
||||
// axum's `with_graceful_shutdown` waits for in-flight requests to
|
||||
// complete (up to the configured grace) once the future resolves.
|
||||
let shutdown_signal_for_axum = state.subscribe_shutdown();
|
||||
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
|
||||
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
|
||||
|
||||
// `into_make_service_with_connect_info` exposes the socket peer address as
|
||||
// `ConnectInfo<SocketAddr>` so the push limiter can key on the real client
|
||||
// when no trusted proxy header applies (see `rate_limit::client_key`).
|
||||
let serve_result = axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move {
|
||||
let mut rx = shutdown_signal_for_axum;
|
||||
// Wait until the watcher flips to true, then return so axum
|
||||
// can begin draining.
|
||||
while !*rx.borrow_and_update() {
|
||||
if rx.changed().await.is_err() {
|
||||
// Sender dropped — treat as shutdown.
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// Server has stopped accepting new connections and drained in-flight
|
||||
// requests. Tear the rest of the system down.
|
||||
info!("HTTP server stopped, beginning process shutdown");
|
||||
if let Some(h) = metrics_handle {
|
||||
h.abort();
|
||||
}
|
||||
let _ = grace; // recorded for operators in the log above; not enforced
|
||||
serve_result?;
|
||||
info!("clean exit");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal as unix_signal, SignalKind};
|
||||
let mut sigterm =
|
||||
unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
let mut sigint = unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("SIGTERM received, shutting down"),
|
||||
_ = sigint.recv() => info!("SIGINT received, shutting down"),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
use tokio::signal;
|
||||
let _ = signal::ctrl_c().await;
|
||||
info!("Ctrl-C received, shutting down");
|
||||
}
|
||||
tx.send(true).ok();
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_db_with_retry(
|
||||
config: &Config,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Option<Arc<Db>> {
|
||||
let initial_retry_secs = config.db_retry_initial_secs;
|
||||
let max_retry_secs = config.db_retry_max_secs.max(initial_retry_secs);
|
||||
let acquire_timeout = std::time::Duration::from_secs(config.db_acquire_timeout_secs);
|
||||
let attempt_timeout = std::time::Duration::from_secs(config.db_connect_timeout_secs);
|
||||
let mut attempts = 0_u64;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
return None;
|
||||
}
|
||||
|
||||
attempts = attempts.saturating_add(1);
|
||||
db_startup.attempts.store(attempts, Ordering::Relaxed);
|
||||
|
||||
// Bound the whole attempt, not just the pool connect: migrations
|
||||
// block on a cross-instance advisory lock, and an unbounded wait
|
||||
// there would wedge this loop — no retries, no logs, no recovery.
|
||||
// Timing out and retrying is safe; migrations are idempotent.
|
||||
let attempt = match tokio::time::timeout(
|
||||
attempt_timeout,
|
||||
Db::connect(
|
||||
&config.database_url,
|
||||
config.db_max_connections,
|
||||
acquire_timeout,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(anyhow!(
|
||||
"connect + migrate attempt exceeded {}s (GITLAWB_DB_CONNECT_TIMEOUT_SECS); \
|
||||
is another instance holding the migration lock?",
|
||||
attempt_timeout.as_secs()
|
||||
)),
|
||||
};
|
||||
|
||||
match attempt {
|
||||
Ok(db) => {
|
||||
info!(attempts, "database connection established");
|
||||
return Some(Arc::new(db));
|
||||
}
|
||||
Err(err) => {
|
||||
// A bad DATABASE_URL or rejected credentials won't heal on
|
||||
// their own. Still retry (exiting would crash-loop and hammer
|
||||
// the provider — and take liveness down with it), but log at
|
||||
// error level and skip straight to the maximum backoff; the
|
||||
// /ready health check is what surfaces this to deploys.
|
||||
let permanent = is_likely_permanent_db_error(&err);
|
||||
let retry_secs = if permanent {
|
||||
max_retry_secs
|
||||
} else {
|
||||
database_retry_delay_secs(initial_retry_secs, max_retry_secs, attempts)
|
||||
};
|
||||
db_startup
|
||||
.next_retry_secs
|
||||
.store(retry_secs, Ordering::Relaxed);
|
||||
if permanent {
|
||||
tracing::error!(
|
||||
attempts,
|
||||
retry_secs,
|
||||
err = %err,
|
||||
"database rejected our configuration (bad DATABASE_URL or credentials?) — retrying, but operator action is likely required"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
attempts,
|
||||
retry_secs,
|
||||
err = %err,
|
||||
"database unavailable during startup; retrying"
|
||||
);
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(retry_secs)) => {}
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that indicate misconfiguration rather than a transient outage: a
|
||||
/// malformed DATABASE_URL, or a server that answered and rejected us —
|
||||
/// Postgres error class 28xxx (invalid authorization) or 3D000 (database
|
||||
/// does not exist). Best-effort: an error that anyhow can't downcast back to
|
||||
/// sqlx just counts as transient.
|
||||
fn is_likely_permanent_db_error(err: &anyhow::Error) -> bool {
|
||||
match err.downcast_ref::<sqlx::Error>() {
|
||||
Some(sqlx::Error::Configuration(_)) => true,
|
||||
Some(sqlx::Error::Database(db)) => db
|
||||
.code()
|
||||
.map(|c| c.starts_with("28") || c.starts_with("3D"))
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn database_retry_delay_secs(initial_secs: u64, max_secs: u64, attempts: u64) -> u64 {
|
||||
// The exponent bound only keeps the u32 cast safe — max_secs is the real
|
||||
// (operator-configurable) cap, and saturating math handles overflow.
|
||||
let exponent = attempts.saturating_sub(1).min(63) as u32;
|
||||
initial_secs
|
||||
.saturating_mul(2_u64.saturating_pow(exponent))
|
||||
.min(max_secs)
|
||||
}
|
||||
|
||||
async fn run_degraded_server(
|
||||
listener: TcpListener,
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
mut db_ready_rx: watch::Receiver<bool>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
let addr = listener.local_addr().ok();
|
||||
let router = build_degraded_router(node_did, db_startup);
|
||||
info!(?addr, "degraded HTTP server ready");
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
// wait_for resolves on predicate-true or sender-drop; either way
|
||||
// this phase is over.
|
||||
tokio::select! {
|
||||
_ = db_ready_rx.wait_for(|ready| *ready) => {}
|
||||
_ = shutdown_rx.wait_for(|stop| *stop) => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
|
||||
let state = DegradedState {
|
||||
node_did,
|
||||
db_startup,
|
||||
};
|
||||
// Everything answers 503 with the same body — including /health and
|
||||
// /ready, so peer pings (which treat any 2xx /health as alive) and
|
||||
// uptime monitors correctly see a node that cannot serve traffic.
|
||||
// `/` additionally carries the node identity for probing peers.
|
||||
Router::new()
|
||||
.route("/", get(degraded_node_info))
|
||||
.fallback(degraded_unavailable)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// One source of truth for the degraded 503 body, sharing the error
|
||||
/// vocabulary with error.rs so clients see the same code/message for
|
||||
/// "database unavailable" regardless of which phase produced it.
|
||||
fn degraded_body(db_startup: &DbStartupStatus) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"status": "degraded",
|
||||
"database": "initializing",
|
||||
"error": error::DB_UNAVAILABLE_CODE,
|
||||
"message": error::DB_UNAVAILABLE_MESSAGE,
|
||||
"db_attempts": db_startup.attempts.load(Ordering::Relaxed),
|
||||
"db_next_retry_secs": db_startup.next_retry_secs.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResponse {
|
||||
let mut body = degraded_body(&state.db_startup);
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert("name".into(), "gitlawb-node".into());
|
||||
obj.insert("version".into(), env!("CARGO_PKG_VERSION").into());
|
||||
obj.insert("did".into(), state.node_did.clone().into());
|
||||
}
|
||||
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
|
||||
}
|
||||
|
||||
async fn degraded_unavailable(State(state): State<DegradedState>) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(degraded_body(&state.db_startup)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a small axum router that exposes only `GET /metrics` on its own
|
||||
/// listener. Returns the JoinHandle so `main()` can abort it on shutdown.
|
||||
/// This is deliberately separate from the main router so the metrics port
|
||||
/// can be firewalled differently from the API port — bind to localhost
|
||||
/// or a private interface only.
|
||||
async fn spawn_metrics_server(
|
||||
addr: &str,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<tokio::task::JoinHandle<()>> {
|
||||
use axum::{response::IntoResponse, routing::get, Router};
|
||||
|
||||
async fn metrics_handler() -> impl IntoResponse {
|
||||
match metrics::encode() {
|
||||
Ok(body) => (
|
||||
axum::http::StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; version=0.0.4; charset=utf-8",
|
||||
)],
|
||||
body,
|
||||
),
|
||||
Err(e) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
format!("metrics encode error: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind metrics listener to {addr}"))?;
|
||||
let app = Router::new().route("/metrics", get(metrics_handler));
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
while !*shutdown_rx.borrow_and_update() {
|
||||
if shutdown_rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(err = %e, "metrics server exited with error");
|
||||
}
|
||||
});
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn build_operator_client(
|
||||
config: &config::Config,
|
||||
node_did: &str,
|
||||
@@ -260,23 +849,70 @@ fn build_operator_client(
|
||||
}
|
||||
|
||||
/// Announce to bootstrap peers on startup, then periodically ping all known peers.
|
||||
async fn gossip_task(state: AppState, bootstrap_peers: Vec<String>) {
|
||||
// Small delay to let the HTTP server come up before we try to announce
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
async fn gossip_task(
|
||||
state: AppState,
|
||||
bootstrap_peers: Vec<String>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// If shutdown arrives during the initial delay, exit before announcing.
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("gossip: shutdown during startup delay, exiting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
// Reuse the shared no-redirect client for every gossip outbound call (the
|
||||
// bootstrap announce POST and the periodic peer /health ping). Peer URLs are
|
||||
// attacker-influenceable, so a 3xx to a private address must not be followed.
|
||||
// Do NOT fall back to reqwest::Client::new(): its default follows redirects
|
||||
// and would reintroduce the SSRF closed here (#93).
|
||||
let client = state.http_client.clone();
|
||||
let my_did = state.node_did.to_string();
|
||||
let my_url = state.config.public_url.clone().unwrap_or_default();
|
||||
|
||||
// Announce ourselves to each bootstrap peer
|
||||
for peer_url in &bootstrap_peers {
|
||||
let announce_url = format!("{}/api/v1/peers/announce", peer_url.trim_end_matches('/'));
|
||||
// Cooperative shutdown between peers — a slow peer shouldn't
|
||||
// block the node exiting.
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("gossip: shutdown signalled during peer announce, exiting");
|
||||
return;
|
||||
}
|
||||
let path = "/api/v1/peers/announce";
|
||||
let announce_url = format!("{}{}", peer_url.trim_end_matches('/'), path);
|
||||
let body = serde_json::json!({
|
||||
"did": my_did,
|
||||
"http_url": my_url,
|
||||
"did": my_did.clone(),
|
||||
"http_url": my_url.clone(),
|
||||
});
|
||||
match client.post(&announce_url).json(&body).send().await {
|
||||
Ok(resp) => {
|
||||
let body_bytes = match serde_json::to_vec(&body) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, "failed to serialize peer announce body");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let signed = sign_request(state.node_keypair.as_ref(), "POST", path, &body_bytes);
|
||||
// Per-request timeout inside the loop; do not let one hung peer
|
||||
// block others. The request itself is a normal tokio future so
|
||||
// it's cancel-safe on shutdown.
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client
|
||||
.post(&announce_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Content-Digest", signed.content_digest)
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature)
|
||||
.body(body_bytes)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
// Add them back to our peer list
|
||||
@@ -292,33 +928,69 @@ async fn gossip_task(state: AppState, bootstrap_peers: Vec<String>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(url = %announce_url, err = %e, "failed to announce to bootstrap peer")
|
||||
}
|
||||
Err(_) => tracing::warn!(url = %announce_url, "bootstrap peer announce timed out (5s)"),
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic ping every 5 minutes
|
||||
// Periodic ping every 5 minutes — exit on shutdown.
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let peers = match state.db.list_peers().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for peer in peers {
|
||||
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
|
||||
let ok = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false);
|
||||
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let peers = match state.db.list_peers().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for peer in peers {
|
||||
let ok = ping_peer_health(&client, &peer.http_url).await;
|
||||
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("gossip task: shutdown signal received, exiting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shared node HTTP client used for every outbound fan-out (sync
|
||||
/// trigger, profile/repo fetches, gossip announce + peer pings).
|
||||
///
|
||||
/// No redirects: peer URLs are attacker-influenceable, so a `3xx` to a private
|
||||
/// address must not be followed (SSRF guard, #78/#93). Do NOT replace with
|
||||
/// `reqwest::Client::new()` — its default follows redirects. Kept as a named
|
||||
/// builder so tests bind the redirect guarantee to the real client the node
|
||||
/// runs, not a hand-rolled equivalent.
|
||||
fn build_http_client() -> reqwest::Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Ping a peer's `/health` endpoint and report whether it answered 2xx.
|
||||
///
|
||||
/// Takes the client by reference so callers supply the shared, no-redirect
|
||||
/// `state.http_client`. Peer URLs are attacker-influenceable, so a `3xx` to a
|
||||
/// private address must not be followed. Do NOT call this with a bare
|
||||
/// `reqwest::Client::new()`: its default follows redirects and would
|
||||
/// reintroduce the SSRF this guards against (#93).
|
||||
async fn ping_peer_health(client: &reqwest::Client, http_url: &str) -> bool {
|
||||
let url = format!("{}/health", http_url.trim_end_matches('/'));
|
||||
client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
let key_path = config.resolved_key_path();
|
||||
|
||||
@@ -351,3 +1023,64 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
Ok(kp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::ping_peer_health;
|
||||
|
||||
// Build the client exactly as production does (super::build_http_client) so
|
||||
// these tests bind the redirect guarantee to the real shared client the
|
||||
// node runs. A regression that makes build_http_client follow redirects
|
||||
// fails ping_peer_health_does_not_follow_redirect.
|
||||
fn production_http_client() -> reqwest::Client {
|
||||
super::build_http_client().expect("failed to build production http client")
|
||||
}
|
||||
|
||||
// A peer answering `/health` with a 302 toward an internal address must not
|
||||
// be followed: the redirect target must never be requested (#93).
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_does_not_follow_redirect() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let internal = server
|
||||
.mock("GET", "/internal-metadata")
|
||||
.with_status(200)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let _health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(302)
|
||||
.with_header("location", &format!("{}/internal-metadata", server.url()))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(!ok, "a 302 must not count as a healthy peer");
|
||||
// expect(0) is enforced only at assert time; this fails if the redirect
|
||||
// was followed to the internal target.
|
||||
internal.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_reports_success_on_200() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _health = server
|
||||
.mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let ok = ping_peer_health(&production_http_client(), &server.url()).await;
|
||||
|
||||
assert!(ok, "a 200 /health must count as a healthy peer");
|
||||
}
|
||||
|
||||
// A transport error (nothing listening) must map to unhealthy, never a
|
||||
// spurious healthy — the .unwrap_or(false) arm.
|
||||
#[tokio::test]
|
||||
async fn ping_peer_health_reports_unhealthy_on_connection_error() {
|
||||
let ok = ping_peer_health(&production_http_client(), "http://127.0.0.1:1").await;
|
||||
assert!(!ok, "a connection error must count as an unhealthy peer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Prometheus metrics for the gitlawb node.
|
||||
//!
|
||||
//! Exposes a small, deliberately conservative set of counters and histograms
|
||||
//! that cover the questions an operator actually asks of a live node:
|
||||
//!
|
||||
//! * is push traffic flowing? — `gitlawb_pushes_total`
|
||||
//! * is fetch traffic flowing? — `gitlawb_fetches_total`
|
||||
//! * are signature checks passing or failing? —
|
||||
//! `gitlawb_auth_successes_total` / `gitlawb_auth_failures_total`
|
||||
//! * is the sync worker making progress? —
|
||||
//! `gitlawb_sync_queue_processed_total{status}`
|
||||
//! * are webhooks reaching their endpoints? —
|
||||
//! `gitlawb_webhook_deliveries_total{result}`
|
||||
//! * how big are the packs we're sending and receiving? —
|
||||
//! `gitlawb_pack_size_bytes`
|
||||
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
|
||||
//! * currently-connected peer count — `gitlawb_peers_connected`
|
||||
//!
|
||||
//! All metrics live in a single process-wide registry initialized by
|
||||
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
|
||||
//! are no-ops until [`init`] has been called, so unit tests and benchmarks
|
||||
//! that don't go through `main()` don't need to do anything special.
|
||||
//!
|
||||
//! The endpoint is opt-in via `GITLAWB_METRICS_ADDR`; if unset, no listener
|
||||
//! is bound and no exposition happens. Increments are atomic and effectively
|
||||
//! free.
|
||||
//!
|
||||
//! Follow-ups (not in this module):
|
||||
//! * per-route latency histograms (TraceLayer already gives us spans)
|
||||
//! * per-peer p2p counters
|
||||
//! * ipfs / pinata counters
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use prometheus::{
|
||||
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
|
||||
TextEncoder,
|
||||
};
|
||||
|
||||
/// The single, process-wide metrics registry. Initialized by [`init`].
|
||||
/// Wrapped in `OnceLock` so unit tests can run without going through
|
||||
/// `main()` — the public `record_*` helpers treat an uninitialized
|
||||
/// registry as a silent no-op.
|
||||
static REGISTRY: OnceLock<Registry> = OnceLock::new();
|
||||
static INFO: OnceLock<IntGaugeVec> = OnceLock::new();
|
||||
static PUSHES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static FETCHES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static AUTH_SUCCESSES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
|
||||
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
|
||||
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
|
||||
|
||||
/// One-time initializer. Builds the registry, registers every metric,
|
||||
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
|
||||
/// more than once is a silent no-op. MUST be called from `main()` after
|
||||
/// the node DID is known.
|
||||
pub fn init(version: &str, node_did: &str) {
|
||||
if REGISTRY.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let registry = Registry::new();
|
||||
|
||||
let info = IntGaugeVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_info",
|
||||
"Constant 1 labelled with version and node DID",
|
||||
),
|
||||
&["version", "did"],
|
||||
)
|
||||
.expect("gitlawb_info metric definition");
|
||||
registry
|
||||
.register(Box::new(info.clone()))
|
||||
.expect("register gitlawb_info");
|
||||
info.with_label_values(&[version, node_did]).set(1);
|
||||
INFO.set(info).expect("set INFO once");
|
||||
|
||||
let pushes = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_pushes_total",
|
||||
"Total successful git push (receive-pack) completions",
|
||||
),
|
||||
&["repo"],
|
||||
)
|
||||
.expect("gitlawb_pushes_total definition");
|
||||
registry
|
||||
.register(Box::new(pushes.clone()))
|
||||
.expect("register gitlawb_pushes_total");
|
||||
PUSHES.set(pushes).expect("set PUSHES once");
|
||||
|
||||
let fetches = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_fetches_total",
|
||||
"Total successful git fetch (upload-pack) completions",
|
||||
),
|
||||
&["repo"],
|
||||
)
|
||||
.expect("gitlawb_fetches_total definition");
|
||||
registry
|
||||
.register(Box::new(fetches.clone()))
|
||||
.expect("register gitlawb_fetches_total");
|
||||
FETCHES.set(fetches).expect("set FETCHES once");
|
||||
|
||||
let auth_successes = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_auth_successes_total",
|
||||
"Total HTTP signature checks that passed",
|
||||
),
|
||||
&["route"],
|
||||
)
|
||||
.expect("gitlawb_auth_successes_total definition");
|
||||
registry
|
||||
.register(Box::new(auth_successes.clone()))
|
||||
.expect("register gitlawb_auth_successes_total");
|
||||
AUTH_SUCCESSES
|
||||
.set(auth_successes)
|
||||
.expect("set AUTH_SUCCESSES once");
|
||||
|
||||
let auth_failures = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_auth_failures_total",
|
||||
"Total HTTP signature checks that failed",
|
||||
),
|
||||
&["route", "reason"],
|
||||
)
|
||||
.expect("gitlawb_auth_failures_total definition");
|
||||
registry
|
||||
.register(Box::new(auth_failures.clone()))
|
||||
.expect("register gitlawb_auth_failures_total");
|
||||
AUTH_FAILURES
|
||||
.set(auth_failures)
|
||||
.expect("set AUTH_FAILURES once");
|
||||
|
||||
let sync_processed = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_sync_queue_processed_total",
|
||||
"Total sync_queue items the worker has processed",
|
||||
),
|
||||
&["status"],
|
||||
)
|
||||
.expect("gitlawb_sync_queue_processed_total definition");
|
||||
registry
|
||||
.register(Box::new(sync_processed.clone()))
|
||||
.expect("register gitlawb_sync_queue_processed_total");
|
||||
SYNC_PROCESSED
|
||||
.set(sync_processed)
|
||||
.expect("set SYNC_PROCESSED once");
|
||||
|
||||
let webhook_deliveries = IntCounterVec::new(
|
||||
Opts::new(
|
||||
"gitlawb_webhook_deliveries_total",
|
||||
"Total webhook delivery attempts and their outcomes",
|
||||
),
|
||||
&["result"],
|
||||
)
|
||||
.expect("gitlawb_webhook_deliveries_total definition");
|
||||
registry
|
||||
.register(Box::new(webhook_deliveries.clone()))
|
||||
.expect("register gitlawb_webhook_deliveries_total");
|
||||
WEBHOOK_DELIVERIES
|
||||
.set(webhook_deliveries)
|
||||
.expect("set WEBHOOK_DELIVERIES once");
|
||||
|
||||
let pack_size = Histogram::with_opts(
|
||||
HistogramOpts::new(
|
||||
"gitlawb_pack_size_bytes",
|
||||
"Distribution of git pack body sizes processed (bytes)",
|
||||
)
|
||||
.buckets(vec![
|
||||
1_024.0, // 1 KB
|
||||
64_000.0, // ~64 KB
|
||||
1_000_000.0, // 1 MB
|
||||
16_000_000.0, // 16 MB
|
||||
64_000_000.0, // 64 MB
|
||||
256_000_000.0, // 256 MB
|
||||
1_073_741_824.0, // 1 GiB
|
||||
2_147_483_648.0, // 2 GiB (current cap)
|
||||
]),
|
||||
)
|
||||
.expect("gitlawb_pack_size_bytes definition");
|
||||
registry
|
||||
.register(Box::new(pack_size.clone()))
|
||||
.expect("register gitlawb_pack_size_bytes");
|
||||
PACK_SIZE.set(pack_size).expect("set PACK_SIZE once");
|
||||
|
||||
let peers_connected = IntGauge::with_opts(Opts::new(
|
||||
"gitlawb_peers_connected",
|
||||
"Currently connected libp2p peers",
|
||||
))
|
||||
.expect("gitlawb_peers_connected definition");
|
||||
registry
|
||||
.register(Box::new(peers_connected.clone()))
|
||||
.expect("register gitlawb_peers_connected");
|
||||
PEERS_CONNECTED
|
||||
.set(peers_connected)
|
||||
.expect("set PEERS_CONNECTED once");
|
||||
|
||||
REGISTRY
|
||||
.set(registry)
|
||||
.expect("set REGISTRY once (init must be called exactly once)");
|
||||
}
|
||||
|
||||
/// Record one successful push (receive-pack completion). Labelled with
|
||||
/// the repo in the form `owner_short/repo_name`. No-op if `init` was
|
||||
/// never called (e.g. inside a unit test that doesn't go through main).
|
||||
pub fn record_push(repo: &str) {
|
||||
if let Some(c) = PUSHES.get() {
|
||||
c.with_label_values(&[repo]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one successful fetch (upload-pack completion).
|
||||
pub fn record_fetch(repo: &str) {
|
||||
if let Some(c) = FETCHES.get() {
|
||||
c.with_label_values(&[repo]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one HTTP signature check that passed.
|
||||
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
|
||||
pub fn record_auth_success(route: &str) {
|
||||
if let Some(c) = AUTH_SUCCESSES.get() {
|
||||
c.with_label_values(&[route]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one HTTP signature check that failed. `reason` is a short,
|
||||
/// machine-friendly token (e.g. `expired`, `bad_signature`, `missing_header`).
|
||||
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
|
||||
pub fn record_auth_failure(route: &str, reason: &str) {
|
||||
if let Some(c) = AUTH_FAILURES.get() {
|
||||
c.with_label_values(&[route, reason]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped,
|
||||
/// rejected, deferred}. `rejected` is terminal and caused by a guard (a
|
||||
/// malformed slug, or a path resolving outside repos_dir); `deferred` leaves
|
||||
/// the row pending for a later pass, so a queue stalled on an operator
|
||||
/// condition is visible rather than looking idle.
|
||||
pub fn record_sync_processed(status: &str) {
|
||||
if let Some(c) = SYNC_PROCESSED.get() {
|
||||
c.with_label_values(&[status]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one webhook delivery attempt outcome. `result` ∈
|
||||
/// {ok, http_error, network_error, skipped}.
|
||||
pub fn record_webhook_delivery(result: &str) {
|
||||
if let Some(c) = WEBHOOK_DELIVERIES.get() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a pack body size observation (bytes).
|
||||
pub fn observe_pack_size(bytes: f64) {
|
||||
if let Some(h) = PACK_SIZE.get() {
|
||||
h.observe(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the currently-connected peer count gauge.
|
||||
pub fn set_peers_connected(count: i64) {
|
||||
if let Some(g) = PEERS_CONNECTED.get() {
|
||||
g.set(count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the registry as the standard Prometheus text exposition format.
|
||||
/// Returns an error if `init` was never called.
|
||||
pub fn encode() -> Result<String, prometheus::Error> {
|
||||
let registry = REGISTRY
|
||||
.get()
|
||||
.ok_or_else(|| prometheus::Error::Msg("metrics::init() was never called".into()))?;
|
||||
let metric_families = registry.gather();
|
||||
let mut buf = Vec::new();
|
||||
let encoder = TextEncoder::new();
|
||||
encoder.encode(&metric_families, &mut buf)?;
|
||||
String::from_utf8(buf)
|
||||
.map_err(|e| prometheus::Error::Msg(format!("non-UTF8 in metric buffer: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: these tests are not run in parallel by default (cargo runs
|
||||
// tests in a binary in parallel via threads but the OnceLock guard
|
||||
// means only the first init() call succeeds; subsequent ones are
|
||||
// no-ops). The encode test below is structured so it's safe to run
|
||||
// alongside other tests in the same binary.
|
||||
|
||||
#[test]
|
||||
fn encode_after_init_returns_prometheus_text() {
|
||||
// Reset is not supported by OnceLock, so this test relies on
|
||||
// either being the first to run, or `init` being a no-op on
|
||||
// second call. Both paths are exercised: if init() was already
|
||||
// called by another test, the no-op branch still leaves the
|
||||
// registry usable.
|
||||
init("0.0.0-test", "did:key:test");
|
||||
PUSHES
|
||||
.get()
|
||||
.expect("PUSHES set after init")
|
||||
.with_label_values(&["alice/repo"])
|
||||
.inc();
|
||||
|
||||
let body = encode().expect("encode should succeed after init");
|
||||
assert!(
|
||||
body.contains("# HELP gitlawb_info"),
|
||||
"expected gitlawb_info HELP line in: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("# TYPE gitlawb_pushes_total counter"),
|
||||
"expected gitlawb_pushes_total TYPE line in: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("gitlawb_pushes_total{repo=\"alice/repo\"} 1"),
|
||||
"expected the incremented counter to be visible in: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_helpers_are_noops_before_init() {
|
||||
// These don't panic. They also don't show up in encode() output
|
||||
// because the registry doesn't exist yet.
|
||||
record_push("test/repo");
|
||||
record_fetch("test/repo");
|
||||
record_auth_success("test/route");
|
||||
record_auth_failure("test/route", "test_reason");
|
||||
record_sync_processed("done");
|
||||
record_webhook_delivery("ok");
|
||||
observe_pack_size(1024.0);
|
||||
set_peers_connected(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_before_init_returns_error() {
|
||||
// encode() must error rather than panic if init() was never called.
|
||||
// This test is meaningful only if NO other test in this binary
|
||||
// ran first. cargo runs tests on multiple threads within a
|
||||
// single binary, so this is racy — but worst case the assertion
|
||||
// below is "encode succeeded with a non-empty body", which is
|
||||
// still a valid state.
|
||||
let result = encode();
|
||||
match result {
|
||||
Err(_) => { /* expected when init wasn't called */ }
|
||||
Ok(body) => {
|
||||
// Some other test ran init() first; that's fine, the
|
||||
// body should still be a valid exposition payload.
|
||||
assert!(!body.is_empty(), "non-empty exposition body");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,12 @@ impl OperatorClient {
|
||||
}
|
||||
|
||||
/// Spawn a background task that posts a heartbeat on `heartbeat_interval` cadence.
|
||||
/// Errors are logged and retried on the next tick.
|
||||
pub fn spawn_heartbeat_loop(self: Arc<Self>) {
|
||||
/// Errors are logged and retried on the next tick. The task exits cleanly
|
||||
/// when `shutdown_rx` flips to `true`.
|
||||
pub fn spawn_heartbeat_loop(
|
||||
self: Arc<Self>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let interval_secs = self.cfg.heartbeat_interval.as_secs();
|
||||
info!(
|
||||
interval_hours = interval_secs / 3600,
|
||||
@@ -158,11 +162,20 @@ impl OperatorClient {
|
||||
// First tick fires immediately — use it to send an initial heartbeat
|
||||
interval.tick().await;
|
||||
loop {
|
||||
match self.send_heartbeat().await {
|
||||
Ok(tx) => info!(tx = %tx, "operator heartbeat sent"),
|
||||
Err(e) => warn!(err = %e, "operator heartbeat failed — will retry"),
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match self.send_heartbeat().await {
|
||||
Ok(tx) => info!(tx = %tx, "operator heartbeat sent"),
|
||||
Err(e) => warn!(err = %e, "operator heartbeat failed — will retry"),
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("operator heartbeat loop: shutdown signal received, exiting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
interval.tick().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! Provides:
|
||||
//! - Peer discovery via Kademlia DHT (DID → multiaddr mapping)
|
||||
//! - Real-time ref-update events via Gossipsub
|
||||
//! - Local network discovery via mDNS
|
||||
//!
|
||||
//! The node's PeerId is derived from its Ed25519 identity keypair,
|
||||
//! so the gitlawb DID and libp2p PeerId share the same key.
|
||||
@@ -15,12 +14,13 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use libp2p::futures::StreamExt;
|
||||
use libp2p::{
|
||||
gossipsub, identify, kad, mdns, noise,
|
||||
swarm::{NetworkBehaviour, SwarmEvent},
|
||||
tcp, yamux, Multiaddr, PeerId,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use libp2p_core::{muxing::StreamMuxerBox, Multiaddr, PeerId, Transport};
|
||||
use libp2p_gossipsub as gossipsub;
|
||||
use libp2p_identify as identify;
|
||||
use libp2p_identity as identity;
|
||||
use libp2p_kad as kad;
|
||||
use libp2p_swarm::{NetworkBehaviour, Swarm, SwarmEvent};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -39,6 +39,11 @@ pub struct RefUpdateEvent {
|
||||
pub pusher_did: String,
|
||||
/// Repository identifier (owner/name)
|
||||
pub repo: String,
|
||||
/// Full owner DID — added in #144 for display and storage; not yet
|
||||
/// wired into the feed gate matcher. Optional for backward compat with
|
||||
/// older peers that don't include it.
|
||||
#[serde(default)]
|
||||
pub owner_did: Option<String>,
|
||||
/// Git ref that changed (e.g., "refs/heads/main")
|
||||
pub ref_name: String,
|
||||
/// SHA before the push (all-zeros for new ref)
|
||||
@@ -152,21 +157,23 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
|
||||
|
||||
/// Combined libp2p behaviour.
|
||||
#[derive(NetworkBehaviour)]
|
||||
#[behaviour(prelude = "libp2p_swarm::derive_prelude")]
|
||||
struct GitlawbBehaviour {
|
||||
kademlia: kad::Behaviour<kad::store::MemoryStore>,
|
||||
gossipsub: gossipsub::Behaviour,
|
||||
identify: identify::Behaviour,
|
||||
mdns: mdns::tokio::Behaviour,
|
||||
}
|
||||
|
||||
/// Start the libp2p swarm. Returns a handle for sending commands and the
|
||||
/// listening multiaddrs. Runs the event loop as a background tokio task.
|
||||
/// listening multiaddrs. Runs the event loop as a background tokio task
|
||||
/// that exits cleanly when `shutdown_rx` flips to `true`.
|
||||
pub async fn start(
|
||||
node_did: &str,
|
||||
listen_port: u16,
|
||||
bootstrap_addrs: Vec<Multiaddr>,
|
||||
db: Arc<Db>,
|
||||
auto_sync: bool,
|
||||
shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<P2pHandle> {
|
||||
// Derive a stable libp2p Ed25519 key from a seed based on the node DID.
|
||||
// In production you'd load/persist this key alongside the identity PEM.
|
||||
@@ -183,7 +190,7 @@ pub async fn start(
|
||||
seed_bytes[i * 8..(i + 1) * 8].copy_from_slice(&seed.wrapping_add(i as u64).to_le_bytes());
|
||||
}
|
||||
|
||||
let local_key = libp2p::identity::Keypair::ed25519_from_bytes(seed_bytes)
|
||||
let local_key = identity::Keypair::ed25519_from_bytes(seed_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create p2p keypair: {e}"))?;
|
||||
let local_peer_id = PeerId::from(local_key.public());
|
||||
|
||||
@@ -196,65 +203,66 @@ pub async fn start(
|
||||
local_peer_id,
|
||||
};
|
||||
|
||||
// Build the swarm
|
||||
let mut swarm = libp2p::SwarmBuilder::with_existing_identity(local_key)
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
tcp::Config::default(),
|
||||
noise::Config::new,
|
||||
yamux::Config::default,
|
||||
)?
|
||||
.with_behaviour(|key| {
|
||||
let peer_id = PeerId::from(key.public());
|
||||
let kad_store = kad::store::MemoryStore::new(local_peer_id);
|
||||
let mut kademlia = kad::Behaviour::new(local_peer_id, kad_store);
|
||||
kademlia.set_mode(Some(kad::Mode::Server));
|
||||
|
||||
// Kademlia
|
||||
let kad_store = kad::store::MemoryStore::new(peer_id);
|
||||
let mut kademlia = kad::Behaviour::new(peer_id, kad_store);
|
||||
kademlia.set_mode(Some(kad::Mode::Server));
|
||||
let gossipsub_config = gossipsub::ConfigBuilder::default()
|
||||
.heartbeat_interval(Duration::from_secs(10))
|
||||
.validation_mode(gossipsub::ValidationMode::Permissive)
|
||||
.message_id_fn(|msg: &gossipsub::Message| {
|
||||
let mut h = DefaultHasher::new();
|
||||
msg.data.hash(&mut h);
|
||||
gossipsub::MessageId::from(h.finish().to_string())
|
||||
})
|
||||
.build()
|
||||
.expect("gossipsub config");
|
||||
let gossipsub = gossipsub::Behaviour::new(
|
||||
gossipsub::MessageAuthenticity::Signed(local_key.clone()),
|
||||
gossipsub_config,
|
||||
)
|
||||
.expect("gossipsub behaviour");
|
||||
|
||||
// Gossipsub — message_id based on content hash to deduplicate
|
||||
let gossipsub_config = gossipsub::ConfigBuilder::default()
|
||||
.heartbeat_interval(Duration::from_secs(10))
|
||||
.validation_mode(gossipsub::ValidationMode::Permissive)
|
||||
.message_id_fn(|msg: &gossipsub::Message| {
|
||||
let mut h = DefaultHasher::new();
|
||||
msg.data.hash(&mut h);
|
||||
gossipsub::MessageId::from(h.finish().to_string())
|
||||
})
|
||||
.build()
|
||||
.expect("gossipsub config");
|
||||
let gossipsub = gossipsub::Behaviour::new(
|
||||
gossipsub::MessageAuthenticity::Signed(key.clone()),
|
||||
gossipsub_config,
|
||||
)
|
||||
.expect("gossipsub behaviour");
|
||||
let identify = identify::Behaviour::new(identify::Config::new(
|
||||
"/gitlawb/1.0.0".to_string(),
|
||||
local_key.public(),
|
||||
));
|
||||
|
||||
// Identify — so peers can exchange their observed addresses
|
||||
let identify = identify::Behaviour::new(identify::Config::new(
|
||||
"/gitlawb/1.0.0".to_string(),
|
||||
key.public(),
|
||||
));
|
||||
|
||||
// mDNS — local network peer discovery
|
||||
let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), peer_id)
|
||||
.expect("mdns behaviour");
|
||||
|
||||
GitlawbBehaviour {
|
||||
kademlia,
|
||||
gossipsub,
|
||||
identify,
|
||||
mdns,
|
||||
}
|
||||
})?
|
||||
.build();
|
||||
let behaviour = GitlawbBehaviour {
|
||||
kademlia,
|
||||
gossipsub,
|
||||
identify,
|
||||
};
|
||||
// DNS wraps QUIC so multiaddrs like /dns6/<app>.internal/udp/…/quic-v1
|
||||
// resolve at dial time. On Fly, peer nodes must dial each other over the
|
||||
// private 6PN network via <app>.internal hostnames — dialing through the
|
||||
// public anycast edge breaks the handshake (the proxy closes the
|
||||
// connection mid-stream).
|
||||
let quic = libp2p_quic::tokio::Transport::new(libp2p_quic::Config::new(&local_key))
|
||||
.map(|(peer_id, muxer), _| (peer_id, StreamMuxerBox::new(muxer)));
|
||||
let transport = libp2p_dns::tokio::Transport::system(quic)?.boxed();
|
||||
let mut swarm = Swarm::new(
|
||||
transport,
|
||||
behaviour,
|
||||
local_peer_id,
|
||||
libp2p_swarm::Config::with_tokio_executor(),
|
||||
);
|
||||
|
||||
// Subscribe to the ref-updates topic
|
||||
let topic = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC);
|
||||
swarm.behaviour_mut().gossipsub.subscribe(&topic)?;
|
||||
|
||||
// Listen
|
||||
let listen_addr: Multiaddr = format!("/ip4/0.0.0.0/tcp/{listen_port}").parse()?;
|
||||
swarm.listen_on(listen_addr)?;
|
||||
// Listen on both IPv4 (local/mDNS + any IPv4 dials) and IPv6 (required
|
||||
// for Fly's 6PN inter-app network — <app>.internal DNS only returns AAAA
|
||||
// records, so peers dial us via IPv6 and need a matching IPv6 socket).
|
||||
let v4: Multiaddr = format!("/ip4/0.0.0.0/udp/{listen_port}/quic-v1").parse()?;
|
||||
if let Err(e) = swarm.listen_on(v4) {
|
||||
warn!(err = %e, "failed to listen on IPv4");
|
||||
}
|
||||
let v6: Multiaddr = format!("/ip6/::/udp/{listen_port}/quic-v1").parse()?;
|
||||
if let Err(e) = swarm.listen_on(v6) {
|
||||
warn!(err = %e, "failed to listen on IPv6");
|
||||
}
|
||||
|
||||
// Bootstrap Kademlia with known peers
|
||||
for addr in bootstrap_addrs {
|
||||
@@ -270,8 +278,18 @@ pub async fn start(
|
||||
|
||||
// Start the event loop as a background task
|
||||
tokio::spawn(async move {
|
||||
let mut shutdown_rx = shutdown_rx;
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Graceful shutdown: exit the swarm loop when the
|
||||
// process-wide signal flips. This drops the Swarm
|
||||
// which closes all libp2p connections cleanly.
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("p2p swarm: shutdown signal received, exiting event loop");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle swarm events
|
||||
event = swarm.select_next_some() => {
|
||||
match event {
|
||||
@@ -294,6 +312,7 @@ pub async fn start(
|
||||
node_did: event.node_did.clone(),
|
||||
pusher_did: event.pusher_did.clone(),
|
||||
repo: event.repo.clone(),
|
||||
owner_did: event.owner_did.clone(),
|
||||
ref_name: event.ref_name.clone(),
|
||||
old_sha: event.old_sha.clone(),
|
||||
new_sha: event.new_sha.clone(),
|
||||
@@ -343,15 +362,6 @@ pub async fn start(
|
||||
}
|
||||
}
|
||||
|
||||
SwarmEvent::Behaviour(GitlawbBehaviourEvent::Mdns(
|
||||
mdns::Event::Discovered(peers)
|
||||
)) => {
|
||||
for (peer_id, addr) in peers {
|
||||
debug!(peer = %peer_id, addr = %addr, "mDNS discovered peer");
|
||||
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr.clone());
|
||||
let _ = swarm.dial(addr);
|
||||
}
|
||||
}
|
||||
SwarmEvent::Behaviour(GitlawbBehaviourEvent::Identify(
|
||||
identify::Event::Received { peer_id, info, .. }
|
||||
)) => {
|
||||
@@ -428,3 +438,67 @@ pub async fn start(
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ref_update_event_round_trip_with_owner_did() {
|
||||
let event = RefUpdateEvent {
|
||||
node_did: "did:key:zNode".into(),
|
||||
pusher_did: "did:key:zPusher".into(),
|
||||
repo: "zOwner/myrepo".into(),
|
||||
owner_did: Some("did:key:zOwner".into()),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0000000000000000000000000000000000000000".into(),
|
||||
new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
|
||||
timestamp: "2026-07-02T12:00:00Z".into(),
|
||||
cert_id: None,
|
||||
cid: None,
|
||||
};
|
||||
let json = serde_json::to_value(&event).unwrap();
|
||||
// owner_did must be present in the serialized output
|
||||
assert_eq!(json["owner_did"], "did:key:zOwner");
|
||||
assert_eq!(json["repo"], "zOwner/myrepo");
|
||||
|
||||
let deserialized: RefUpdateEvent = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(deserialized.owner_did, Some("did:key:zOwner".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_update_event_backward_compat_no_owner_did() {
|
||||
let old_json = serde_json::json!({
|
||||
"node_did": "did:key:zNode",
|
||||
"pusher_did": "did:key:zPusher",
|
||||
"repo": "zOwner/myrepo",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0000000000000000000000000000000000000000",
|
||||
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"timestamp": "2026-07-02T12:00:00Z",
|
||||
"cert_id": null,
|
||||
"cid": null
|
||||
});
|
||||
let deserialized: RefUpdateEvent = serde_json::from_value(old_json).unwrap();
|
||||
assert_eq!(deserialized.owner_did, None);
|
||||
assert_eq!(deserialized.repo, "zOwner/myrepo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_update_event_backward_compat_null_owner_did() {
|
||||
let with_null = serde_json::json!({
|
||||
"node_did": "did:key:zNode",
|
||||
"pusher_did": "did:key:zPusher",
|
||||
"repo": "zOwner/myrepo",
|
||||
"owner_did": null,
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0000000000000000000000000000000000000000",
|
||||
"new_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"timestamp": "2026-07-02T12:00:00Z",
|
||||
"cert_id": null,
|
||||
"cid": null
|
||||
});
|
||||
let deserialized: RefUpdateEvent = serde_json::from_value(with_null).unwrap();
|
||||
assert_eq!(deserialized.owner_did, None);
|
||||
}
|
||||
}
|
||||
@@ -66,33 +66,28 @@ pub async fn pin_object(
|
||||
Ok(cid)
|
||||
}
|
||||
|
||||
/// Pin all git objects in a repo that haven't yet been sent to Pinata.
|
||||
/// Pin any of the given candidate git objects that haven't yet been sent to
|
||||
/// Pinata.
|
||||
///
|
||||
/// Objects already recorded with a `pinata_cid` in the DB are skipped.
|
||||
/// Returns `(sha_hex, cid)` pairs for each newly pinned object.
|
||||
/// `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.
|
||||
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,
|
||||
) -> 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 mut pinned = Vec::new();
|
||||
|
||||
for sha in object_list {
|
||||
@@ -131,29 +126,6 @@ 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)]
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::{ConnectInfo, Request};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
|
||||
/// Default ceiling on the number of distinct keys a limiter tracks. Bounds the
|
||||
/// limiter's own memory so a caller that can vary the key (many DIDs, or a
|
||||
/// spoofable IP header) cannot grow the map without limit.
|
||||
const DEFAULT_MAX_KEYS: usize = 100_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Window {
|
||||
timestamps: Vec<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
state: Arc<Mutex<HashMap<String, Window>>>,
|
||||
max_requests: usize,
|
||||
window: Duration,
|
||||
/// Hard cap on tracked keys. When full, expired keys are evicted (at most
|
||||
/// once per [`sweep_interval`](Self::sweep_interval), see below); if still
|
||||
/// full, a request under a *new* key is rejected rather than inserted — so
|
||||
/// the map can never exceed this bound and a rejected request never
|
||||
/// allocates a new entry.
|
||||
max_keys: usize,
|
||||
/// Timestamp of the last inline capacity sweep. The eviction scan is
|
||||
/// O(max_keys), so under a distinct-key flood (when the map sits at the
|
||||
/// cap) running it on every miss would serialize all traffic behind a full
|
||||
/// scan. Gating it to at most once per interval amortizes that cost; the
|
||||
/// background [`cleanup`](Self::cleanup) loop still reclaims on its own.
|
||||
last_sweep: Arc<Mutex<Instant>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
// Retained for tests and callers that don't need a tight key bound;
|
||||
// production limiters use `new_bounded`.
|
||||
#[allow(dead_code)]
|
||||
pub fn new(max_requests: usize, window: Duration) -> Self {
|
||||
Self::new_bounded(max_requests, window, DEFAULT_MAX_KEYS)
|
||||
}
|
||||
|
||||
/// Like [`new`] but with an explicit cap on the number of distinct keys.
|
||||
/// Production limiters keyed on client-influenced values (per-DID, per-IP)
|
||||
/// set this so the limiter's own state cannot be turned into a
|
||||
/// memory-exhaustion vector.
|
||||
pub fn new_bounded(max_requests: usize, window: Duration, max_keys: usize) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(HashMap::new())),
|
||||
max_requests,
|
||||
window,
|
||||
max_keys: max_keys.max(1),
|
||||
last_sweep: Arc::new(Mutex::new(Instant::now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the inline capacity sweep may run: at most once per second, or
|
||||
/// once per window when the window is shorter. Bounds post-flood staleness
|
||||
/// (a full map self-heals within one interval) without paying the O(max_keys)
|
||||
/// scan on every capacity miss.
|
||||
fn sweep_interval(&self) -> Duration {
|
||||
self.window.min(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
pub(crate) async fn check(&self, key: &str) -> bool {
|
||||
// max_requests == 0 means the limiter is disabled, not "block all".
|
||||
if self.max_requests == 0 {
|
||||
return true;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// Fast path: an already-tracked key never allocates and never grows the
|
||||
// map, so it is unaffected by the key cap.
|
||||
if let Some(window) = state.get_mut(key) {
|
||||
window
|
||||
.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
if window.timestamps.len() >= self.max_requests {
|
||||
return false;
|
||||
}
|
||||
window.timestamps.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
// New key. Enforce the cap BEFORE inserting so a flood of distinct keys
|
||||
// cannot grow the map, and a rejected request never allocates an entry.
|
||||
if state.len() >= self.max_keys {
|
||||
// Reclaim expired keys, but at most once per sweep interval — the
|
||||
// scan is O(max_keys) and the map sits at the cap precisely during a
|
||||
// distinct-key flood, so sweeping per miss would serialize traffic
|
||||
// behind a full scan. Between sweeps a new key is simply rejected.
|
||||
let mut last_sweep = self.last_sweep.lock().await;
|
||||
if now.duration_since(*last_sweep) >= self.sweep_interval() {
|
||||
state.retain(|_, w| {
|
||||
w.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
!w.timestamps.is_empty()
|
||||
});
|
||||
*last_sweep = now;
|
||||
}
|
||||
drop(last_sweep);
|
||||
if state.len() >= self.max_keys {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
state.insert(
|
||||
key.to_string(),
|
||||
Window {
|
||||
timestamps: vec![now],
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock().await;
|
||||
state.retain(|_, w| {
|
||||
w.timestamps
|
||||
.retain(|t| now.duration_since(*t) < self.window);
|
||||
!w.timestamps.is_empty()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
let limiter = request.extensions().get::<RateLimiter>().cloned();
|
||||
|
||||
let did = request
|
||||
.extensions()
|
||||
.get::<AuthenticatedDid>()
|
||||
.map(|a| a.0.clone());
|
||||
|
||||
if let (Some(limiter), Some(did)) = (limiter, did) {
|
||||
if !limiter.check(&did).await {
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Which forwarded header (if any) the operator's edge is trusted to set. Only
|
||||
/// a proxy the operator controls may be believed; a raw client can put any
|
||||
/// value in `Fly-Client-IP` / `X-Forwarded-For`, so trusting them unconditionally
|
||||
/// lets a flooder rotate the header and never fill a bucket. Configured via
|
||||
/// `GITLAWB_TRUSTED_PROXY`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum TrustedProxy {
|
||||
/// No trusted proxy: ignore forwarded headers, key on the socket peer IP.
|
||||
/// Safe default for direct/self-hosted nodes.
|
||||
None,
|
||||
/// Behind Fly's edge, which sets (and overwrites any client-supplied)
|
||||
/// `Fly-Client-IP`.
|
||||
Fly,
|
||||
/// Behind a single reverse proxy (e.g. Caddy on the AWS image) that appends
|
||||
/// the real client as the rightmost `X-Forwarded-For` hop.
|
||||
XForwardedFor,
|
||||
}
|
||||
|
||||
impl TrustedProxy {
|
||||
/// Parse `GITLAWB_TRUSTED_PROXY`. Unknown/empty → `None` (trust nothing).
|
||||
pub fn from_env_value(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"fly" | "fly-client-ip" => TrustedProxy::Fly,
|
||||
"xff" | "x-forwarded-for" | "caddy" => TrustedProxy::XForwardedFor,
|
||||
_ => TrustedProxy::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trimmed_nonempty(v: &str) -> Option<String> {
|
||||
let t = v.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the rate-limit key for a request. In a trusted-proxy mode the
|
||||
/// operator's edge header is preferred; when that header is missing or empty we
|
||||
/// fall back to the socket peer address rather than skipping the limiter (a
|
||||
/// malformed header must never disable the brake). With no trusted proxy the
|
||||
/// socket peer is always used and forwarded headers are ignored entirely.
|
||||
/// Returns `None` only when neither a trusted header nor a peer address is
|
||||
/// available (e.g. a synthetic test request with no `ConnectInfo`).
|
||||
pub fn client_key(
|
||||
headers: &HeaderMap,
|
||||
peer: Option<SocketAddr>,
|
||||
trust: TrustedProxy,
|
||||
) -> Option<String> {
|
||||
let from_header = match trust {
|
||||
TrustedProxy::None => None,
|
||||
TrustedProxy::Fly => headers
|
||||
.get("fly-client-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(trimmed_nonempty),
|
||||
// Rightmost hop = the value appended by the immediately-upstream trusted
|
||||
// proxy. The leftmost hop is client-controlled and must not be trusted.
|
||||
TrustedProxy::XForwardedFor => headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.rsplit(',').next())
|
||||
.and_then(trimmed_nonempty),
|
||||
};
|
||||
from_header.or_else(|| peer.map(|p| p.ip().to_string()))
|
||||
}
|
||||
|
||||
/// Per-client-IP limiter for the git push path, carrying the trusted-proxy
|
||||
/// policy. A newtype so it can live in request extensions (keyed by type)
|
||||
/// alongside the per-DID [`RateLimiter`]. Per-DID limits are useless against a
|
||||
/// push flood from a DID farm (one throwaway DID per repo), so the push path
|
||||
/// throttles on the resolved client IP instead.
|
||||
#[derive(Clone)]
|
||||
pub struct IpRateLimiter {
|
||||
pub limiter: RateLimiter,
|
||||
pub trust: TrustedProxy,
|
||||
}
|
||||
|
||||
/// Infallible extractor for the socket peer address from `ConnectInfo`. Yields
|
||||
/// `None` when the server was started without connect-info (e.g. `oneshot` in
|
||||
/// tests), so a handler never 500s on its absence — the limiter simply falls
|
||||
/// back per [`client_key`].
|
||||
pub struct PeerAddr(pub Option<SocketAddr>);
|
||||
|
||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Ok(PeerAddr(
|
||||
parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
|
||||
/// middleware now serves the push path AND the peer-sync routes, so the message
|
||||
/// stays generic (the offending path is recorded in the warn log below).
|
||||
pub fn too_many_requests() -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
"rate limit exceeded — try again later",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Throttle the git push path by resolved client IP. The socket peer address is
|
||||
/// read from `ConnectInfo` (see `into_make_service_with_connect_info` in
|
||||
/// `main`). Only skips the limiter when no key can be resolved at all.
|
||||
pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
let limiter = request.extensions().get::<IpRateLimiter>().cloned();
|
||||
let peer = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|c| c.0);
|
||||
|
||||
if let Some(limiter) = limiter {
|
||||
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
|
||||
if !limiter.limiter.check(&key).await {
|
||||
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
|
||||
return too_many_requests();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_within_limit() {
|
||||
let limiter = RateLimiter::new(3, Duration::from_secs(60));
|
||||
assert!(limiter.check("did:key:test1").await);
|
||||
assert!(limiter.check("did:key:test1").await);
|
||||
assert!(limiter.check("did:key:test1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocks_over_limit() {
|
||||
let limiter = RateLimiter::new(2, Duration::from_secs(60));
|
||||
assert!(limiter.check("did:key:test2").await);
|
||||
assert!(limiter.check("did:key:test2").await);
|
||||
assert!(!limiter.check("did:key:test2").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn separate_keys_independent() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_secs(60));
|
||||
assert!(limiter.check("did:key:alice").await);
|
||||
assert!(limiter.check("did:key:bob").await);
|
||||
assert!(!limiter.check("did:key:alice").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn window_expires() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_millis(50));
|
||||
assert!(limiter.check("did:key:test3").await);
|
||||
assert!(!limiter.check("did:key:test3").await);
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
assert!(limiter.check("did:key:test3").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_removes_expired() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_millis(50));
|
||||
limiter.check("did:key:stale").await;
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
limiter.cleanup().await;
|
||||
let state = limiter.state.lock().await;
|
||||
assert!(state.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_limit_disables() {
|
||||
let limiter = RateLimiter::new(0, Duration::from_secs(60));
|
||||
for _ in 0..1000 {
|
||||
assert!(limiter.check("k").await);
|
||||
}
|
||||
}
|
||||
|
||||
// ── key-cap / memory-bound (P2) ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn caps_tracked_keys_and_rejects_new_ones_when_full() {
|
||||
// Cap of 2 distinct keys; generous request budget so rejection is due to
|
||||
// the key cap, not the per-key limit.
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 2);
|
||||
assert!(limiter.check("a").await);
|
||||
assert!(limiter.check("b").await);
|
||||
// Third distinct key would grow the map past the cap → rejected.
|
||||
assert!(!limiter.check("c").await);
|
||||
// The map never exceeded the cap, and the rejected key was NOT inserted.
|
||||
let state = limiter.state.lock().await;
|
||||
assert_eq!(state.len(), 2);
|
||||
assert!(!state.contains_key("c"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_key_unaffected_by_cap() {
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(60), 1);
|
||||
assert!(limiter.check("a").await); // fills the single slot
|
||||
assert!(limiter.check("a").await); // same key still served
|
||||
assert!(!limiter.check("b").await); // new key rejected — cap full
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_keys_evicted_to_admit_new_when_full() {
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_millis(40), 1);
|
||||
assert!(limiter.check("old").await);
|
||||
tokio::time::sleep(Duration::from_millis(55)).await;
|
||||
// "old" is now expired; a new key triggers inline eviction and is admitted.
|
||||
assert!(limiter.check("new").await);
|
||||
let state = limiter.state.lock().await;
|
||||
assert!(state.contains_key("new"));
|
||||
assert!(!state.contains_key("old"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_sweep_is_amortized_within_interval() {
|
||||
// The O(max_keys) eviction scan must not run on every capacity miss.
|
||||
// With a 1s sweep interval, the resident (unexpired) key is never
|
||||
// evicted by a burst of misses, so repeated new keys are all rejected
|
||||
// without disturbing existing state.
|
||||
let limiter = RateLimiter::new_bounded(100, Duration::from_secs(1), 1);
|
||||
assert!(limiter.check("resident").await);
|
||||
for i in 0..100 {
|
||||
assert!(
|
||||
!limiter.check(&format!("flood-{i}")).await,
|
||||
"new key must be rejected while the single slot is held"
|
||||
);
|
||||
}
|
||||
let state = limiter.state.lock().await;
|
||||
assert_eq!(state.len(), 1, "no flood key was ever inserted");
|
||||
assert!(state.contains_key("resident"));
|
||||
}
|
||||
|
||||
// ── client_key / trusted-proxy resolution (P1 + P2) ─────────────────
|
||||
|
||||
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
v.parse().unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn peer(s: &str) -> Option<SocketAddr> {
|
||||
Some(s.parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_mode_ignores_headers_and_uses_peer() {
|
||||
// Even a well-formed Fly header is ignored without a trusted proxy.
|
||||
let h = headers(&[("fly-client-ip", "203.0.113.7")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::None).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fly_mode_trusts_fly_header() {
|
||||
let h = headers(&[
|
||||
("fly-client-ip", "203.0.113.7"),
|
||||
("x-forwarded-for", "1.2.3.4"),
|
||||
]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::Fly).as_deref(),
|
||||
Some("203.0.113.7")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fly_mode_empty_header_falls_back_to_peer_not_skip() {
|
||||
// Empty Fly-Client-IP must NOT collapse traffic onto Some("") nor skip
|
||||
// the limiter — it falls back to the real peer.
|
||||
let h = headers(&[("fly-client-ip", "")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::Fly).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xff_mode_uses_rightmost_hop_not_client_controlled_first() {
|
||||
// Client prepends spoofed hops; only the rightmost (proxy-appended) is trusted.
|
||||
let h = headers(&[("x-forwarded-for", "9.9.9.9, 8.8.8.8, 198.51.100.9")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xff_mode_empty_leading_hop_does_not_disable_brake() {
|
||||
// "X-Forwarded-For: ,1.2.3.4" — rightmost hop is used; never None-skips.
|
||||
let h = headers(&[("x-forwarded-for", ",1.2.3.4")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("10.0.0.1:1"), TrustedProxy::XForwardedFor).as_deref(),
|
||||
Some("1.2.3.4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_xff_falls_back_to_peer() {
|
||||
let h = headers(&[("x-forwarded-for", " , ")]);
|
||||
assert_eq!(
|
||||
client_key(&h, peer("198.51.100.9:4000"), TrustedProxy::XForwardedFor).as_deref(),
|
||||
Some("198.51.100.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_proxy_parsing() {
|
||||
assert_eq!(TrustedProxy::from_env_value("fly"), TrustedProxy::Fly);
|
||||
assert_eq!(
|
||||
TrustedProxy::from_env_value("X-Forwarded-For"),
|
||||
TrustedProxy::XForwardedFor
|
||||
);
|
||||
assert_eq!(
|
||||
TrustedProxy::from_env_value("caddy"),
|
||||
TrustedProxy::XForwardedFor
|
||||
);
|
||||
assert_eq!(TrustedProxy::from_env_value(""), TrustedProxy::None);
|
||||
assert_eq!(TrustedProxy::from_env_value("garbage"), TrustedProxy::None);
|
||||
}
|
||||
|
||||
// ── middleware 429 path ─────────────────────────────────────────────
|
||||
|
||||
/// A minimal router with the push limiter layered over an OK handler,
|
||||
/// driven via `oneshot`. `ConnectInfo` is attached to each request directly
|
||||
/// (as the production make-service does) so the middleware resolves a peer.
|
||||
fn ip_limited_router(limiter: IpRateLimiter) -> axum::Router {
|
||||
axum::Router::new()
|
||||
.route(
|
||||
"/o/r/git-receive-pack",
|
||||
axum::routing::post(|| async { StatusCode::OK }),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(rate_limit_by_ip))
|
||||
.layer(axum::Extension(limiter))
|
||||
}
|
||||
|
||||
async fn post_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
|
||||
use tower::ServiceExt;
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method(axum::http::Method::POST)
|
||||
.uri("/o/r/git-receive-pack")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
router.clone().oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_returns_429_over_limit() {
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(2, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let peer: SocketAddr = "203.0.113.7:5000".parse().unwrap();
|
||||
// Two requests inside the budget, the third over it (shared Arc state).
|
||||
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
|
||||
assert_eq!(post_from(&router, peer).await, StatusCode::OK);
|
||||
assert_eq!(
|
||||
post_from(&router, peer).await,
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_isolates_distinct_peers() {
|
||||
let router = ip_limited_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let a: SocketAddr = "203.0.113.1:1".parse().unwrap();
|
||||
let b: SocketAddr = "203.0.113.2:1".parse().unwrap();
|
||||
assert_eq!(post_from(&router, a).await, StatusCode::OK);
|
||||
assert_eq!(post_from(&router, b).await, StatusCode::OK); // independent bucket
|
||||
assert_eq!(post_from(&router, a).await, StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
// ── creation-route layering: IP brake runs BEFORE auth ──────────────
|
||||
|
||||
/// A minimal stand-in for the creation route group: an inner "auth" layer
|
||||
/// that rejects every request with 401 (as signature verification would for
|
||||
/// an unsigned caller), wrapped by the per-IP creation limiter as the
|
||||
/// outermost layer — exactly the ordering `server::build_router` applies to
|
||||
/// `/api/v1/repos` et al. Lets us assert the security-relevant property
|
||||
/// (flood traffic is braked before auth burns CPU) without a database, which
|
||||
/// the full `#[sqlx::test]` route test in `api::repos` cannot cover in the
|
||||
/// plain unit-test pass.
|
||||
fn ip_limited_over_auth_router(limiter: IpRateLimiter) -> axum::Router {
|
||||
async fn always_unauthorized(_req: Request, _next: Next) -> Response {
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
axum::Router::new()
|
||||
.route(
|
||||
"/api/v1/repos",
|
||||
axum::routing::post(|| async { StatusCode::CREATED }),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(always_unauthorized))
|
||||
.layer(axum::middleware::from_fn(rate_limit_by_ip))
|
||||
.layer(axum::Extension(limiter))
|
||||
}
|
||||
|
||||
async fn post_repos_from(router: &axum::Router, peer: SocketAddr) -> StatusCode {
|
||||
use tower::ServiceExt;
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method(axum::http::Method::POST)
|
||||
.uri("/api/v1/repos")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(peer));
|
||||
router.clone().oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creation_ip_brake_rejects_before_auth() {
|
||||
// A DID farm mints a fresh signature per repo, so the per-DID limiter
|
||||
// never trips; the per-IP brake is what stops the flood. It must run
|
||||
// outermost, ahead of auth, so a flood is rejected with 429 before
|
||||
// signature verification — otherwise every request would reach (and pay
|
||||
// for) auth and merely 401.
|
||||
let router = ip_limited_over_auth_router(IpRateLimiter {
|
||||
limiter: RateLimiter::new(1, Duration::from_secs(60)),
|
||||
trust: TrustedProxy::None,
|
||||
});
|
||||
let peer: SocketAddr = "203.0.113.42:7000".parse().unwrap();
|
||||
|
||||
// First request passes the IP brake and reaches the (failing) auth layer.
|
||||
assert_eq!(
|
||||
post_repos_from(&router, peer).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Budget now exhausted: the IP brake short-circuits with 429 *before*
|
||||
// auth — proving the limiter is the outermost layer.
|
||||
assert_eq!(
|
||||
post_repos_from(&router, peer).await,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"an exhausted IP must be braked with 429 before auth runs, not leak to 401"
|
||||
);
|
||||
}
|
||||
}
|
||||
+386
-130
@@ -13,14 +13,26 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, TraceLayer};
|
||||
use tracing::Level;
|
||||
|
||||
use crate::api::{
|
||||
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, protect,
|
||||
pulls, register, replicas, repos, resolve, stars, tasks, webhooks,
|
||||
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles,
|
||||
protect, pulls, register, replicas, repos, resolve, stars, status, tasks, visibility, webhooks,
|
||||
};
|
||||
use crate::auth;
|
||||
use crate::rate_limit;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn graphql_handler(State(state): State<AppState>, req: GraphQLRequest) -> GraphQLResponse {
|
||||
state.graphql_schema.execute(req.into_inner()).await.into()
|
||||
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_playground() -> impl IntoResponse {
|
||||
@@ -30,132 +42,270 @@ async fn graphql_playground() -> impl IntoResponse {
|
||||
))
|
||||
}
|
||||
|
||||
/// Applies the standard auth middleware pair to a router: HTTP Signature verification
|
||||
/// followed by UCAN chain validation. The two layers run in this order for every
|
||||
/// matched request: `require_signature` first (sets `AuthenticatedDid`), then
|
||||
/// `require_ucan_chain` (reads it).
|
||||
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
|
||||
router
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
auth::require_ucan_chain,
|
||||
))
|
||||
.layer(middleware::from_fn(auth::require_signature))
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
// ── GraphQL routes ─────────────────────────────────────────────────────
|
||||
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) ───────────────────────
|
||||
let task_write_routes = Router::new()
|
||||
.route("/api/v1/tasks", post(tasks::create_task))
|
||||
.route("/api/v1/tasks/{id}/claim", post(tasks::claim_task))
|
||||
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
|
||||
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task))
|
||||
.layer(middleware::from_fn(auth::require_signature));
|
||||
let task_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route("/api/v1/tasks", post(tasks::create_task))
|
||||
.route("/api/v1/tasks/{id}/claim", post(tasks::claim_task))
|
||||
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
|
||||
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
|
||||
state.clone(),
|
||||
);
|
||||
|
||||
// ── Task routes (read — open) ──────────────────────────────────────────
|
||||
let task_read_routes = Router::new()
|
||||
.route("/api/v1/tasks", get(tasks::list_tasks))
|
||||
.route("/api/v1/tasks/{id}", get(tasks::get_task));
|
||||
|
||||
// ── Write routes — require HTTP Signature ──────────────────────────────
|
||||
let write_routes = Router::new()
|
||||
.route("/api/v1/repos", post(repos::create_repo))
|
||||
.route("/api/register", post(register::register))
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr))
|
||||
// ── Rate-limited creation routes — require HTTP Signature, plus a per-DID
|
||||
// throttle AND a per-IP flood brake. The per-DID limiter (inner) caps a
|
||||
// single identity; the per-IP limiter (outer) caps a DID farm that mints a
|
||||
// fresh throwaway did:key per repo to slip past both the per-DID limit and
|
||||
// the iCaptcha gate — the mechanism behind the recurring spam-repo floods.
|
||||
// The per-IP layer wraps the auth layer (outermost = runs first) so flood
|
||||
// traffic is rejected before signature verification burns CPU, matching the
|
||||
// push path.
|
||||
let limiter = state.rate_limiter.clone();
|
||||
let create_ip_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.create_ip_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let creation_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route("/api/v1/repos", post(repos::create_repo))
|
||||
.route("/api/register", post(register::register))
|
||||
.route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues",
|
||||
post(issues::create_issue),
|
||||
)
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
|
||||
.layer(axum::Extension(limiter)),
|
||||
state.clone(),
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(create_ip_limiter));
|
||||
|
||||
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
|
||||
let write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
|
||||
post(pulls::merge_pr),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/close",
|
||||
post(pulls::close_pr),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
|
||||
post(pulls::create_review),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
|
||||
post(pulls::create_comment),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/hooks",
|
||||
post(webhooks::create_webhook),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/hooks/{id}",
|
||||
axum::routing::delete(webhooks::delete_webhook),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
|
||||
post(protect::protect_branch),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
|
||||
axum::routing::delete(protect::unprotect_branch),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/star",
|
||||
axum::routing::put(stars::star_repo),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/star",
|
||||
axum::routing::delete(stars::unstar_repo),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
axum::routing::put(replicas::register_replica),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
axum::routing::delete(replicas::unregister_replica),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/labels",
|
||||
post(labels::add_label),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/labels/{label}",
|
||||
axum::routing::delete(labels::remove_label),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
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(),
|
||||
);
|
||||
|
||||
// ── Status claim writes — signature, plus the per-DID throttle AND the
|
||||
// per-IP flood brake `creation_routes` carries. Not in `write_routes`, which
|
||||
// has neither: every call here appends a row, so an unthrottled writer grows
|
||||
// the claim log on demand and the three per-repo caps would be the only bound.
|
||||
let status_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/statuses/{sha}",
|
||||
post(status::create_status),
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
|
||||
.layer(axum::Extension(state.rate_limiter.clone())),
|
||||
state.clone(),
|
||||
)
|
||||
// The route's advertised body bound, applied at the transport. It sits
|
||||
// OUTSIDE the auth layers because `require_signature` collects and hashes
|
||||
// the whole body before any handler runs: the handler's own check measures
|
||||
// an allocation that has already happened, so without this layer the 8 KiB
|
||||
// figure bounded what gets stored and not what gets buffered, and
|
||||
// concurrent requests multiplied exactly the cost it was written to
|
||||
// prevent. It sits INSIDE the rate limiters so an oversized request is
|
||||
// still counted by the per-IP brake before it is refused.
|
||||
//
|
||||
// A declared Content-Length over the limit is answered 413 without reading
|
||||
// a byte; a body that declares nothing (or understates) is truncated at the
|
||||
// limit and fails where it is read, which `require_signature` reports as an
|
||||
// unreadable body.
|
||||
.layer(RequestBodyLimitLayer::new(status::MAX_REQUEST_BODY_BYTES))
|
||||
// The one route that persists the signed request body, so the one route
|
||||
// whose signature material carries it. Applied OUTSIDE `add_auth_layers`
|
||||
// (outermost = runs first) because `require_signature` reads this marker to
|
||||
// decide whether to carry the body; a layer added inside the auth pair runs
|
||||
// after the middleware and is never seen. `create_status` refuses an absent
|
||||
// body rather than storing an empty column, so getting this order wrong
|
||||
// fails loudly instead of quietly writing unverifiable claims.
|
||||
.layer(axum::Extension(auth::PersistsSignedBody))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.create_ip_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
}));
|
||||
|
||||
// ── Status claim reads — open, behind `optional_signature` so the handler
|
||||
// sees the caller when one signs and still answers a public repo for an
|
||||
// anonymous reader. Without the layer the handler's optional auth extension
|
||||
// is None for EVERY caller, including the owner, and every private repo
|
||||
// reads as not-found.
|
||||
let status_read_routes = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
|
||||
post(pulls::merge_pr),
|
||||
"/api/v1/repos/{owner}/{repo}/commits/{sha}/status",
|
||||
get(status::commit_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/close",
|
||||
post(pulls::close_pr),
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/status",
|
||||
get(status::pull_request_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
|
||||
post(pulls::create_review),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
|
||||
post(pulls::create_comment),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/hooks",
|
||||
post(webhooks::create_webhook),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/hooks/{id}",
|
||||
axum::routing::delete(webhooks::delete_webhook),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
|
||||
post(protect::protect_branch),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
|
||||
axum::routing::delete(protect::unprotect_branch),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/star",
|
||||
axum::routing::put(stars::star_repo),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/star",
|
||||
axum::routing::delete(stars::unstar_repo),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
axum::routing::put(replicas::register_replica),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
axum::routing::delete(replicas::unregister_replica),
|
||||
)
|
||||
.route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/labels",
|
||||
post(labels::add_label),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/labels/{label}",
|
||||
axum::routing::delete(labels::remove_label),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::require_signature));
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
|
||||
// routes only — all other API routes keep axum's default 2 MB cap.
|
||||
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
|
||||
// helper signs requests with RFC 9421 signatures using the agent's keypair.
|
||||
let pack_limit = state.config.max_pack_bytes;
|
||||
let git_write_routes = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
post(repos::git_receive_pack),
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit))
|
||||
.layer(middleware::from_fn(auth::require_signature));
|
||||
// Per-IP throttle wraps the auth layer (outermost = runs first): flood
|
||||
// traffic is rejected before signature verification burns CPU. Per-DID
|
||||
// limiting is deliberately NOT used here — a DID farm (one throwaway
|
||||
// identity per repo, as in the June 2026 push flood) never trips it.
|
||||
let push_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.push_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let git_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
post(repos::git_receive_pack),
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit)),
|
||||
state.clone(),
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(push_limiter));
|
||||
|
||||
// ── 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))
|
||||
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
|
||||
.layer(middleware::from_fn(auth::optional_signature))
|
||||
.merge(Router::new().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));
|
||||
|
||||
// ── Bounty routes (write — require HTTP Signature) ─────────────────
|
||||
let bounty_write_routes = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/bounties",
|
||||
post(bounties::create_bounty),
|
||||
)
|
||||
.route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty))
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/submit",
|
||||
post(bounties::submit_bounty),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/approve",
|
||||
post(bounties::approve_bounty),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/cancel",
|
||||
post(bounties::cancel_bounty),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::require_signature));
|
||||
let bounty_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/bounties",
|
||||
post(bounties::create_bounty),
|
||||
)
|
||||
.route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty))
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/submit",
|
||||
post(bounties::submit_bounty),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/approve",
|
||||
post(bounties::approve_bounty),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/cancel",
|
||||
post(bounties::cancel_bounty),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/dispute",
|
||||
post(bounties::dispute_bounty),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
|
||||
// ── Bounty routes (read — open) ──────────────────────────────────────
|
||||
let bounty_read_routes = Router::new()
|
||||
@@ -165,41 +315,75 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/api/v1/bounties", get(bounties::list_all_bounties))
|
||||
.route("/api/v1/bounties/{id}", get(bounties::get_bounty))
|
||||
.route(
|
||||
"/api/v1/bounties/{id}/dispute",
|
||||
post(bounties::dispute_bounty),
|
||||
)
|
||||
.route("/api/v1/bounties/stats", get(bounties::bounty_stats))
|
||||
.route(
|
||||
"/api/v1/agents/{did}/bounties",
|
||||
get(bounties::agent_bounty_stats),
|
||||
);
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// ── Issue routes ──────────────────────────────────────────────────────
|
||||
let issue_write_routes = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues",
|
||||
post(issues::create_issue),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{id}/close",
|
||||
post(issues::close_issue),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
|
||||
post(issues::create_issue_comment),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::require_signature));
|
||||
// ── Profile routes (write — require HTTP Signature) ─────────────────
|
||||
let profile_write_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
|
||||
state.clone(),
|
||||
);
|
||||
|
||||
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
|
||||
let issue_write_routes = add_auth_layers(
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{id}/close",
|
||||
post(issues::close_issue),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
|
||||
post(issues::create_issue_comment),
|
||||
),
|
||||
state.clone(),
|
||||
);
|
||||
|
||||
// ── Peer discovery routes ─────────────────────────────────────────────
|
||||
// announce is intentionally unauthenticated — nodes announce by DID which
|
||||
// is self-proving; rate-limiting and blocklisting are handled elsewhere.
|
||||
// Peer writes accept signatures when present and can require them after a
|
||||
// coordinated live-network upgrade.
|
||||
let peer_read_routes = Router::new()
|
||||
.route("/api/v1/peers", get(peers::list_peers))
|
||||
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer));
|
||||
|
||||
// /sync/trigger drives an O(peers) outbound fan-out + per-repo enqueue, so it
|
||||
// ALWAYS requires a signature (both config modes) and carries a tight per-IP
|
||||
// brake. A signature alone does not cap cost — a did:key farm self-registers
|
||||
// (INV-10) — so the IP brake is a separate, load-bearing half. The brake is
|
||||
// outermost (runs before signature verification burns CPU) and is keyed on the
|
||||
// client IP before any DID is read, so DID rotation cannot bypass it.
|
||||
let sync_trigger_routes = add_auth_layers(
|
||||
Router::new().route("/api/v1/sync/trigger", post(peers::trigger_sync)),
|
||||
state.clone(),
|
||||
)
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.sync_trigger_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
}));
|
||||
|
||||
// announce + notify keep their rolling-upgrade signature behavior (unsigned
|
||||
// accepted until all peers upgrade), but both reach peer-write side effects —
|
||||
// notify hits the same enqueue_sync sink as trigger — so they carry a per-IP
|
||||
// brake too, on a SEPARATE bucket from trigger's, so an unsigned notify flood
|
||||
// cannot drain the signed trigger caller's quota.
|
||||
let mut peer_write_routes = Router::new()
|
||||
.route("/api/v1/peers/announce", post(peers::announce))
|
||||
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer))
|
||||
.route("/api/v1/sync/trigger", post(peers::trigger_sync))
|
||||
.route("/api/v1/sync/notify", post(peers::notify_sync));
|
||||
peer_write_routes = if state.config.require_signed_peer_writes {
|
||||
add_auth_layers(peer_write_routes, state.clone())
|
||||
} else {
|
||||
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
|
||||
};
|
||||
let peer_write_routes = peer_write_routes
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(rate_limit::IpRateLimiter {
|
||||
limiter: state.peer_write_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
}));
|
||||
|
||||
// ── Read routes — open for public repos ───────────────────────────────
|
||||
let read_routes = Router::new()
|
||||
@@ -247,9 +431,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/events",
|
||||
get(events::list_repo_events),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/push-events",
|
||||
get(events::list_repo_push_events),
|
||||
)
|
||||
.route("/api/v1/agents", get(agents::list_agents))
|
||||
.route("/api/v1/agents/{did}", get(agents::show_agent))
|
||||
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
|
||||
.route("/api/v1/agents/{did}/profile", get(profiles::get_profile))
|
||||
.route("/api/v1/events/ref-updates", get(events::list_ref_updates))
|
||||
.route("/api/v1/resolve/{did}", get(resolve::resolve_did))
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls", get(pulls::list_prs))
|
||||
@@ -290,22 +479,45 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
get(replicas::list_replicas),
|
||||
)
|
||||
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs));
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
|
||||
// large pack responses from the server don't get truncated on the client side.
|
||||
let git_read_routes = Router::new()
|
||||
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs))
|
||||
.route(
|
||||
"/{owner}/{repo}/git-upload-pack",
|
||||
post(repos::git_upload_pack),
|
||||
)
|
||||
.route(
|
||||
"/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),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/encrypted-blob/{oid}",
|
||||
axum::routing::get(crate::api::encrypted::get_encrypted_blob),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate",
|
||||
axum::routing::get(crate::api::encrypted::replicate_encrypted_blobs),
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit));
|
||||
.layer(RequestBodyLimitLayer::new(pack_limit))
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// ── Meta ──────────────────────────────────────────────────────────────
|
||||
let meta_routes = Router::new()
|
||||
.route("/", get(node_info))
|
||||
.route("/health", get(health))
|
||||
.route("/ready", get(ready))
|
||||
.route("/api/v1/p2p/info", get(p2p_info))
|
||||
.route("/api/v1/stats", get(stats))
|
||||
.route("/api/v1/contracts", get(contracts_info));
|
||||
@@ -316,12 +528,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(task_read_routes)
|
||||
.merge(bounty_write_routes)
|
||||
.merge(bounty_read_routes)
|
||||
.merge(profile_write_routes)
|
||||
.merge(creation_routes)
|
||||
.merge(write_routes)
|
||||
.merge(status_write_routes)
|
||||
.merge(status_read_routes)
|
||||
.merge(git_write_routes)
|
||||
.merge(git_read_routes)
|
||||
.merge(issue_write_routes)
|
||||
.merge(read_routes)
|
||||
.merge(peer_read_routes)
|
||||
.merge(peer_write_routes)
|
||||
.merge(sync_trigger_routes)
|
||||
.merge(ipfs_routes)
|
||||
.merge(arweave_routes)
|
||||
.merge(meta_routes)
|
||||
@@ -344,6 +562,26 @@ async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
/// Readiness = "this node can serve real traffic", and every real endpoint
|
||||
/// depends on the database, so probe it rather than reporting a constant.
|
||||
/// The probe gets its own short bound so a wedged pool can't hang the health
|
||||
/// check for the full acquire timeout.
|
||||
async fn ready(State(state): State<AppState>) -> axum::response::Response {
|
||||
let probe = tokio::time::timeout(std::time::Duration::from_secs(2), state.db.ping()).await;
|
||||
match probe {
|
||||
Ok(Ok(())) => Json(json!({ "status": "ready" })).into_response(),
|
||||
_ => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"status": "degraded",
|
||||
"error": crate::error::DB_UNAVAILABLE_CODE,
|
||||
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let p2p_peer_id = state.p2p.as_ref().map(|h| h.local_peer_id.to_string());
|
||||
Json(json!({
|
||||
@@ -358,13 +596,31 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
let agents = state.db.count_agents().await.unwrap_or(0);
|
||||
let pushes = state.db.count_pushes().await.unwrap_or(0);
|
||||
Json(json!({
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::git::repo_store::RepoStore;
|
||||
use crate::p2p::P2pHandle;
|
||||
use crate::rate_limit::RateLimiter;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefUpdateBroadcast {
|
||||
@@ -16,6 +17,7 @@ pub struct RefUpdateBroadcast {
|
||||
pub pusher_did: String,
|
||||
pub node_did: String,
|
||||
pub timestamp: String,
|
||||
pub owner_did: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -48,4 +50,72 @@ pub struct AppState {
|
||||
pub machine_id: Option<String>,
|
||||
/// Centralized repo storage: local disk cache + optional Tigris backend
|
||||
pub repo_store: RepoStore,
|
||||
/// Per-DID rate limiter for creation endpoints (repos, issues, PRs)
|
||||
pub rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for the same creation endpoints. The per-DID
|
||||
/// limiter above cannot brake a creation flood from a DID farm — one
|
||||
/// throwaway `did:key` per repo means each DID makes a single create call
|
||||
/// and never trips its own bucket. A valid iCaptcha proof does not cap this
|
||||
/// either: the enforced level draws only machine-solvable deterministic
|
||||
/// challenges (and the caller can pin the easy type), so a bot mints a fresh
|
||||
/// DID, solves a proof, and creates a repo unthrottled. Braking on the
|
||||
/// resolved client IP is what actually stops a single-source flood (same
|
||||
/// rationale as `push_rate_limiter`). Keyed by `push_limiter_trust`.
|
||||
pub create_ip_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for git-receive-pack. Per-DID limits cannot
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
pub push_rate_limiter: RateLimiter,
|
||||
/// Which forwarded header (if any) the edge is trusted to set, for
|
||||
/// resolving the push limiter's client-IP key. See `GITLAWB_TRUSTED_PROXY`.
|
||||
/// Node-wide; also keys the two peer-sync limiters below.
|
||||
pub push_limiter_trust: crate::rate_limit::TrustedProxy,
|
||||
/// Per-client-IP limiter for `POST /api/v1/sync/trigger` (tight). The route
|
||||
/// requires a signature, but a signature does not cap cost (a did:key farm
|
||||
/// self-registers), and its per-call cost is an O(peers) fan-out, so the IP
|
||||
/// brake is a separate, load-bearing half. Its own bucket so an unsigned
|
||||
/// `/sync/notify` flood cannot drain the signed trigger caller's quota.
|
||||
pub sync_trigger_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP limiter for the peer-write routes (`/peers/announce`,
|
||||
/// `/sync/notify`) (generous). `/sync/notify` reaches the same `enqueue_sync`
|
||||
/// sink as trigger and accepts unsigned requests from known peers, so it is
|
||||
/// braked too; each peer's distinct IP gets its own bucket.
|
||||
pub peer_write_rate_limiter: RateLimiter,
|
||||
/// Process-wide graceful-shutdown signal. Sending `true` causes every
|
||||
/// task that holds a `watch::Receiver` to exit at its next await point.
|
||||
/// Used by:
|
||||
/// * the SIGINT/SIGTERM handler in `main()`
|
||||
/// * axum's `with_graceful_shutdown` to drain in-flight HTTP requests
|
||||
/// * the libp2p swarm task
|
||||
/// * the gossip, sync, operator heartbeat, and rate-limit cleanup loops
|
||||
pub shutdown_tx: tokio::sync::watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Subscribe to the shutdown signal. Returns a fresh receiver whose
|
||||
/// initial value matches the current state.
|
||||
pub fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<bool> {
|
||||
self.shutdown_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown. Idempotent — calling more than once
|
||||
/// has no effect. Returns `true` if this call was the one that
|
||||
/// flipped the signal.
|
||||
#[allow(dead_code)] // used by tests; main() drives the signal directly
|
||||
pub fn shutdown(&self) -> bool {
|
||||
self.shutdown_tx.send_if_modified(|v| {
|
||||
if *v {
|
||||
false
|
||||
} else {
|
||||
*v = true;
|
||||
true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` once shutdown has been signalled.
|
||||
#[allow(dead_code)] // used by tests and any future handler that wants to short-circuit
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
*self.shutdown_tx.borrow()
|
||||
}
|
||||
}
|
||||
+1922
-63
File diff suppressed because it is too large.
Load diff
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user