mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
189
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 | ||
|
|
14a1cdd656 | ||
|
|
be099386e5 | ||
|
|
b09714af0b | ||
|
|
d09c23da86 | ||
|
|
f3df4a4dd4 | ||
|
|
034cb3c80c | ||
|
|
d2a440e521 | ||
|
|
f312955c35 | ||
|
|
ec4ef81770 |
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
|
||||
@@ -1,6 +1,7 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
dist/
|
||||
|
||||
# Local node data
|
||||
/data
|
||||
@@ -21,3 +22,7 @@ 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.8"
|
||||
# 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"
|
||||
+29
-7
@@ -8,32 +8,46 @@ 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,
|
||||
# cargo can keep the dummy `fn main() {}` binaries from the cache layer above
|
||||
# and the runtime container exits immediately with code 0.
|
||||
COPY crates/ crates/
|
||||
COPY bootstrap-peers.json ./
|
||||
RUN find crates -name "*.rs" -exec touch {} + && \
|
||||
rm -f target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb && \
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
@@ -43,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
|
||||
@@ -64,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,76 +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:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb \
|
||||
cargo run -p gitlawb-node --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## macOS menu bar app
|
||||
|
||||
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
|
||||
- Docker Desktop, OrbStack, or Colima
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
./scripts/build-macos-app.sh
|
||||
```
|
||||
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
|
||||
|
||||
Output:
|
||||
|
||||
```txt
|
||||
dist/Gitlawb Node.app
|
||||
dist/Gitlawb Node.dmg
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- Start/stop local node stack.
|
||||
- Status indicator.
|
||||
- Settings GUI.
|
||||
- Auto-start on login.
|
||||
- Docker runtime detection.
|
||||
|
||||
Unsigned local build:
|
||||
|
||||
```bash
|
||||
xattr -cr "dist/Gitlawb Node.app"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-06-30",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
"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.",
|
||||
"did": "did:key:z6MktERLyhFpjUyN7dnkf7CYY5RXKJ6BTZiKohoi37YVBS7R",
|
||||
"http_url": "https://rapybus-gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-05-16"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
+117
-14
@@ -10,9 +10,9 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Error, Result};
|
||||
use crate::did::Did;
|
||||
use crate::identity::{Keypair, verify};
|
||||
use crate::identity::{verify, Keypair};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
|
||||
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
|
||||
@@ -110,7 +110,7 @@ impl RefUpdateCert {
|
||||
///
|
||||
/// Returns the list of DIDs whose signatures are valid.
|
||||
pub fn verify_all(&self) -> Result<Vec<Did>> {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let signing_bytes = self.body.to_signing_bytes()?;
|
||||
let mut valid = Vec::new();
|
||||
|
||||
@@ -118,10 +118,12 @@ impl RefUpdateCert {
|
||||
// Resolve the verifying key from the DID
|
||||
let vk = cert_sig.signer.to_verifying_key()?;
|
||||
|
||||
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&cert_sig.sig)
|
||||
let sig_bytes_vec = URL_SAFE_NO_PAD
|
||||
.decode(&cert_sig.sig)
|
||||
.map_err(|e| Error::RefCert(format!("invalid base64 sig: {e}")))?;
|
||||
|
||||
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
|
||||
let sig_bytes: [u8; 64] = sig_bytes_vec
|
||||
.try_into()
|
||||
.map_err(|_| Error::RefCert("signature must be 64 bytes".to_string()))?;
|
||||
|
||||
verify(&vk, &signing_bytes, &sig_bytes)?;
|
||||
@@ -133,11 +135,7 @@ impl RefUpdateCert {
|
||||
|
||||
/// Check if this certificate satisfies a threshold of valid signatures
|
||||
/// from the provided set of authorized maintainer DIDs.
|
||||
pub fn satisfies_threshold(
|
||||
&self,
|
||||
maintainers: &[Did],
|
||||
threshold: usize,
|
||||
) -> Result<bool> {
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
let valid = self.verify_all()?;
|
||||
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
|
||||
Ok(count >= threshold)
|
||||
@@ -187,7 +185,8 @@ mod tests {
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
cert.validate_structure().unwrap();
|
||||
let valid = cert.verify_all().unwrap();
|
||||
@@ -208,7 +207,8 @@ mod tests {
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
cert.countersign(&kp2).unwrap();
|
||||
let valid = cert.verify_all().unwrap();
|
||||
@@ -228,7 +228,8 @@ mod tests {
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
cert.countersign(&kp2).unwrap();
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
@@ -246,7 +247,8 @@ mod tests {
|
||||
dummy_hash('b'),
|
||||
42,
|
||||
&kp,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_string_pretty(&cert).unwrap();
|
||||
assert!(json.contains("gitlawb/ref-update/v1"));
|
||||
@@ -255,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());
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
use cid::CidGeneric;
|
||||
use multihash_codetable::{Code, MultihashDigest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
|
||||
use crate::{Error, Result};
|
||||
@@ -87,9 +87,9 @@ pub fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
|
||||
|
||||
/// Parse a 64-character hex SHA-256 string into raw bytes.
|
||||
pub fn sha256_hex_to_bytes(hex_str: &str) -> Result<[u8; 32]> {
|
||||
let bytes = hex::decode(hex_str)
|
||||
.map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
|
||||
bytes.try_into()
|
||||
let bytes = hex::decode(hex_str).map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidCid("sha256 hash must be 32 bytes (64 hex chars)".to_string()))
|
||||
}
|
||||
|
||||
@@ -110,7 +110,10 @@ mod tests {
|
||||
// CIDv1 base32 strings start with 'b'
|
||||
let data = b"blob 13\0hello gitlawb";
|
||||
let c = Cid::from_git_object_bytes(data);
|
||||
assert!(c.to_string().starts_with('b'), "CIDv1 should be base32 (starts with 'b')");
|
||||
assert!(
|
||||
c.to_string().starts_with('b'),
|
||||
"CIDv1 should be base32 (starts with 'b')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -126,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,12 +75,13 @@ impl Did {
|
||||
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
|
||||
if !self.is_did_key() {
|
||||
return Err(Error::InvalidDid(format!(
|
||||
"expected did:key, got did:{}", self.method()
|
||||
"expected did:key, got did:{}",
|
||||
self.method()
|
||||
)));
|
||||
}
|
||||
|
||||
let (_, bytes) = multibase::decode(self.method_id())
|
||||
.map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
let (_, bytes) =
|
||||
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
|
||||
|
||||
if !bytes.starts_with(ED25519_MULTICODEC) {
|
||||
return Err(Error::InvalidDid(
|
||||
@@ -92,8 +93,7 @@ impl Did {
|
||||
.try_into()
|
||||
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
|
||||
|
||||
VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|e| Error::InvalidDid(e.to_string()))
|
||||
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))
|
||||
}
|
||||
|
||||
/// Return the full DID string as a `&str`.
|
||||
@@ -105,7 +105,9 @@ impl Did {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match self.method() {
|
||||
"key" | "web" | "gitlawb" => Ok(()),
|
||||
other => Err(Error::InvalidDid(format!("unsupported DID method: {other}"))),
|
||||
other => Err(Error::InvalidDid(format!(
|
||||
"unsupported DID method: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +123,9 @@ impl FromStr for Did {
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if !s.starts_with("did:") {
|
||||
return Err(Error::InvalidDid(format!("'{s}' does not start with 'did:'")));
|
||||
return Err(Error::InvalidDid(format!(
|
||||
"'{s}' does not start with 'did:'"
|
||||
)));
|
||||
}
|
||||
let did = Self(s.to_string());
|
||||
did.validate()?;
|
||||
@@ -248,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());
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,14 @@
|
||||
//! Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:z6Mk...";alg="ed25519";created=<unix>
|
||||
//! Signature: sig1=:base64signature:
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use chrono::Utc;
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Error, Result};
|
||||
use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The component identifiers covered by every gitlawb signature.
|
||||
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
|
||||
@@ -53,16 +53,16 @@ impl HttpSignature {
|
||||
let sig_input = sig_input.trim();
|
||||
|
||||
// Expect: sig1=("@method" "@path" "content-digest");keyid="...";alg="...";created=...
|
||||
let rest = sig_input
|
||||
.strip_prefix("sig1=")
|
||||
.ok_or_else(|| Error::HttpSignature("Signature-Input must start with 'sig1='".into()))?;
|
||||
let rest = sig_input.strip_prefix("sig1=").ok_or_else(|| {
|
||||
Error::HttpSignature("Signature-Input must start with 'sig1='".into())
|
||||
})?;
|
||||
|
||||
let open = rest.find('(').ok_or_else(|| {
|
||||
Error::HttpSignature("missing '(' in Signature-Input".into())
|
||||
})?;
|
||||
let close = rest.find(')').ok_or_else(|| {
|
||||
Error::HttpSignature("missing ')' in Signature-Input".into())
|
||||
})?;
|
||||
let open = rest
|
||||
.find('(')
|
||||
.ok_or_else(|| Error::HttpSignature("missing '(' in Signature-Input".into()))?;
|
||||
let close = rest
|
||||
.find(')')
|
||||
.ok_or_else(|| Error::HttpSignature("missing ')' in Signature-Input".into()))?;
|
||||
|
||||
let components_str = &rest[open + 1..close];
|
||||
let params_str = &rest[close + 1..]; // starts with ';'
|
||||
@@ -98,15 +98,19 @@ impl HttpSignature {
|
||||
.trim()
|
||||
.strip_prefix("sig1=:")
|
||||
.and_then(|s| s.strip_suffix(':'))
|
||||
.ok_or_else(|| {
|
||||
Error::HttpSignature("Signature must be 'sig1=:base64:'".into())
|
||||
})?;
|
||||
.ok_or_else(|| Error::HttpSignature("Signature must be 'sig1=:base64:'".into()))?;
|
||||
|
||||
let signature_bytes = STANDARD
|
||||
.decode(sig_b64)
|
||||
.map_err(|e| Error::HttpSignature(format!("invalid base64 in Signature: {e}")))?;
|
||||
|
||||
Ok(Self { key_id, alg, created, components, signature_bytes })
|
||||
Ok(Self {
|
||||
key_id,
|
||||
alg,
|
||||
created,
|
||||
components,
|
||||
signature_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject if the `created` timestamp is more than 5 minutes from now.
|
||||
@@ -125,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()
|
||||
}
|
||||
@@ -178,12 +182,9 @@ pub fn sign_request(
|
||||
request_values.insert("@path".to_string(), path_and_query.to_string());
|
||||
request_values.insert("content-digest".to_string(), content_digest.clone());
|
||||
|
||||
let signing_string = build_signing_string(
|
||||
COVERED_COMPONENTS,
|
||||
sig_params_value,
|
||||
&request_values,
|
||||
)
|
||||
.expect("required components always present when building");
|
||||
let signing_string =
|
||||
build_signing_string(COVERED_COMPONENTS, sig_params_value, &request_values)
|
||||
.expect("required components always present when building");
|
||||
|
||||
let sig_bytes = keypair.sign(signing_string.as_bytes());
|
||||
let sig_b64 = STANDARD.encode(sig_bytes.to_bytes());
|
||||
@@ -290,10 +291,16 @@ mod tests {
|
||||
request_values.insert("content-digest".to_string(), headers.content_digest.clone());
|
||||
|
||||
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
let signing_string = build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
|
||||
let signing_string =
|
||||
build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
|
||||
|
||||
let vk = sig.key_id.to_verifying_key().unwrap();
|
||||
let sig_b64 = headers.signature.strip_prefix("sig1=:").unwrap().strip_suffix(':').unwrap();
|
||||
let sig_b64 = headers
|
||||
.signature
|
||||
.strip_prefix("sig1=:")
|
||||
.unwrap()
|
||||
.strip_suffix(':')
|
||||
.unwrap();
|
||||
let sig_bytes: [u8; 64] = STANDARD.decode(sig_b64).unwrap().try_into().unwrap();
|
||||
|
||||
assert!(verify(&vk, signing_string.as_bytes(), &sig_bytes).is_ok());
|
||||
@@ -321,7 +328,9 @@ mod tests {
|
||||
let kp = Keypair::generate();
|
||||
let did = kp.did();
|
||||
// created=1 is way in the past — should fail clock skew check
|
||||
let sig_input = format!(r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#);
|
||||
let sig_input = format!(
|
||||
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#
|
||||
);
|
||||
let sig = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap();
|
||||
assert!(sig.check_created().is_err());
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
|
||||
//! `did:key` DID. The private key is stored as PKCS#8 PEM on disk.
|
||||
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey, Signer, Signature};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{Error, Result};
|
||||
use crate::did::Did;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
|
||||
#[derive(Clone)]
|
||||
@@ -47,12 +47,14 @@ impl Keypair {
|
||||
|
||||
/// Sign and return base64url-encoded signature string.
|
||||
pub fn sign_b64(&self, msg: &[u8]) -> String {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig = self.sign(msg);
|
||||
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())
|
||||
}
|
||||
@@ -69,18 +71,13 @@ impl Keypair {
|
||||
/// Load from PEM-encoded PKCS#8 private key string.
|
||||
pub fn from_pem(pem: &str) -> Result<Self> {
|
||||
use pkcs8::DecodePrivateKey;
|
||||
let signing_key = SigningKey::from_pkcs8_pem(pem)
|
||||
.map_err(|e| Error::Key(e.to_string()))?;
|
||||
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|e| Error::Key(e.to_string()))?;
|
||||
Ok(Self { signing_key })
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify an Ed25519 signature.
|
||||
pub fn verify(
|
||||
verifying_key: &VerifyingKey,
|
||||
msg: &[u8],
|
||||
sig_bytes: &[u8; 64],
|
||||
) -> Result<()> {
|
||||
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
|
||||
use ed25519_dalek::Verifier;
|
||||
let sig = Signature::from_bytes(sig_bytes);
|
||||
verifying_key
|
||||
@@ -117,8 +114,8 @@ impl<T: Serialize> Signed<T> {
|
||||
}
|
||||
|
||||
mod sig_b64 {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use serde::{Deserializer, Serializer, Deserialize};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
|
||||
@@ -126,8 +123,12 @@ mod sig_b64 {
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
let bytes = URL_SAFE_NO_PAD.decode(&s).map_err(serde::de::Error::custom)?;
|
||||
bytes.try_into().map_err(|_| serde::de::Error::custom("expected 64 bytes"))
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(&s)
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| serde::de::Error::custom("expected 64 bytes"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,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,10 +1,12 @@
|
||||
pub mod did;
|
||||
pub mod cid;
|
||||
pub mod identity;
|
||||
pub mod http_sig;
|
||||
pub mod ucan;
|
||||
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;
|
||||
pub type Result<T> = std::result::Result<T, 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+308
-21
@@ -12,9 +12,9 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Result};
|
||||
use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A UCAN capability: what resource the token grants access to.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -30,13 +30,31 @@ pub struct Capability {
|
||||
|
||||
impl Capability {
|
||||
pub fn new(with: impl Into<String>, can: impl Into<String>) -> Self {
|
||||
Self { with: with.into(), can: can.into(), constraints: None }
|
||||
Self {
|
||||
with: with.into(),
|
||||
can: can.into(),
|
||||
constraints: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_constraints(mut self, constraints: serde_json::Value) -> Self {
|
||||
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.
|
||||
@@ -128,18 +146,40 @@ 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 base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crate::identity::verify;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
let vk = self.payload.iss.to_verifying_key()?;
|
||||
let signing_bytes = serde_json::to_vec(&self.payload)?;
|
||||
|
||||
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&self.s)
|
||||
let sig_bytes_vec = URL_SAFE_NO_PAD
|
||||
.decode(&self.s)
|
||||
.map_err(|e| Error::Ucan(format!("invalid base64 signature: {e}")))?;
|
||||
|
||||
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
|
||||
let sig_bytes: [u8; 64] = sig_bytes_vec
|
||||
.try_into()
|
||||
.map_err(|_| Error::Ucan("signature must be 64 bytes".to_string()))?;
|
||||
|
||||
verify(&vk, &signing_bytes, &sig_bytes)
|
||||
@@ -147,9 +187,16 @@ 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
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,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}")))?;
|
||||
@@ -222,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()?;
|
||||
}
|
||||
@@ -245,7 +307,8 @@ mod tests {
|
||||
audience.clone(),
|
||||
vec![Capability::new("gitlawb://repos/test/repo", caps::GIT_PUSH)],
|
||||
None,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
ucan.verify_signature().unwrap();
|
||||
assert!(!ucan.is_expired());
|
||||
@@ -255,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();
|
||||
@@ -291,10 +411,12 @@ mod tests {
|
||||
let issuer = Keypair::generate();
|
||||
let audience = Keypair::generate().did();
|
||||
let ucan = Ucan::issue(
|
||||
&issuer, audience,
|
||||
&issuer,
|
||||
audience,
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
None,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
// Root UCAN (no proofs) should verify fine
|
||||
ucan.verify_chain().unwrap();
|
||||
}
|
||||
@@ -307,18 +429,22 @@ mod tests {
|
||||
|
||||
// Alice grants Bob push access
|
||||
let root = Ucan::issue(
|
||||
&alice, bob.did(),
|
||||
&alice,
|
||||
bob.did(),
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
None,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Bob delegates to Charlie (with proof from Alice)
|
||||
let delegated = Ucan::delegate(
|
||||
&bob, charlie.did(),
|
||||
&bob,
|
||||
charlie.did(),
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
None,
|
||||
&root,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Chain should verify: Charlie's token → Bob's proof → Alice signed it
|
||||
delegated.verify_chain().unwrap();
|
||||
@@ -334,18 +460,22 @@ mod tests {
|
||||
|
||||
// Alice grants Bob access
|
||||
let root = Ucan::issue(
|
||||
&alice, bob.did(),
|
||||
&alice,
|
||||
bob.did(),
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
None,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Eve (NOT Bob) tries to delegate using Alice's proof
|
||||
let bad = Ucan::delegate(
|
||||
&eve, charlie.did(),
|
||||
&eve,
|
||||
charlie.did(),
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
None,
|
||||
&root,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should fail: proof audience (Bob) != UCAN issuer (Eve)
|
||||
let err = bad.verify_chain().unwrap_err();
|
||||
@@ -361,20 +491,177 @@ mod tests {
|
||||
// Alice grants Bob access with expiry in the past
|
||||
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||
let root = Ucan::issue(
|
||||
&alice, bob.did(),
|
||||
&alice,
|
||||
bob.did(),
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
Some(exp),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let delegated = Ucan::delegate(
|
||||
&bob, charlie.did(),
|
||||
&bob,
|
||||
charlie.did(),
|
||||
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
|
||||
None,
|
||||
&root,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should fail: the proof is expired
|
||||
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,12 +2,52 @@
|
||||
|
||||
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;
|
||||
|
||||
fn normalize_agent_did(did: &str) -> String {
|
||||
if did.starts_with("did:") {
|
||||
did.to_string()
|
||||
} else {
|
||||
format!("did:key:{did}")
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
return Ok(normalized_did);
|
||||
}
|
||||
|
||||
let requested_key = agent_key_segment(&normalized_did);
|
||||
let matching_agent = state
|
||||
.db
|
||||
.list_agents(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|agent| agent_key_segment(&agent.did).starts_with(requested_key));
|
||||
|
||||
Ok(matching_agent
|
||||
.map(|agent| agent.did)
|
||||
.unwrap_or(normalized_did))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrustResponse {
|
||||
pub did: String,
|
||||
@@ -40,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
|
||||
@@ -48,13 +90,17 @@ pub async fn list_agents(
|
||||
Query(params): Query<AgentListQuery>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let agents = state.db.list_agents(params.capability.as_deref()).await?;
|
||||
let list: Vec<AgentResponse> = agents.into_iter().map(|a| AgentResponse {
|
||||
did: a.did,
|
||||
trust_score: a.trust_score,
|
||||
capabilities: a.capabilities,
|
||||
registered_at: a.registered_at,
|
||||
last_seen: a.last_seen,
|
||||
}).collect();
|
||||
let list: Vec<AgentResponse> = agents
|
||||
.into_iter()
|
||||
.map(|a| AgentResponse {
|
||||
did: a.did,
|
||||
trust_score: a.trust_score,
|
||||
capabilities: a.capabilities,
|
||||
registered_at: a.registered_at,
|
||||
last_seen: a.last_seen,
|
||||
status: a.status,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "agents": list })))
|
||||
}
|
||||
|
||||
@@ -63,15 +109,23 @@ pub async fn show_agent(
|
||||
State(state): State<AppState>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<(StatusCode, Json<AgentResponse>)> {
|
||||
let agent = state.db.get_agent(&did).await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {did} not found")))?;
|
||||
Ok((StatusCode::OK, Json(AgentResponse {
|
||||
did: agent.did,
|
||||
trust_score: agent.trust_score,
|
||||
capabilities: agent.capabilities,
|
||||
registered_at: agent.registered_at,
|
||||
last_seen: agent.last_seen,
|
||||
})))
|
||||
let normalized_did = resolve_agent_did(&state, &did).await?;
|
||||
let agent = state
|
||||
.db
|
||||
.get_agent(&normalized_did)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {normalized_did} not found")))?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(AgentResponse {
|
||||
did: agent.did,
|
||||
trust_score: agent.trust_score,
|
||||
capabilities: agent.capabilities,
|
||||
registered_at: agent.registered_at,
|
||||
last_seen: agent.last_seen,
|
||||
status: agent.status,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/v1/agents/{did}/trust
|
||||
@@ -79,14 +133,126 @@ pub async fn get_trust(
|
||||
State(state): State<AppState>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<Json<TrustResponse>> {
|
||||
let trust_score = state.db.get_trust_score(&did).await?;
|
||||
let push_count = state.db.get_push_count(&did).await?;
|
||||
let normalized_did = resolve_agent_did(&state, &did).await?;
|
||||
let trust_score = state.db.get_trust_score(&normalized_did).await?;
|
||||
let push_count = state.db.get_push_count(&normalized_did).await?;
|
||||
let level = trust_level(trust_score);
|
||||
|
||||
Ok(Json(TrustResponse {
|
||||
did,
|
||||
did: normalized_did,
|
||||
trust_score,
|
||||
push_count,
|
||||
level,
|
||||
}))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/agents/{did}
|
||||
///
|
||||
/// Self-deregistration: the holder of a DID's key marks their own agent
|
||||
/// `revoked`, removing it from discovery (issue #29). Authenticated by the
|
||||
/// rfc9421 signature middleware; a caller may only retire its own DID.
|
||||
pub async fn deregister_agent(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
// Authorize on the verified identity, and act on it too. The path DID is
|
||||
// only an intent check (it must name the caller); the row we revoke is the
|
||||
// authenticated DID itself, never a value derived from the untrusted path.
|
||||
// Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix
|
||||
// resolver act on a different identity than the one just authorized.
|
||||
if !caller_matches_did(&auth.0, &did) {
|
||||
return Err(AppError::BadRequest(
|
||||
"an agent can only deregister itself".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if state.db.revoke_agent(&auth.0).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(AppError::NotFound(format!("agent {} not found", auth.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse};
|
||||
|
||||
#[test]
|
||||
fn caller_matches_own_did_full_form() {
|
||||
assert!(caller_matches_did(
|
||||
"did:key:z6MkExample",
|
||||
"did:key:z6MkExample"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_matches_own_did_short_form() {
|
||||
// Authenticated full DID vs short path form, and vice versa.
|
||||
assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample"));
|
||||
assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_cannot_revoke_a_different_did() {
|
||||
// The core authorization property for issue #29.
|
||||
assert!(!caller_matches_did(
|
||||
"did:key:z6MkAttacker",
|
||||
"did:key:z6MkVictim"
|
||||
));
|
||||
assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_response_surfaces_status() {
|
||||
// A revoked DID's response must carry its status so callers can see it
|
||||
// is retired (issue #29).
|
||||
let resp = AgentResponse {
|
||||
did: "did:key:orphan".to_string(),
|
||||
trust_score: 0.1,
|
||||
capabilities: vec!["reputation:score".to_string()],
|
||||
registered_at: "2026-06-19T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
status: "revoked".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
|
||||
assert_eq!(json["status"], "revoked");
|
||||
assert!(json.get("replaced_by").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_response_active_status() {
|
||||
let resp = AgentResponse {
|
||||
did: "did:key:active".to_string(),
|
||||
trust_score: 0.5,
|
||||
capabilities: vec![],
|
||||
registered_at: "2026-06-19T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
status: "active".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
|
||||
assert_eq!(json["status"], "active");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_agent_did_preserves_full_did() {
|
||||
let did = "did:key:z6MkExample";
|
||||
|
||||
assert_eq!(normalize_agent_did(did), did);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_agent_did_expands_bare_key() {
|
||||
assert_eq!(normalize_agent_did("z6MkExample"), "did:key:z6MkExample");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_key_segment_extracts_did_key_material() {
|
||||
assert_eq!(agent_key_segment("did:key:z6MkExample"), "z6MkExample");
|
||||
assert_eq!(agent_key_segment("z6MkExample"), "z6MkExample");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub async fn list_anchors(
|
||||
.db
|
||||
.list_arweave_anchors(q.repo.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.into()))?;
|
||||
.map_err(crate::error::AppError::Internal)?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
|
||||
@@ -79,9 +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 {
|
||||
@@ -110,18 +110,30 @@ 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 bounties = state.db.list_bounties(
|
||||
Some(&owner),
|
||||
Some(&repo),
|
||||
q.status.as_deref(),
|
||||
q.limit.unwrap_or(50),
|
||||
).await?;
|
||||
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(),
|
||||
limit,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "bounties": bounties })))
|
||||
}
|
||||
@@ -130,24 +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))
|
||||
}
|
||||
|
||||
@@ -158,19 +238,42 @@ 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!("bounty is {}, not open", bounty.status)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"bounty is {}, not open",
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
state.db.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now).await?;
|
||||
state
|
||||
.db
|
||||
.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now)
|
||||
.await?;
|
||||
|
||||
tracing::info!(bounty_id = %id, agent = %auth.0, "bounty claimed");
|
||||
|
||||
let updated = state.db.get_bounty(&id).await?
|
||||
let updated = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
@@ -182,14 +285,24 @@ pub async fn submit_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SubmitBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state.db.get_bounty(&id).await?
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "claimed" {
|
||||
return Err(AppError::BadRequest(format!("bounty is {}, not claimed", bounty.status)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"bounty is {}, not claimed",
|
||||
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();
|
||||
@@ -197,7 +310,10 @@ pub async fn submit_bounty(
|
||||
|
||||
tracing::info!(bounty_id = %id, pr_id = %req.pr_id, "bounty submission");
|
||||
|
||||
let updated = state.db.get_bounty(&id).await?
|
||||
let updated = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
@@ -209,18 +325,29 @@ pub async fn approve_bounty(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ApproveBountyRequest>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state.db.get_bounty(&id).await?
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "submitted" {
|
||||
return Err(AppError::BadRequest(format!("bounty is {}, not submitted", bounty.status)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"bounty is {}, not submitted",
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest("only the bounty creator can approve".into()));
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the bounty creator can approve".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
state.db.approve_bounty(&id, &now, req.tx_hash.as_deref()).await?;
|
||||
state
|
||||
.db
|
||||
.approve_bounty(&id, &now, req.tx_hash.as_deref())
|
||||
.await?;
|
||||
|
||||
// Bump claimant trust score
|
||||
if let Some(ref agent_did) = bounty.claimant_did {
|
||||
@@ -231,7 +358,10 @@ pub async fn approve_bounty(
|
||||
|
||||
tracing::info!(bounty_id = %id, "bounty approved");
|
||||
|
||||
let updated = state.db.get_bounty(&id).await?
|
||||
let updated = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
@@ -242,20 +372,31 @@ pub async fn cancel_bounty(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<BountyRecord>> {
|
||||
let bounty = state.db.get_bounty(&id).await?
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "open" {
|
||||
return Err(AppError::BadRequest(format!("can only cancel open bounties, status is {}", bounty.status)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"can only cancel open bounties, status is {}",
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
if bounty.creator_did != auth.0 {
|
||||
return Err(AppError::BadRequest("only the bounty creator can cancel".into()));
|
||||
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the bounty creator can cancel".into(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.cancel_bounty(&id).await?;
|
||||
tracing::info!(bounty_id = %id, "bounty cancelled");
|
||||
|
||||
let updated = state.db.get_bounty(&id).await?
|
||||
let updated = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
@@ -263,13 +404,33 @@ 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.db.get_bounty(&id).await?
|
||||
let bounty = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
|
||||
if bounty.status != "claimed" && bounty.status != "submitted" {
|
||||
return Err(AppError::BadRequest(format!("can only dispute claimed/submitted bounties, status is {}", bounty.status)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"can only dispute claimed/submitted bounties, status is {}",
|
||||
bounty.status
|
||||
)));
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -277,33 +438,47 @@ pub async fn dispute_bounty(
|
||||
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
|
||||
let deadline = claimed + chrono::Duration::seconds(bounty.deadline_secs);
|
||||
if Utc::now() < deadline {
|
||||
return Err(AppError::BadRequest("deadline has not been exceeded yet".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"deadline has not been exceeded yet".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.get_bounty(&id).await?
|
||||
let updated = state
|
||||
.db
|
||||
.get_bounty(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
/// GET /api/v1/bounties/stats
|
||||
pub async fn bounty_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<BountyStatsResponse>> {
|
||||
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
|
||||
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
|
||||
let claimed = state.db.count_bounties_by_status("claimed").await.unwrap_or(0);
|
||||
let completed = state.db.count_bounties_by_status("completed").await.unwrap_or(0);
|
||||
let claimed = state
|
||||
.db
|
||||
.count_bounties_by_status("claimed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let completed = state
|
||||
.db
|
||||
.count_bounties_by_status("completed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
|
||||
let leaderboard = leaders.into_iter().map(|(did, cnt, total)| AgentBountyEntry {
|
||||
did,
|
||||
completed: cnt,
|
||||
total_earned: total,
|
||||
}).collect();
|
||||
let leaderboard = leaders
|
||||
.into_iter()
|
||||
.map(|(did, cnt, total)| AgentBountyEntry {
|
||||
did,
|
||||
completed: cnt,
|
||||
total_earned: total,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BountyStatsResponse {
|
||||
open,
|
||||
|
||||
@@ -1,47 +1,87 @@
|
||||
//! 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 certs_json: Vec<serde_json::Value> = certs.iter().map(|c| serde_json::json!({
|
||||
"id": c.id,
|
||||
"repo_id": c.repo_id,
|
||||
"ref_name": c.ref_name,
|
||||
"old_sha": c.old_sha,
|
||||
"new_sha": c.new_sha,
|
||||
"pusher_did": c.pusher_did,
|
||||
"node_did": c.node_did,
|
||||
"signature": c.signature,
|
||||
"issued_at": c.issued_at,
|
||||
})).collect();
|
||||
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?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "certificates": certs_json })))
|
||||
// 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| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"repo_id": c.repo_id,
|
||||
"ref_name": c.ref_name,
|
||||
"old_sha": c.old_sha,
|
||||
"new_sha": c.new_sha,
|
||||
"pusher_did": c.pusher_did,
|
||||
"node_did": c.node_did,
|
||||
"signature": c.signature,
|
||||
"issued_at": c.issued_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
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.get_ref_certificate(&id).await?
|
||||
let cert = state
|
||||
.db
|
||||
.get_ref_certificate(&id)
|
||||
.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;
|
||||
@@ -14,7 +15,9 @@ pub struct ChangelogQuery {
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize { 20 }
|
||||
fn default_limit() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/changelog[?limit=N]
|
||||
///
|
||||
@@ -25,28 +28,36 @@ 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);
|
||||
|
||||
// ── Commits from git log ─────────────────────────────────────────────
|
||||
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
|
||||
let commits = store::log(&disk_path, &head_ref, limit).unwrap_or_default();
|
||||
|
||||
let mut events: Vec<serde_json::Value> = commits.into_iter().map(|c| {
|
||||
serde_json::json!({
|
||||
"type": "commit",
|
||||
"sha": c.hash,
|
||||
"message": c.subject,
|
||||
"author": c.author_name,
|
||||
"timestamp": c.timestamp,
|
||||
"branch": record.default_branch,
|
||||
let mut events: Vec<serde_json::Value> = commits
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"type": "commit",
|
||||
"sha": c.hash,
|
||||
"message": c.subject,
|
||||
"author": c.author_name,
|
||||
"timestamp": c.timestamp,
|
||||
"branch": record.default_branch,
|
||||
})
|
||||
})
|
||||
}).collect();
|
||||
.collect();
|
||||
|
||||
// ── Merged PRs ───────────────────────────────────────────────────────
|
||||
let prs = state.db.list_prs(&record.id).await.unwrap_or_default();
|
||||
|
||||
@@ -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,46 +73,136 @@ 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
|
||||
.db
|
||||
.list_all_repos().await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
.list_all_repos()
|
||||
.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
|
||||
@@ -99,13 +216,12 @@ pub async fn get_by_cid(
|
||||
/// Returns all CIDs that have been pinned to the local IPFS node from git
|
||||
/// objects received via push. Each entry includes the git SHA-256 hex, the
|
||||
/// CIDv1 string, and the timestamp when it was pinned.
|
||||
pub async fn list_pins(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
|
||||
let pins = state
|
||||
.db
|
||||
.list_pinned_cids().await
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
.list_pinned_cids()
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"pins": pins,
|
||||
|
||||
@@ -10,8 +10,8 @@ use uuid::Uuid;
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::IssueComment;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
use crate::git::issues as git_issues;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateIssueRequest {
|
||||
@@ -39,8 +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();
|
||||
@@ -58,14 +61,17 @@ pub async fn create_issue(
|
||||
let json_str = serde_json::to_string(&issue)
|
||||
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
|
||||
|
||||
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
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()))?;
|
||||
|
||||
@@ -84,15 +90,20 @@ 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.acquire(&record.owner_did, &record.name).await
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
let raw_issues = git_issues::list_issues(&disk_path)
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let raw_issues =
|
||||
git_issues::list_issues(&disk_path).map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
let mut issues: Vec<serde_json::Value> = Vec::new();
|
||||
for raw in raw_issues {
|
||||
@@ -108,11 +119,16 @@ 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.acquire(&record.owner_did, &record.name).await
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
let raw = git_issues::get_issue(&disk_path, &issue_id)
|
||||
@@ -138,13 +154,19 @@ pub async fn create_issue_comment(
|
||||
Json(req): Json<CreateIssueCommentRequest>,
|
||||
) -> Result<(StatusCode, Json<IssueComment>)> {
|
||||
if req.body.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("comment body must not be empty".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"comment body must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
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.acquire(&record.owner_did, &record.name).await
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
// Verify issue exists
|
||||
crate::git::issues::get_issue(&disk_path, &issue_id)
|
||||
@@ -167,31 +189,84 @@ 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.db.get_repo(&owner, &repo).await?
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.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>)> {
|
||||
@@ -25,40 +25,64 @@ pub async fn add_label(
|
||||
if label.is_empty() || label.len() > 50 {
|
||||
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
|
||||
}
|
||||
if !label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == ':') {
|
||||
if !label
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == ':')
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"label must contain only alphanumeric characters, hyphens, and colons".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let record = state.db.get_repo(&owner, &name).await?
|
||||
let record = state
|
||||
.db
|
||||
.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 { StatusCode::CREATED } else { StatusCode::OK };
|
||||
Ok((status, Json(serde_json::json!({ "label": label, "added": added }))))
|
||||
let status = if added {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
Ok((
|
||||
status,
|
||||
Json(serde_json::json!({ "label": label, "added": added })),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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.db.get_repo(&owner, &name).await?
|
||||
let record = state
|
||||
.db
|
||||
.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,18 +1,703 @@
|
||||
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;
|
||||
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" })),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,16 @@ pub async fn protect_branch(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, branch)): Path<(String, String, String)>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state.db.get_repo(&owner, &repo).await?
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.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(),
|
||||
));
|
||||
}
|
||||
@@ -33,11 +35,14 @@ pub async fn protect_branch(
|
||||
|
||||
tracing::info!(repo = %repo, branch = %branch, caller = %caller, "branch protected");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
||||
"status": "protected",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"branch": branch,
|
||||
}))))
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
"status": "protected",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"branch": branch,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/repos/:owner/:repo/branches/:branch/protect
|
||||
@@ -46,13 +51,15 @@ pub async fn unprotect_branch(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, branch)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state.db.get_repo(&owner, &repo).await?
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.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(),
|
||||
));
|
||||
}
|
||||
@@ -72,9 +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?;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::{PullRequest, PrComment, PrReview};
|
||||
use crate::db::{PrComment, PrReview, PullRequest};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::git::store;
|
||||
use crate::state::AppState;
|
||||
@@ -40,11 +40,16 @@ 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.target_branch.unwrap_or_else(|| record.default_branch.clone());
|
||||
let target_branch = req
|
||||
.target_branch
|
||||
.unwrap_or_else(|| record.default_branch.clone());
|
||||
let number = state.db.next_pr_number(&record.id).await?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
@@ -60,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,
|
||||
};
|
||||
@@ -68,7 +74,11 @@ pub async fn create_pr(
|
||||
|
||||
// Bump trust score for the PR author — increment current score by 0.05
|
||||
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only open PRs)
|
||||
let current = state.db.get_trust_score(&pr.author_did).await.unwrap_or(0.05);
|
||||
let current = state
|
||||
.db
|
||||
.get_trust_score(&pr.author_did)
|
||||
.await
|
||||
.unwrap_or(0.05);
|
||||
let new_score = (current + 0.05).min(1.0);
|
||||
let _ = state.db.update_trust_score(&pr.author_did, new_score).await;
|
||||
|
||||
@@ -92,23 +102,32 @@ 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(serde_json::json!({ "pulls": prs, "count": prs.len() })))
|
||||
Ok(Json(
|
||||
serde_json::json!({ "pulls": prs, "count": prs.len() }),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/pulls/:number
|
||||
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.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
Ok(Json(pr))
|
||||
@@ -118,15 +137,42 @@ 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.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.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()))?;
|
||||
|
||||
@@ -143,28 +189,64 @@ pub async fn merge_pr(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state.db.get_repo(&owner, &name).await?
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let pr = state.db.get_pr(&record.id, number).await?
|
||||
// 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)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
if pr.status != "open" {
|
||||
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
|
||||
}
|
||||
|
||||
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
let merger_did = auth.0;
|
||||
let merge_result = store::merge_branch(&disk_path, &pr.target_branch, &pr.source_branch, &merger_did, &pr.title);
|
||||
|
||||
// Always release the advisory lock — even on error
|
||||
guard.release().await;
|
||||
// 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,
|
||||
&pr.source_branch,
|
||||
&merger_did,
|
||||
&pr.title,
|
||||
);
|
||||
|
||||
// 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(
|
||||
@@ -192,14 +274,30 @@ 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.db.get_repo(&owner, &name).await?
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
|
||||
let pr = state.db.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.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(
|
||||
@@ -224,15 +322,21 @@ 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.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
let valid_statuses = ["approved", "changes_requested", "comment"];
|
||||
if !valid_statuses.contains(&req.status.as_str()) {
|
||||
return Err(AppError::BadRequest("status must be approved, changes_requested, or comment".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"status must be approved, changes_requested, or comment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let review = PrReview {
|
||||
@@ -266,11 +370,16 @@ 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.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
let reviews = state.db.list_pr_reviews(&pr.id).await?;
|
||||
@@ -285,13 +394,19 @@ pub async fn create_comment(
|
||||
Json(req): Json<CreateCommentRequest>,
|
||||
) -> Result<(StatusCode, Json<PrComment>)> {
|
||||
if req.body.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("comment body must not be empty".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"comment body must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
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.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
let comment = PrComment {
|
||||
@@ -311,13 +426,174 @@ 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.get_pr(&record.id, number).await?
|
||||
let pr = state
|
||||
.db
|
||||
.get_pr(&record.id, number)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use gitlawb_core::did::Did;
|
||||
use gitlawb_core::ucan::Ucan;
|
||||
@@ -35,38 +35,66 @@ 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()
|
||||
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.register_agent(agent_did.as_str(), &req.capabilities).await?;
|
||||
state
|
||||
.db
|
||||
.register_agent(agent_did.as_str(), &req.capabilities)
|
||||
.await?;
|
||||
|
||||
// Grant a small baseline trust score on first registration (verified via HTTP Signature).
|
||||
// Score grows further with pushes, PRs, and issue activity.
|
||||
let initial_trust = 0.05;
|
||||
let _ = state.db.update_trust_score(agent_did.as_str(), initial_trust).await;
|
||||
let _ = state
|
||||
.db
|
||||
.update_trust_score(agent_did.as_str(), initial_trust)
|
||||
.await;
|
||||
|
||||
// Issue a bootstrap UCAN from the node's identity
|
||||
let ucan = Ucan::bootstrap(&state.node_keypair, agent_did.clone())
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
let exp = Utc::now() + chrono::Duration::days(30);
|
||||
let ucan_encoded = ucan.encode()
|
||||
.map_err(|e| AppError::Internal(e.into()))?;
|
||||
let ucan_encoded = ucan.encode().map_err(|e| AppError::Internal(e.into()))?;
|
||||
|
||||
tracing::info!(did = %agent_did, "registered new agent");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(RegisterResponse {
|
||||
status: "accepted".to_string(),
|
||||
did: agent_did.to_string(),
|
||||
ucan: ucan_encoded,
|
||||
node: format!("{}:{}", state.config.host, state.config.port),
|
||||
expires: exp.to_rfc3339(),
|
||||
trust_score: initial_trust,
|
||||
capabilities: req.capabilities,
|
||||
message: "welcome to the network, agent.".to_string(),
|
||||
})))
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
status: "accepted".to_string(),
|
||||
did: agent_did.to_string(),
|
||||
ucan: ucan_encoded,
|
||||
node: format!("{}:{}", state.config.host, state.config.port),
|
||||
expires: exp.to_rfc3339(),
|
||||
trust_score: initial_trust,
|
||||
capabilities: req.capabilities,
|
||||
message: "welcome to the network, agent.".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Repo replica registration API.
|
||||
//!
|
||||
//! Lets a node tell the origin "I'm hosting a replica of your repo." The
|
||||
//! origin records the (replica DID, replica URL) pair and exposes the list
|
||||
//! publicly so anyone can see how many nodes are mirroring a given repo.
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! - `PUT /api/v1/repos/:owner/:repo/replicas` (auth) register
|
||||
//! - `DELETE /api/v1/repos/:owner/:repo/replicas` (auth) unregister
|
||||
//! - `GET /api/v1/repos/:owner/:repo/replicas` (public) list
|
||||
//!
|
||||
//! Auth model: the caller's DID (verified via HTTP Signatures) is the
|
||||
//! replica's identity. There's no separate "claim this URL" check — replicas
|
||||
//! self-report their public URL. Operators viewing the list should treat
|
||||
//! replica URLs as advisory until they actually reach out and verify.
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterReplicaRequest {
|
||||
/// Publicly reachable URL of the replica node (e.g. `https://my-node.example.com`).
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// PUT /api/v1/repos/:owner/:repo/replicas
|
||||
/// Idempotent — first registration returns 201, subsequent ones update the URL and return 200.
|
||||
pub async fn register_replica(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<RegisterReplicaRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
validate_replica_url(&req.url)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let replica_did = &auth.0;
|
||||
|
||||
// Don't let an owner register themselves as a replica of their own repo
|
||||
// (did_matches handles the full vs bare did:key owner form).
|
||||
if crate::api::did_matches(replica_did, &record.owner_did) {
|
||||
return Err(AppError::BadRequest(
|
||||
"the repo owner is not a replica of their own repo".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let inserted = state
|
||||
.db
|
||||
.register_replica(&record.id, replica_did, &req.url)
|
||||
.await?;
|
||||
let count = state.db.count_replicas(&record.id).await?;
|
||||
|
||||
let status = if inserted {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
repo = %repo,
|
||||
replica = %replica_did,
|
||||
url = %req.url,
|
||||
"replica registered"
|
||||
);
|
||||
|
||||
Ok((
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"status": "registered",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"replica_did": replica_did,
|
||||
"replica_url": req.url,
|
||||
"replica_count": count,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/repos/:owner/:repo/replicas
|
||||
/// Idempotent — no error if the caller wasn't registered.
|
||||
pub async fn unregister_replica(
|
||||
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}")))?;
|
||||
|
||||
let replica_did = &auth.0;
|
||||
state.db.unregister_replica(&record.id, replica_did).await?;
|
||||
let count = state.db.count_replicas(&record.id).await?;
|
||||
|
||||
tracing::info!(repo = %repo, replica = %replica_did, "replica unregistered");
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "unregistered",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"replica_count": count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/:owner/:repo/replicas
|
||||
/// 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 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?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"replica_count": replicas.len(),
|
||||
"replicas": replicas,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Basic URL hygiene — must be http(s), parseable, length-bounded.
|
||||
fn validate_replica_url(url: &str) -> Result<()> {
|
||||
if url.is_empty() {
|
||||
return Err(AppError::BadRequest("replica url is empty".into()));
|
||||
}
|
||||
if url.len() > 512 {
|
||||
return Err(AppError::BadRequest("replica url exceeds 512 chars".into()));
|
||||
}
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return Err(AppError::BadRequest(
|
||||
"replica url must start with http:// or https://".into(),
|
||||
));
|
||||
}
|
||||
// No spaces / control chars / newlines.
|
||||
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"replica url contains whitespace or control characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_accepts_normal_https() {
|
||||
validate_replica_url("https://node.example.com").unwrap();
|
||||
validate_replica_url("https://my-node.example.com:7545").unwrap();
|
||||
validate_replica_url("http://localhost:7545").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_rejects_empty() {
|
||||
assert!(validate_replica_url("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_rejects_non_http_scheme() {
|
||||
for bad in [
|
||||
"ftp://host",
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"/path",
|
||||
] {
|
||||
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_rejects_whitespace_and_control() {
|
||||
for bad in [
|
||||
"https://host .com",
|
||||
"https://host\n.com",
|
||||
"https://host\t.com",
|
||||
"https://host\0evil.com",
|
||||
] {
|
||||
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_rejects_overlong() {
|
||||
let long = format!("https://{}.com", "a".repeat(600));
|
||||
assert!(validate_replica_url(&long).is_err());
|
||||
}
|
||||
}
|
||||
+3352
-269
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,22 +19,29 @@ 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?;
|
||||
let count = state.db.count_stars(&record.id).await?;
|
||||
|
||||
let status = if inserted { StatusCode::CREATED } else { StatusCode::OK };
|
||||
let status = if inserted {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
|
||||
tracing::info!(repo = %repo, caller = %caller, "repo starred");
|
||||
|
||||
Ok((status, Json(serde_json::json!({
|
||||
"status": "starred",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"star_count": count,
|
||||
}))))
|
||||
Ok((
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"status": "starred",
|
||||
"repo": format!("{owner}/{repo}"),
|
||||
"star_count": count,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/repos/:owner/:repo/star
|
||||
@@ -44,8 +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?;
|
||||
@@ -61,13 +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;
|
||||
@@ -33,12 +33,22 @@ pub async fn create_webhook(
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<CreateWebhookRequest>,
|
||||
) -> Result<(StatusCode, Json<Webhook>)> {
|
||||
let record = state.db.get_repo(&owner, &name).await?
|
||||
let record = state
|
||||
.db
|
||||
.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://") {
|
||||
return Err(AppError::BadRequest("webhook URL must be http:// or https://".into()));
|
||||
// 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 a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
|
||||
@@ -63,30 +73,62 @@ 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
|
||||
let redacted: Vec<_> = hooks.into_iter().map(|mut h| {
|
||||
if h.secret.is_some() { h.secret = Some("***".into()); }
|
||||
h
|
||||
}).collect();
|
||||
let redacted: Vec<_> = hooks
|
||||
.into_iter()
|
||||
.map(|mut h| {
|
||||
if h.secret.is_some() {
|
||||
h.secret = Some("***".into());
|
||||
}
|
||||
h
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "webhooks": redacted, "count": redacted.len() })))
|
||||
Ok(Json(
|
||||
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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.db.get_repo(&owner, &name).await?
|
||||
let record = state
|
||||
.db
|
||||
.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.db.get_webhook(&id).await?
|
||||
let hook = state
|
||||
.db
|
||||
.get_webhook(&id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("webhook {id} not found")))?;
|
||||
|
||||
if hook.repo_id != record.id {
|
||||
@@ -96,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/"));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
/// Data describing a ref-update event to be anchored.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefAnchor {
|
||||
@@ -104,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}")
|
||||
@@ -112,9 +213,9 @@ pub fn arweave_url(tx_id: &str) -> String {
|
||||
/// Build the Irys tag header value for Arweave indexing.
|
||||
/// Format: comma-separated "name:value" pairs.
|
||||
fn build_tags_header(anchor: &RefAnchor) -> String {
|
||||
vec![
|
||||
format!("App-Name:gitlawb"),
|
||||
format!("Schema:gitlawb/ref-update/v1"),
|
||||
[
|
||||
"App-Name:gitlawb".to_string(),
|
||||
"Schema:gitlawb/ref-update/v1".to_string(),
|
||||
format!("Repo:{}", sanitize_tag(&anchor.repo)),
|
||||
format!("Ref:{}", sanitize_tag(&anchor.ref_name)),
|
||||
format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]),
|
||||
@@ -142,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(),
|
||||
@@ -169,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(),
|
||||
@@ -178,14 +279,131 @@ mod tests {
|
||||
|
||||
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
|
||||
assert!(result.is_ok(), "anchor should succeed: {result:?}");
|
||||
assert_eq!(result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
|
||||
);
|
||||
_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");
|
||||
assert_eq!(url, "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -34,16 +98,17 @@ use gitlawb_core::identity::verify;
|
||||
pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
// Buffer the body so we can verify content-digest and pass it downstream
|
||||
let (parts, body) = request.into_parts();
|
||||
let body_bytes = match body.collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => {
|
||||
return (
|
||||
let body_bytes =
|
||||
match body.collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "unreadable_body", "message": "could not read request body" })),
|
||||
Json(
|
||||
json!({ "error": "unreadable_body", "message": "could not read request body" }),
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
.into_response(),
|
||||
};
|
||||
|
||||
let sig_input = parts
|
||||
.headers
|
||||
@@ -61,8 +126,9 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
(Some(i), Some(s)) => (i, s),
|
||||
_ => {
|
||||
return human_detected(
|
||||
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures"
|
||||
).into_response();
|
||||
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,22 +222,21 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
request_values.insert("content-digest".to_string(), content_digest);
|
||||
|
||||
// The @signature-params value is the part of Signature-Input after "sig1="
|
||||
let sig_params_value = sig_input
|
||||
.strip_prefix("sig1=")
|
||||
.unwrap_or(&sig_input);
|
||||
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input);
|
||||
|
||||
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
|
||||
|
||||
let signing_string = match build_signing_string(&components_ref, sig_params_value, &request_values) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let signing_string =
|
||||
match build_signing_string(&components_ref, sig_params_value, &request_values) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// Verify Ed25519 signature
|
||||
let sig_array: [u8; 64] = match sig.signature_bytes.as_slice().try_into() {
|
||||
@@ -200,7 +265,11 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
|
||||
}
|
||||
|
||||
// Verify Content-Digest matches the actual request body
|
||||
if let Some(claimed) = parts.headers.get("content-digest").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(claimed) = parts
|
||||
.headers
|
||||
.get("content-digest")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
let actual = compute_content_digest(&body_bytes);
|
||||
if claimed != actual {
|
||||
return (
|
||||
@@ -216,8 +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()));
|
||||
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
|
||||
}
|
||||
|
||||
@@ -239,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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Embedded seed list of public Gitlawb network nodes.
|
||||
//!
|
||||
//! This module parses `bootstrap-peers.json` (embedded at compile time) and
|
||||
//! merges its contents into the runtime config so a fresh `docker compose up`
|
||||
//! joins the network without any manual peer configuration.
|
||||
//!
|
||||
//! Operators can opt out by setting `GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true` in
|
||||
//! their environment — useful for isolated dev networks or testing.
|
||||
//!
|
||||
//! Add a node to the canonical list via PR to `bootstrap-peers.json`.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use libp2p_core::Multiaddr;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const EMBEDDED_PEERS_JSON: &str = include_str!("../../../bootstrap-peers.json");
|
||||
const SUPPORTED_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BootstrapList {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
peers: Vec<BootstrapPeer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BootstrapPeer {
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
operator: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
did: Option<String>,
|
||||
http_url: Option<String>,
|
||||
p2p_multiaddr: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
added: Option<String>,
|
||||
}
|
||||
|
||||
/// Counts of newly-added entries returned by `merge_into_vecs`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct MergeCounts {
|
||||
http: usize,
|
||||
p2p: usize,
|
||||
}
|
||||
|
||||
/// Returns true when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value.
|
||||
fn seeds_disabled() -> bool {
|
||||
std::env::var("GITLAWB_BOOTSTRAP_DISABLE_SEEDS")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Parse the seed list from a JSON string, rejecting unsupported versions.
|
||||
fn parse_seed_list(json: &str) -> Result<BootstrapList, String> {
|
||||
let list: BootstrapList = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
if list.version != SUPPORTED_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported bootstrap-peers.json version: {} (expected {})",
|
||||
list.version, SUPPORTED_VERSION
|
||||
));
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Pure merge: appends entries from `list` to the two vectors, deduping.
|
||||
/// Returns counts of entries actually added (i.e. not already present).
|
||||
fn merge_into_vecs(
|
||||
list: BootstrapList,
|
||||
http_peers: &mut Vec<String>,
|
||||
p2p_bootstrap: &mut Vec<String>,
|
||||
) -> MergeCounts {
|
||||
let mut counts = MergeCounts::default();
|
||||
|
||||
for peer in list.peers {
|
||||
if let Some(url) = peer
|
||||
.http_url
|
||||
.as_ref()
|
||||
.filter(|u| !u.is_empty() && !http_peers.contains(u))
|
||||
{
|
||||
http_peers.push(url.clone());
|
||||
counts.http += 1;
|
||||
}
|
||||
|
||||
if let Some(addr_str) = peer.p2p_multiaddr.as_ref().filter(|s| !s.is_empty()) {
|
||||
match Multiaddr::from_str(addr_str) {
|
||||
Ok(_) => {
|
||||
if !p2p_bootstrap.contains(addr_str) {
|
||||
p2p_bootstrap.push(addr_str.clone());
|
||||
counts.p2p += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(
|
||||
name = %peer.name,
|
||||
addr = %addr_str,
|
||||
err = %e,
|
||||
"invalid p2p_multiaddr in bootstrap-peers.json — skipping"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
|
||||
/// Merge the embedded seed list into the runtime config.
|
||||
///
|
||||
/// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
|
||||
/// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
|
||||
/// - Dedupes against entries already present (env / CLI takes precedence)
|
||||
/// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
|
||||
pub fn merge_seeds(config: &mut Config) {
|
||||
if seeds_disabled() {
|
||||
info!("bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS");
|
||||
return;
|
||||
}
|
||||
|
||||
let list = match parse_seed_list(EMBEDDED_PEERS_JSON) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
warn!(err = %e, "failed to load embedded bootstrap-peers.json — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let counts = merge_into_vecs(list, &mut config.bootstrap_peers, &mut config.p2p_bootstrap);
|
||||
|
||||
if counts.http > 0 || counts.p2p > 0 {
|
||||
info!(
|
||||
http_peers = counts.http,
|
||||
p2p_peers = counts.p2p,
|
||||
"merged bootstrap seed list into config"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_valid_v1_list() {
|
||||
let json = r#"{
|
||||
"version": 1,
|
||||
"updated": "2026-04-29",
|
||||
"peers": [
|
||||
{
|
||||
"name": "alpha",
|
||||
"operator": "Alice",
|
||||
"did": "did:key:z6MkAlice",
|
||||
"http_url": "https://alpha.example.com",
|
||||
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546",
|
||||
"added": "2026-04-29"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let list = parse_seed_list(json).expect("should parse");
|
||||
assert_eq!(list.version, 1);
|
||||
assert_eq!(list.peers.len(), 1);
|
||||
assert_eq!(list.peers[0].name, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unknown_version() {
|
||||
let json = r#"{ "version": 99, "peers": [] }"#;
|
||||
let err = parse_seed_list(json).expect_err("should reject");
|
||||
assert!(err.contains("unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_malformed_json() {
|
||||
let err = parse_seed_list("{ not json").expect_err("should reject");
|
||||
assert!(!err.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_empty_peers_array() {
|
||||
let json = r#"{ "version": 1, "peers": [] }"#;
|
||||
let list = parse_seed_list(json).expect("should parse");
|
||||
assert!(list.peers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_treats_missing_peers_as_empty() {
|
||||
let json = r#"{ "version": 1 }"#;
|
||||
let list = parse_seed_list(json).expect("should parse");
|
||||
assert!(list.peers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_new_http_and_p2p() {
|
||||
let list = parse_seed_list(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"peers": [
|
||||
{
|
||||
"name": "alpha",
|
||||
"http_url": "https://alpha.example.com",
|
||||
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut http = Vec::new();
|
||||
let mut p2p = Vec::new();
|
||||
let counts = merge_into_vecs(list, &mut http, &mut p2p);
|
||||
|
||||
assert_eq!(counts, MergeCounts { http: 1, p2p: 1 });
|
||||
assert_eq!(http, vec!["https://alpha.example.com"]);
|
||||
assert_eq!(p2p, vec!["/ip4/1.2.3.4/tcp/7546"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_dedupes_existing_entries() {
|
||||
let list = parse_seed_list(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"peers": [
|
||||
{ "name": "alpha", "http_url": "https://alpha.example.com" }
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut http = vec!["https://alpha.example.com".to_string()];
|
||||
let mut p2p = Vec::new();
|
||||
let counts = merge_into_vecs(list, &mut http, &mut p2p);
|
||||
|
||||
assert_eq!(counts.http, 0, "should not double-add");
|
||||
assert_eq!(http.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_skips_invalid_p2p_multiaddr() {
|
||||
let list = parse_seed_list(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"peers": [
|
||||
{
|
||||
"name": "bad",
|
||||
"http_url": "https://bad.example.com",
|
||||
"p2p_multiaddr": "this is not a multiaddr"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut http = Vec::new();
|
||||
let mut p2p = Vec::new();
|
||||
let counts = merge_into_vecs(list, &mut http, &mut p2p);
|
||||
|
||||
assert_eq!(counts.http, 1, "http still added");
|
||||
assert_eq!(counts.p2p, 0, "invalid p2p skipped");
|
||||
assert!(p2p.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_skips_empty_strings() {
|
||||
let list = parse_seed_list(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"peers": [
|
||||
{ "name": "blank", "http_url": "", "p2p_multiaddr": "" }
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut http = Vec::new();
|
||||
let mut p2p = Vec::new();
|
||||
let counts = merge_into_vecs(list, &mut http, &mut p2p);
|
||||
|
||||
assert_eq!(counts, MergeCounts::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_handles_null_optional_fields() {
|
||||
let list = parse_seed_list(
|
||||
r#"{
|
||||
"version": 1,
|
||||
"peers": [
|
||||
{
|
||||
"name": "alpha",
|
||||
"operator": null,
|
||||
"did": null,
|
||||
"http_url": "https://alpha.example.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": null
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut http = Vec::new();
|
||||
let mut p2p = Vec::new();
|
||||
let counts = merge_into_vecs(list, &mut http, &mut p2p);
|
||||
|
||||
assert_eq!(counts, MergeCounts { http: 1, p2p: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_seed_list_parses_successfully() {
|
||||
// Regression: the canonical bootstrap-peers.json shipped in the repo
|
||||
// must always be valid, since it's compiled into the binary.
|
||||
parse_seed_list(EMBEDDED_PEERS_JSON)
|
||||
.expect("embedded bootstrap-peers.json must always parse");
|
||||
}
|
||||
}
|
||||
@@ -41,17 +41,18 @@ pub async fn issue_ref_certificate(
|
||||
let signature = state.node_keypair.sign_b64(&payload_bytes);
|
||||
|
||||
let cert = RefCertificate {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo_id.to_string(),
|
||||
ref_name: ref_name.to_string(),
|
||||
old_sha: old_sha.to_string(),
|
||||
new_sha: new_sha.to_string(),
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: repo_id.to_string(),
|
||||
ref_name: ref_name.to_string(),
|
||||
old_sha: old_sha.to_string(),
|
||||
new_sha: new_sha.to_string(),
|
||||
pusher_did: pusher_did.to_string(),
|
||||
node_did,
|
||||
signature,
|
||||
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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
|
||||
@@ -9,7 +9,11 @@ pub struct Config {
|
||||
pub repos_dir: PathBuf,
|
||||
|
||||
/// PostgreSQL connection URL (Supabase or any Postgres instance)
|
||||
#[arg(long, env = "DATABASE_URL", default_value = "postgresql://localhost/gitlawb")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "DATABASE_URL",
|
||||
default_value = "postgresql://localhost/gitlawb"
|
||||
)]
|
||||
pub database_url: String,
|
||||
|
||||
/// Host to bind to
|
||||
@@ -24,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,
|
||||
|
||||
@@ -36,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,
|
||||
@@ -45,15 +66,19 @@ pub struct Config {
|
||||
pub pinata_jwt: String,
|
||||
|
||||
/// Pinata v3 upload URL
|
||||
#[arg(long, env = "GITLAWB_PINATA_UPLOAD_URL", default_value = "https://uploads.pinata.cloud/v3/files")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_PINATA_UPLOAD_URL",
|
||||
default_value = "https://uploads.pinata.cloud/v3/files"
|
||||
)]
|
||||
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>,
|
||||
|
||||
@@ -75,7 +100,11 @@ pub struct Config {
|
||||
pub contract_name_registry: String,
|
||||
|
||||
/// Base L2 RPC URL
|
||||
#[arg(long, env = "GITLAWB_CHAIN_RPC_URL", default_value = "https://sepolia.base.org")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_CHAIN_RPC_URL",
|
||||
default_value = "https://sepolia.base.org"
|
||||
)]
|
||||
pub chain_rpc_url: String,
|
||||
|
||||
/// Base L2 node staking contract address (GitlawbNodeStaking). When set
|
||||
@@ -110,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 {
|
||||
@@ -127,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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+6642
-273
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,
|
||||
@@ -49,36 +126,28 @@ impl IntoResponse for AppError {
|
||||
"repo_exists",
|
||||
format!("repository '{r}' already exists"),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
AppError::BadRequest(msg) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"bad_request",
|
||||
msg.clone(),
|
||||
),
|
||||
AppError::Git(msg) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"git_error",
|
||||
msg.clone(),
|
||||
),
|
||||
AppError::Db(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"db_error",
|
||||
e.to_string(),
|
||||
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,
|
||||
"internal_error",
|
||||
@@ -96,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,19 +23,26 @@ 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);
|
||||
anyhow::bail!("git hash-object failed: {stderr}");
|
||||
}
|
||||
|
||||
let hash = String::from_utf8(output.stdout)
|
||||
.context("git hash-object output is not valid UTF-8")?;
|
||||
let hash =
|
||||
String::from_utf8(output.stdout).context("git hash-object output is not valid UTF-8")?;
|
||||
let hash = hash.trim();
|
||||
|
||||
// Update the ref
|
||||
@@ -58,7 +65,11 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
|
||||
// List all refs under refs/gitlawb/issues/
|
||||
let list_output = Command::new("git")
|
||||
.args(["for-each-ref", "--format=%(refname)", "refs/gitlawb/issues/"])
|
||||
.args([
|
||||
"for-each-ref",
|
||||
"--format=%(refname)",
|
||||
"refs/gitlawb/issues/",
|
||||
])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.context("failed to run git for-each-ref")?;
|
||||
@@ -121,10 +132,7 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
|
||||
}
|
||||
|
||||
let output = String::from_utf8_lossy(&list.stdout);
|
||||
let matches: Vec<&str> = output
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.collect();
|
||||
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => Ok(None),
|
||||
@@ -156,8 +164,8 @@ pub fn close_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
|
||||
let raw = get_issue(repo_path, &full_id)?
|
||||
.expect("ref existed in resolve but not in get — should be impossible");
|
||||
|
||||
let mut issue: serde_json::Value = serde_json::from_str(&raw)
|
||||
.context("invalid issue JSON in git ref")?;
|
||||
let mut issue: serde_json::Value =
|
||||
serde_json::from_str(&raw).context("invalid issue JSON in git ref")?;
|
||||
issue["status"] = serde_json::Value::String("closed".to_string());
|
||||
|
||||
let updated = serde_json::to_string(&issue).context("failed to serialize updated issue")?;
|
||||
@@ -193,15 +201,6 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_bare_repo(dir: &TempDir) {
|
||||
Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
// git hash-object -w needs a non-bare repo; use a working repo instead
|
||||
}
|
||||
|
||||
fn init_repo(dir: &TempDir) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
@@ -225,7 +224,12 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "abc12345-0000-0000-0000-000000000000";
|
||||
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_issue_id(dir.path(), full_id).unwrap();
|
||||
assert_eq!(resolved, Some(full_id.to_string()));
|
||||
}
|
||||
@@ -235,7 +239,12 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "abc12345-0000-0000-0000-000000000000";
|
||||
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
|
||||
assert_eq!(resolved, Some(full_id.to_string()));
|
||||
}
|
||||
@@ -252,8 +261,18 @@ mod tests {
|
||||
fn test_resolve_ambiguous_prefix_errors() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
create_issue(dir.path(), "abc12345-aaaa-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
|
||||
create_issue(dir.path(), "abc12345-bbbb-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
|
||||
create_issue(
|
||||
dir.path(),
|
||||
"abc12345-aaaa-0000-0000-000000000000",
|
||||
r#"{"status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
create_issue(
|
||||
dir.path(),
|
||||
"abc12345-bbbb-0000-0000-000000000000",
|
||||
r#"{"status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let result = resolve_issue_id(dir.path(), "abc12345");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("ambiguous"));
|
||||
@@ -264,7 +283,12 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "def99999-0000-0000-0000-000000000000";
|
||||
create_issue(dir.path(), full_id, r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let updated = close_issue(dir.path(), "def99999").unwrap().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&updated).unwrap();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod store;
|
||||
pub mod smart_http;
|
||||
pub mod issues;
|
||||
pub mod tigris;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,20 +248,23 @@ impl RepoStore {
|
||||
|
||||
/// Compute the local disk path and owner slug for a repo.
|
||||
///
|
||||
/// Rejects any input that could be used for path traversal — strict
|
||||
/// allowlist on both `owner_did` and `repo_name`, length-bounded, and
|
||||
/// the resulting path must remain inside `repos_dir`.
|
||||
/// Three-layer defence against path traversal:
|
||||
/// 1. Strict allowlist on `owner_did` and `repo_name` (no `..`, slashes,
|
||||
/// null bytes, leading dots; length-bounded).
|
||||
/// 2. The joined path must remain rooted at `repos_dir`.
|
||||
/// 3. Every component of the joined path must be `Component::Normal`
|
||||
/// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir`
|
||||
/// segment is rejected. This is the CodeQL-recognised barrier
|
||||
/// pattern for `rust/path-injection`.
|
||||
fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> {
|
||||
validate_path_components(owner_did, repo_name)?;
|
||||
|
||||
let owner_slug = owner_did.replace(':', "_").replace('/', "_");
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
let local_path = self
|
||||
.repos_dir
|
||||
.join(&owner_slug)
|
||||
.join(format!("{repo_name}.git"));
|
||||
|
||||
// Defence in depth: even though both inputs are allowlisted, verify
|
||||
// the joined path is still rooted at repos_dir.
|
||||
if !local_path.starts_with(&self.repos_dir) {
|
||||
anyhow::bail!(
|
||||
"computed repo path escaped repos_dir: {}",
|
||||
@@ -243,6 +272,23 @@ impl RepoStore {
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit component walk — sanitisation barrier that static analysers
|
||||
// (CodeQL `rust/path-injection`) recognise. The path must be composed
|
||||
// entirely of Normal segments after the root prefix; any ParentDir or
|
||||
// CurDir component is a traversal attempt.
|
||||
for component in local_path.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
|
||||
Component::ParentDir => {
|
||||
anyhow::bail!("path contains parent-directory component");
|
||||
}
|
||||
Component::CurDir => {
|
||||
anyhow::bail!("path contains current-directory component");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((owner_slug, local_path))
|
||||
}
|
||||
}
|
||||
@@ -257,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");
|
||||
@@ -318,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
|
||||
@@ -351,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
@@ -1,6 +1,6 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use anyhow::{Result, Context, bail};
|
||||
|
||||
/// Initialize a new bare git repository with SHA-1 object format (default).
|
||||
///
|
||||
@@ -107,7 +107,8 @@ pub fn resolve_head(repo_path: &Path, preferred_branch: &str) -> String {
|
||||
|
||||
// 3. Walk all refs — prefer main/master, then take the first one
|
||||
if let Ok(refs) = list_refs(repo_path) {
|
||||
let branches: Vec<_> = refs.iter()
|
||||
let branches: Vec<_> = refs
|
||||
.iter()
|
||||
.filter(|(r, _)| r.starts_with("refs/heads/"))
|
||||
.collect();
|
||||
// Preferred names in order
|
||||
@@ -199,7 +200,13 @@ pub fn ls_tree(repo_path: &Path, refname: &str, tree_path: &str) -> Result<Vec<T
|
||||
let kind = parts.next()?.to_string();
|
||||
let hash = parts.next()?.to_string();
|
||||
let size: Option<u64> = parts.next().and_then(|s| s.parse().ok());
|
||||
Some(TreeEntry { mode, kind, hash, path: name.to_string(), size })
|
||||
Some(TreeEntry {
|
||||
mode,
|
||||
kind,
|
||||
hash,
|
||||
path: name.to_string(),
|
||||
size,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -239,7 +246,9 @@ pub struct CommitInfo {
|
||||
|
||||
fn serialize_timestamp<S: serde::Serializer>(ts: &i64, s: S) -> Result<S::Ok, S::Error> {
|
||||
use chrono::TimeZone;
|
||||
let dt = chrono::Utc.timestamp_opt(*ts, 0).single()
|
||||
let dt = chrono::Utc
|
||||
.timestamp_opt(*ts, 0)
|
||||
.single()
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
s.serialize_str(&dt.to_rfc3339())
|
||||
}
|
||||
@@ -262,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)
|
||||
@@ -275,11 +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>")?;
|
||||
@@ -289,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.
|
||||
@@ -303,9 +334,48 @@ 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(repo_path: &Path, target_branch: &str, source_branch: &str, author_did: &str, pr_title: &str) -> Result<String> {
|
||||
pub fn merge_branch(
|
||||
repo_path: &Path,
|
||||
target_branch: &str,
|
||||
source_branch: &str,
|
||||
author_did: &str,
|
||||
pr_title: &str,
|
||||
) -> Result<String> {
|
||||
let worktree_path = repo_path.join("_merge_worktree");
|
||||
|
||||
// Clean up any leftover worktree
|
||||
@@ -324,13 +394,24 @@ pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str,
|
||||
.output()
|
||||
.context("failed to create worktree")?;
|
||||
if !wt.status.success() {
|
||||
bail!("git worktree add failed: {}", String::from_utf8_lossy(&wt.stderr));
|
||||
bail!(
|
||||
"git worktree add failed: {}",
|
||||
String::from_utf8_lossy(&wt.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Run merge in worktree
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--no-ff", source_branch, "-m",
|
||||
&format!("Merge branch '{}' into {} ({})", source_branch, target_branch, pr_title)])
|
||||
.args([
|
||||
"merge",
|
||||
"--no-ff",
|
||||
source_branch,
|
||||
"-m",
|
||||
&format!(
|
||||
"Merge branch '{}' into {} ({})",
|
||||
source_branch, target_branch, pr_title
|
||||
),
|
||||
])
|
||||
.current_dir(&worktree_path)
|
||||
.env("GIT_AUTHOR_NAME", author_did)
|
||||
.env("GIT_AUTHOR_EMAIL", format!("{}@gitlawb", author_did))
|
||||
@@ -349,7 +430,10 @@ pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str,
|
||||
let _ = std::fs::remove_dir_all(&worktree_path);
|
||||
|
||||
if !success {
|
||||
bail!("git merge failed: {}", String::from_utf8_lossy(&merge.stderr));
|
||||
bail!(
|
||||
"git merge failed: {}",
|
||||
String::from_utf8_lossy(&merge.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Get new HEAD of target branch
|
||||
@@ -365,8 +449,56 @@ pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str,
|
||||
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
|
||||
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
|
||||
// Sanitize the DID for use as a directory name
|
||||
let owner_slug = owner_did
|
||||
.replace(':', "_")
|
||||
.replace('/', "_");
|
||||
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;
|
||||
@@ -23,7 +25,10 @@ impl TigrisClient {
|
||||
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
||||
let s3 = S3Client::new(&config);
|
||||
info!(bucket = %bucket, "tigris storage client initialized");
|
||||
Ok(Self { s3, bucket: bucket.to_string() })
|
||||
Ok(Self {
|
||||
s3,
|
||||
bucket: bucket.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
|
||||
@@ -34,7 +39,9 @@ impl TigrisClient {
|
||||
/// Check if a repo archive exists in Tigris.
|
||||
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
match self.s3.head_object()
|
||||
match self
|
||||
.s3
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
@@ -42,7 +49,7 @@ impl TigrisClient {
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
if e.as_service_error().map_or(false, |e| e.is_not_found()) {
|
||||
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
|
||||
@@ -67,7 +74,8 @@ impl TigrisClient {
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
|
||||
|
||||
self.s3.put_object()
|
||||
self.s3
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.body(body)
|
||||
@@ -81,21 +89,30 @@ impl TigrisClient {
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract to local disk.
|
||||
pub async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
|
||||
pub async fn download(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
|
||||
|
||||
let resp = self.s3.get_object()
|
||||
let resp = self
|
||||
.s3
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris GET {key}"))?;
|
||||
|
||||
let data = resp.body.collect().await
|
||||
let data = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes()
|
||||
.to_vec();
|
||||
.into_bytes();
|
||||
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
@@ -114,7 +131,8 @@ impl TigrisClient {
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
self.s3.delete_object()
|
||||
self.s3
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
@@ -139,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,9 +64,15 @@ 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 tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
|
||||
let task = db
|
||||
.claim_task(&id, &assignee_did)
|
||||
.await
|
||||
@@ -71,9 +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>>();
|
||||
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
|
||||
@@ -95,9 +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>>();
|
||||
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))
|
||||
@@ -113,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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use async_graphql::{Context, Subscription};
|
||||
use async_graphql::futures_util::Stream;
|
||||
use async_graphql::{Context, Subscription};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt as _;
|
||||
|
||||
@@ -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<'_>,
|
||||
@@ -22,7 +31,7 @@ impl SubscriptionRoot {
|
||||
BroadcastStream::new(rx).filter_map(move |r| {
|
||||
let rf = repo.clone();
|
||||
match r {
|
||||
Ok(ev) if rf.as_deref().map_or(true, |r| ev.repo == r) => Some(RefUpdateType {
|
||||
Ok(ev) if rf.as_deref().is_none_or(|r| ev.repo == r) => Some(RefUpdateType {
|
||||
repo: ev.repo,
|
||||
ref_name: ev.ref_name,
|
||||
old_sha: ev.old_sha,
|
||||
@@ -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,
|
||||
}
|
||||
@@ -47,15 +57,13 @@ impl SubscriptionRoot {
|
||||
BroadcastStream::new(rx).filter_map(move |r| {
|
||||
let tf = task_id.clone();
|
||||
match r {
|
||||
Ok(ev) if tf.as_deref().map_or(true, |id| ev.task_id == id) => {
|
||||
Some(TaskEventType {
|
||||
task_id: ev.task_id,
|
||||
old_status: ev.old_status,
|
||||
new_status: ev.new_status,
|
||||
by_did: ev.by_did,
|
||||
at: ev.at,
|
||||
})
|
||||
}
|
||||
Ok(ev) if tf.as_deref().is_none_or(|id| ev.task_id == id) => Some(TaskEventType {
|
||||
task_id: ev.task_id,
|
||||
old_status: ev.old_status,
|
||||
new_status: ev.new_status,
|
||||
by_did: ev.by_did,
|
||||
at: ev.at,
|
||||
}),
|
||||
_ => 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());
|
||||
}
|
||||
}
|
||||
@@ -63,35 +63,47 @@ pub async fn pin_git_object(ipfs_api: &str, sha256_hex: &str, data: &[u8]) -> Re
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
v["Hash"].as_str().map(|s| s.to_string())
|
||||
})
|
||||
.last()
|
||||
.next_back()
|
||||
.unwrap_or(expected_cid.clone());
|
||||
|
||||
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
|
||||
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)
|
||||
}
|
||||
+840
-70
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,9 +129,7 @@ impl OperatorClient {
|
||||
.rpc_url
|
||||
.parse()
|
||||
.with_context(|| format!("invalid RPC URL: {}", self.cfg.rpc_url))?;
|
||||
let provider = ProviderBuilder::new()
|
||||
.wallet(wallet)
|
||||
.connect_http(rpc_url);
|
||||
let provider = ProviderBuilder::new().wallet(wallet).connect_http(rpc_url);
|
||||
let contract = IGitlawbNodeStaking::new(self.cfg.contract_address, provider);
|
||||
|
||||
let pending = contract
|
||||
@@ -147,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,
|
||||
@@ -160,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,26 +3,24 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::{HashMap, hash_map::DefaultHasher};
|
||||
use std::collections::{hash_map::DefaultHasher, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use libp2p::futures::StreamExt;
|
||||
use libp2p::{
|
||||
gossipsub, identify, kad,
|
||||
mdns,
|
||||
noise, tcp, yamux,
|
||||
swarm::{NetworkBehaviour, SwarmEvent},
|
||||
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;
|
||||
@@ -41,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)
|
||||
@@ -65,6 +68,15 @@ pub struct DidRecord {
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Snapshot of the libp2p swarm state for observability.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SwarmStatus {
|
||||
pub connected_peers: usize,
|
||||
pub gossipsub_mesh_peers: usize,
|
||||
pub gossipsub_all_peers: usize,
|
||||
pub listen_addrs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Commands sent to the swarm task from the rest of the node.
|
||||
#[derive(Debug)]
|
||||
pub enum P2pCommand {
|
||||
@@ -79,7 +91,12 @@ pub enum P2pCommand {
|
||||
/// Store a DID record in the Kademlia DHT (fire-and-forget)
|
||||
PutDid(DidRecord),
|
||||
/// Look up a DID in the Kademlia DHT; reply on the oneshot sender
|
||||
GetDid { did: String, reply: oneshot::Sender<Option<DidRecord>> },
|
||||
GetDid {
|
||||
did: String,
|
||||
reply: oneshot::Sender<Option<DidRecord>>,
|
||||
},
|
||||
/// Get a snapshot of the swarm status
|
||||
GetStatus { reply: oneshot::Sender<SwarmStatus> },
|
||||
}
|
||||
|
||||
/// Handle returned to the rest of the node for sending commands to the swarm.
|
||||
@@ -96,7 +113,10 @@ impl P2pHandle {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn add_peer(&self, peer_id: PeerId, addr: Multiaddr) {
|
||||
let _ = self.tx.send(P2pCommand::AddKnownPeer { peer_id, addr }).await;
|
||||
let _ = self
|
||||
.tx
|
||||
.send(P2pCommand::AddKnownPeer { peer_id, addr })
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -109,6 +129,15 @@ impl P2pHandle {
|
||||
let _ = self.tx.send(P2pCommand::PutDid(record)).await;
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> Option<SwarmStatus> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = self.tx.send(P2pCommand::GetStatus { reply: tx }).await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), rx)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
}
|
||||
|
||||
/// Look up a DID in the DHT. Returns None if not found or timeout (10s).
|
||||
pub async fn get_did(&self, did: String) -> Option<DidRecord> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
@@ -128,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.
|
||||
@@ -159,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());
|
||||
|
||||
@@ -172,68 +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 {
|
||||
@@ -249,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 {
|
||||
@@ -273,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(),
|
||||
@@ -298,14 +338,12 @@ pub async fn start(
|
||||
kad::Event::OutboundQueryProgressed { id, result, .. }
|
||||
)) => {
|
||||
match result {
|
||||
kad::QueryResult::GetRecord(Ok(ok)) => {
|
||||
if let kad::GetRecordOk::FoundRecord(pr) = ok {
|
||||
if let Some(reply) = pending_get_did.remove(&id) {
|
||||
let record = serde_json::from_slice::<DidRecord>(
|
||||
&pr.record.value
|
||||
).ok();
|
||||
let _ = reply.send(record);
|
||||
}
|
||||
kad::QueryResult::GetRecord(Ok(kad::GetRecordOk::FoundRecord(pr))) => {
|
||||
if let Some(reply) = pending_get_did.remove(&id) {
|
||||
let record = serde_json::from_slice::<DidRecord>(
|
||||
&pr.record.value
|
||||
).ok();
|
||||
let _ = reply.send(record);
|
||||
}
|
||||
}
|
||||
kad::QueryResult::GetRecord(Err(e)) => {
|
||||
@@ -324,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, .. }
|
||||
)) => {
|
||||
@@ -391,6 +420,16 @@ pub async fn start(
|
||||
let query_id = swarm.behaviour_mut().kademlia.get_record(key);
|
||||
pending_get_did.insert(query_id, reply);
|
||||
}
|
||||
P2pCommand::GetStatus { reply } => {
|
||||
let topic_hash = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC).hash();
|
||||
let status = SwarmStatus {
|
||||
connected_peers: swarm.connected_peers().count(),
|
||||
gossipsub_mesh_peers: swarm.behaviour().gossipsub.mesh_peers(&topic_hash).count(),
|
||||
gossipsub_all_peers: swarm.behaviour().gossipsub.all_peers().count(),
|
||||
listen_addrs: swarm.listeners().map(|a| a.to_string()).collect(),
|
||||
};
|
||||
let _ = reply.send(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,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,25 +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)]
|
||||
@@ -159,7 +135,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_pin_skipped_when_jwt_empty() {
|
||||
let client = reqwest::Client::new();
|
||||
let result = pin_object(&client, "https://uploads.pinata.cloud/v3/files", "", "deadbeef", b"data").await;
|
||||
let result = pin_object(
|
||||
&client,
|
||||
"https://uploads.pinata.cloud/v3/files",
|
||||
"",
|
||||
"deadbeef",
|
||||
b"data",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "", "empty JWT must return empty CID");
|
||||
}
|
||||
@@ -186,7 +169,10 @@ mod tests {
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "pin should succeed: {result:?}");
|
||||
assert_eq!(result.unwrap(), "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"
|
||||
);
|
||||
_mock.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -201,7 +187,14 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let result = pin_object(&client, &server.url(), "bad-jwt", "deadbeef00000000", b"data").await;
|
||||
let result = pin_object(
|
||||
&client,
|
||||
&server.url(),
|
||||
"bad-jwt",
|
||||
"deadbeef00000000",
|
||||
b"data",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("401"));
|
||||
@@ -255,7 +248,14 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let result = pin_object(&client, &server.url(), "my-pinata-jwt", "deadbeef00000000", b"data").await;
|
||||
let result = pin_object(
|
||||
&client,
|
||||
&server.url(),
|
||||
"my-pinata-jwt",
|
||||
"deadbeef00000000",
|
||||
b"data",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
_mock.assert_async().await;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::{
|
||||
extract::State,
|
||||
middleware,
|
||||
@@ -6,21 +7,32 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use serde_json::json;
|
||||
use tower_http::trace::{DefaultOnResponse, DefaultOnFailure, TraceLayer};
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
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, repos, resolve, stars, tasks, webhooks};
|
||||
use crate::api::{
|
||||
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>,
|
||||
auth: Option<axum::Extension<crate::auth::AuthenticatedDid>>,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
state.graphql_schema.execute(req.into_inner()).await.into()
|
||||
// `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,145 +42,482 @@ 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))
|
||||
.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}/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));
|
||||
// ── 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}/commits/{sha}/status",
|
||||
get(status::commit_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/status",
|
||||
get(status::pull_request_status),
|
||||
)
|
||||
.layer(middleware::from_fn(auth::optional_signature));
|
||||
|
||||
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
|
||||
// routes only — all other API routes keep axum's default 2 MB cap.
|
||||
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
|
||||
// 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));
|
||||
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()
|
||||
.route("/api/v1/repos/{owner}/{repo}/bounties", get(bounties::list_repo_bounties))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/bounties",
|
||||
get(bounties::list_repo_bounties),
|
||||
)
|
||||
.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));
|
||||
.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()
|
||||
.route("/api/v1/repos", get(repos::list_repos))
|
||||
.route("/api/v1/repos/federated", get(repos::list_federated_repos))
|
||||
.route("/api/v1/repos/{owner}/{repo}", get(repos::get_repo))
|
||||
.route("/api/v1/repos/{owner}/{repo}/commits", get(repos::list_commits))
|
||||
.route("/api/v1/repos/{owner}/{repo}/tree", get(repos::get_tree_root))
|
||||
.route("/api/v1/repos/{owner}/{repo}/tree/{*path}", get(repos::get_tree))
|
||||
.route("/api/v1/repos/{owner}/{repo}/blob/{*path}", get(repos::get_blob))
|
||||
.route("/api/v1/repos/{owner}/{repo}/issues", get(issues::list_issues))
|
||||
.route("/api/v1/repos/{owner}/{repo}/issues/{id}", get(issues::get_issue))
|
||||
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/comments", get(issues::list_issue_comments))
|
||||
.route("/api/v1/repos/{owner}/{repo}/labels", get(labels::list_labels))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/commits",
|
||||
get(repos::list_commits),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/tree",
|
||||
get(repos::get_tree_root),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/tree/{*path}",
|
||||
get(repos::get_tree),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/blob/{*path}",
|
||||
get(repos::get_blob),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues",
|
||||
get(issues::list_issues),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{id}",
|
||||
get(issues::get_issue),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
|
||||
get(issues::list_issue_comments),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/labels",
|
||||
get(labels::list_labels),
|
||||
)
|
||||
.route("/api/v1/repos/{owner}/{repo}/certs", get(certs::list_certs))
|
||||
.route("/api/v1/repos/{owner}/{repo}/certs/{id}", get(certs::get_cert))
|
||||
.route("/api/v1/repos/{owner}/{repo}/events", get(events::list_repo_events))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/certs/{id}",
|
||||
get(certs::get_cert),
|
||||
)
|
||||
.route(
|
||||
"/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))
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}", get(pulls::get_pr))
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff", get(pulls::get_pr_diff))
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", get(pulls::list_reviews))
|
||||
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", get(pulls::list_comments))
|
||||
.route("/api/v1/repos/{owner}/{repo}/hooks", get(webhooks::list_webhooks))
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}",
|
||||
get(pulls::get_pr),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/diff",
|
||||
get(pulls::get_pr_diff),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
|
||||
get(pulls::list_reviews),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
|
||||
get(pulls::list_comments),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/hooks",
|
||||
get(webhooks::list_webhooks),
|
||||
)
|
||||
.route("/api/v1/repos/{owner}/{repo}/refs", get(repos::list_refs))
|
||||
.route("/api/v1/repos/{owner}/{repo}/branches/protected", get(protect::list_protected_branches))
|
||||
.route("/api/v1/repos/{owner}/{repo}/changelog", get(changelog::get_changelog))
|
||||
.route("/api/v1/repos/{owner}/{repo}/star", get(stars::get_star_status))
|
||||
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs));
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/branches/protected",
|
||||
get(protect::list_protected_branches),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/changelog",
|
||||
get(changelog::get_changelog),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/star",
|
||||
get(stars::get_star_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
get(replicas::list_replicas),
|
||||
)
|
||||
.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}/git-upload-pack", post(repos::git_upload_pack))
|
||||
.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));
|
||||
@@ -179,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)
|
||||
@@ -207,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!({
|
||||
@@ -221,8 +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!({
|
||||
@@ -237,7 +635,11 @@ async fn contracts_info(State(state): State<AppState>) -> Json<serde_json::Value
|
||||
let did_registry = &state.config.contract_did_registry;
|
||||
let name_registry = &state.config.contract_name_registry;
|
||||
let rpc_url = &state.config.chain_rpc_url;
|
||||
let chain_id: u64 = if rpc_url.contains("sepolia") { 84532 } else { 8453 };
|
||||
let chain_id: u64 = if rpc_url.contains("sepolia") {
|
||||
84532
|
||||
} else {
|
||||
8453
|
||||
};
|
||||
Json(serde_json::json!({
|
||||
"chain": if chain_id == 8453 { "base" } else { "base-sepolia" },
|
||||
"chain_id": chain_id,
|
||||
@@ -255,11 +657,18 @@ async fn contracts_info(State(state): State<AppState>) -> Json<serde_json::Value
|
||||
|
||||
async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
match &state.p2p {
|
||||
Some(h) => Json(json!({
|
||||
"enabled": true,
|
||||
"peer_id": h.local_peer_id.to_string(),
|
||||
"topics": [crate::p2p::REF_UPDATES_TOPIC],
|
||||
})),
|
||||
Some(h) => {
|
||||
let status = h.status().await;
|
||||
Json(json!({
|
||||
"enabled": true,
|
||||
"peer_id": h.local_peer_id.to_string(),
|
||||
"topics": [crate::p2p::REF_UPDATES_TOPIC],
|
||||
"connected_peers": status.as_ref().map(|s| s.connected_peers),
|
||||
"gossipsub_mesh_peers": status.as_ref().map(|s| s.gossipsub_mesh_peers),
|
||||
"gossipsub_all_peers": status.as_ref().map(|s| s.gossipsub_all_peers),
|
||||
"listen_addrs": status.as_ref().map(|s| s.listen_addrs.clone()),
|
||||
}))
|
||||
}
|
||||
None => Json(json!({ "enabled": false })),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use gitlawb_core::did::Did;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use std::sync::Arc;
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user