mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5cb7d0be | ||
|
|
e3314c611c | ||
|
|
8b91f2841a | ||
|
|
fb831b1037 | ||
|
|
1da425158a | ||
|
|
4dc62b21ca | ||
|
|
f79309b63c | ||
|
|
04e794129f | ||
|
|
9cf48f52ba | ||
|
|
89fa647027 | ||
|
|
1ab55e5f3b | ||
|
|
ec4399cee8 | ||
|
|
a47ea93152 | ||
|
|
82f2484c39 | ||
|
|
fad75a6579 | ||
|
|
75aa1a0a4e | ||
|
|
05fc52db53 | ||
|
|
e15fa73466 | ||
|
|
5dfd8c5874 | ||
|
|
5071524c2c | ||
|
|
d503655f90 | ||
|
|
a2502fcd87 | ||
|
|
f455726ef3 | ||
|
|
a9e9c4813c | ||
|
|
8843c4ffdf | ||
|
|
3d81f578ee | ||
|
|
d141e59dc2 | ||
|
|
d27f0bec1b | ||
|
|
f8b9cf11ea | ||
|
|
1b1c972654 | ||
|
|
13a24240a9 | ||
|
|
2a8d64d057 |
No files matched your search
+8
-29
@@ -3,7 +3,10 @@
|
||||
# All variables are optional unless marked REQUIRED.
|
||||
|
||||
# ── Node identity ─────────────────────────────────────────────────────────
|
||||
# Path to the node's Ed25519 keypair PEM file.
|
||||
# Path to the node's Ed25519 keypair PEM file. Must be absolute: its parent is
|
||||
# also the delegation store that `gl ucan import` writes and `git-remote-gitlawb`
|
||||
# reads, and the two do not share a working directory, so a relative path sends
|
||||
# them to different places. `~/...` is expanded; a bare `~` is not accepted.
|
||||
# Generate with: gl identity new
|
||||
GITLAWB_KEY=/data/keys/identity.pem
|
||||
|
||||
@@ -17,31 +20,6 @@ GITLAWB_PORT=7545
|
||||
# ── Storage ───────────────────────────────────────────────────────────────
|
||||
GITLAWB_REPOS_DIR=/data/repos
|
||||
|
||||
# ── Object storage (durable repo archives) ────────────────────────────────
|
||||
# Backend for whole-repo archives: s3 | fs | ipfs. Empty = auto-detect:
|
||||
# s3 when a bucket is set, else fs when GITLAWB_STORAGE_FS_DIR is set, else
|
||||
# local-only (repos live only on this node's disk). `ipfs` is never
|
||||
# auto-selected — setting GITLAWB_IPFS_API alone keeps its pinning-only
|
||||
# meaning; opt in explicitly with GITLAWB_STORAGE_BACKEND=ipfs. NOTE: the
|
||||
# ipfs backend stores archives in the Kubo daemon's LOCAL MFS namespace, so
|
||||
# every node must point at the SAME Kubo instance.
|
||||
GITLAWB_STORAGE_BACKEND=
|
||||
# Bucket for the s3 backend (Tigris, R2, AWS S3, MinIO, B2).
|
||||
# GITLAWB_TIGRIS_BUCKET is honored as a legacy alias.
|
||||
GITLAWB_S3_BUCKET=
|
||||
# Endpoint URL override for the s3 backend (R2/MinIO). On Tigris/Fly the
|
||||
# endpoint arrives via AWS_ENDPOINT_URL_S3 — leave empty.
|
||||
GITLAWB_S3_ENDPOINT=
|
||||
# Force path-style addressing (required by MinIO and some S3-compatibles).
|
||||
GITLAWB_S3_FORCE_PATH_STYLE=false
|
||||
# Directory for the fs (local filesystem) backend.
|
||||
GITLAWB_STORAGE_FS_DIR=
|
||||
# Ack pushes before the durable upload finishes (write-back). Lower latency;
|
||||
# opt-in durability tradeoff — see --help for the full semantics.
|
||||
GITLAWB_ASYNC_UPLOAD=false
|
||||
# Dedicated DB pool for per-repo write locks; a push pins one connection for
|
||||
# its lifetime, so this bounds per-node push concurrency.
|
||||
|
||||
# PostgreSQL connection URL. Required.
|
||||
# When using the bundled docker-compose, this is wired automatically.
|
||||
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
@@ -123,9 +101,10 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
|
||||
# push to every repository, private ones included. On by default.
|
||||
#
|
||||
# Set to false only for a rolling upgrade whose pushers are not yet the repo
|
||||
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
|
||||
# verified but not yet honored for authorization, so they cannot push while this
|
||||
# is on. See docs/RUN-A-NODE.md.
|
||||
# owner. A holder of an owner-rooted, time-bounded git/push UCAN naming that
|
||||
# repository can push while this is on — see docs/RUN-A-NODE.md for issuing and
|
||||
# importing one. A CI key with no such delegation counts as a non-owner and
|
||||
# cannot push.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
|
||||
Generated
+3
-2
@@ -3377,10 +3377,13 @@ name = "git-remote-gitlawb"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3441,7 +3444,6 @@ dependencies = [
|
||||
"async-compression",
|
||||
"async-graphql",
|
||||
"async-graphql-axum",
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
@@ -3466,7 +3468,6 @@ dependencies = [
|
||||
"libp2p-kad",
|
||||
"libp2p-quic",
|
||||
"libp2p-swarm",
|
||||
"md-5",
|
||||
"mockito",
|
||||
"multiaddr",
|
||||
"prometheus",
|
||||
|
||||
@@ -66,8 +66,8 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Repository write authorization is one switch: `GITLAWB_ENFORCE_OWNER_PUSH`, on by default since #330. With it off — rolling upgrades only — a valid HTTP Signature identifies a pusher but does not authorize one, so every signed caller may push everywhere.
|
||||
- UCAN capabilities are consulted on the push path only. There, the chain's root issuer is anchored to the repository owner, so an owner-rooted, time-bounded delegation that names the repository grants scoped collaborator access for pushing to it, for the `git/push`, `*`, or `repo/admin` actions. A `*` *resource* is refused: a delegation must name the repository it applies to, so it cannot grow to cover repos created later. Push-only cuts the other way too: a delegation grants no read access, so on a private repository the delegate must also be a reader (`gl visibility set`) — the two gates a push crosses are described in [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). The rest is unchanged: there is no revocation path, `nb` constraints are refused rather than interpreted, and no other route — reads, pull requests, issues, agents — consults capabilities at all.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
@@ -311,7 +311,7 @@ metadata local disk / optional S3
|
||||
| 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. |
|
||||
| UCAN | Capability token. An owner delegates `git/push` on a repo to another DID; the node honors it when the proof chain roots at that owner. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| 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. |
|
||||
@@ -393,11 +393,11 @@ Important node settings:
|
||||
| `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. Defaults to `false` during the staged rollout below. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `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, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (storage-backend HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
|
||||
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
|
||||
| `GITLAWB_MAX_CONCURRENT_GIT_OPS` | Max concurrent served git READ ops (upload-pack and its `info/refs` advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER`. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
|
||||
| `GITLAWB_MAX_CONCURRENT_GIT_PUSHES` | Max concurrent `git-receive-pack` POST operations, in a pool separate from the read pool. The anon receive-pack `info/refs` advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (`/8`, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
|
||||
| `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER` | Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as `GITLAWB_TRUSTED_PROXY`: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
|
||||
@@ -412,13 +412,7 @@ Important node settings:
|
||||
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
|
||||
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
|
||||
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
|
||||
| `GITLAWB_STORAGE_BACKEND` | Object-storage backend for repo archives: `s3`, `fs`, or `ipfs`. Empty = auto-detect (`s3` if a bucket is set, else `fs` if a dir is set, else local-only). `ipfs` is never auto-selected and requires all nodes to share one Kubo instance (MFS is daemon-local). |
|
||||
| `GITLAWB_S3_BUCKET` | Bucket for the `s3` backend (Tigris, R2, AWS S3, MinIO, B2). |
|
||||
| `GITLAWB_S3_ENDPOINT` | Endpoint URL override for the `s3` backend (R2/MinIO; empty on Tigris/Fly). |
|
||||
| `GITLAWB_S3_FORCE_PATH_STYLE` | Force path-style S3 addressing (MinIO and some S3-compatibles). |
|
||||
| `GITLAWB_STORAGE_FS_DIR` | Directory for the `fs` (local filesystem) backend. |
|
||||
| `GITLAWB_ASYNC_UPLOAD` | Ack pushes before the durable storage upload (write-back). Lower latency, opt-in durability tradeoff. Default `false`. |
|
||||
| `GITLAWB_TIGRIS_BUCKET` | Legacy alias for `GITLAWB_S3_BUCKET` (selects the `s3` backend). |
|
||||
| `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. |
|
||||
|
||||
|
||||
@@ -14,8 +14,17 @@ path = "src/main.rs"
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
# Reading the node's DID from `GET /` so a delegated push can address its
|
||||
# invocation to the right executor.
|
||||
serde_json = { workspace = true }
|
||||
# The invocation carries the delegation's expiry, so an unbounded write capability
|
||||
# is never minted; converting the stored i64 timestamp needs chrono.
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# Home-directory lookup for the shared GITLAWB_KEY resolver in gitlawb-core,
|
||||
# which takes `home` as an argument so core's dependency allowlist stays lean.
|
||||
dirs = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
|
||||
@@ -124,7 +124,9 @@ fn help_text() -> String {
|
||||
\n\
|
||||
ENVIRONMENT:\n\
|
||||
\x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\
|
||||
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\
|
||||
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push, absolute\n\
|
||||
\x20 (default: ~/.gitlawb/identity.pem). Its parent also\n\
|
||||
\x20 holds the delegations `gl ucan import` writes.\n\
|
||||
\x20 GITLAWB_LOG Log filter (default: warn)\n\
|
||||
\n\
|
||||
FLAGS:\n\
|
||||
@@ -408,6 +410,205 @@ fn build_advertisement_request(
|
||||
/// Public-repo fetch still works anonymously when no keypair is present. The body
|
||||
/// is signed (content-digest) but NOT attached here, so the caller can move the
|
||||
/// (possibly large) pack bytes into `.body()` rather than clone them.
|
||||
/// How long to wait for the node's DID before giving up and pushing without a
|
||||
/// delegation. Short on purpose: the answer is optional, and the node decides
|
||||
/// whether the header was required.
|
||||
const NODE_DID_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// A path component safe to build a filename from. Mirrors `gl`'s
|
||||
/// `ucan_cmd::is_safe_component`; change both together.
|
||||
///
|
||||
/// Here the value comes from the remote URL rather than a token, and the path is
|
||||
/// only read — but a `..` owner would still read an arbitrary file and send its
|
||||
/// contents to the node as `X-Ucan`, so the same allow-list applies.
|
||||
fn is_safe_component(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& !s.contains("..")
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
|
||||
}
|
||||
|
||||
/// Where a delegation for `owner_did`/`repo` is stored.
|
||||
///
|
||||
/// Must agree with `gl`'s `ucan_cmd::delegation_path`, which writes these files.
|
||||
/// `gl` is not a dependency of this crate, so the six lines are duplicated rather
|
||||
/// than shared; change both together. Keyed on the bare base58 key because
|
||||
/// `did:key:` contains a colon, which Windows rejects in a filename.
|
||||
fn delegation_path(dir: &std::path::Path, owner_did: &str, repo: &str) -> std::path::PathBuf {
|
||||
let bare = owner_did.strip_prefix("did:key:").unwrap_or(owner_did);
|
||||
dir.join("delegations").join(format!("{bare}__{repo}.ucan"))
|
||||
}
|
||||
|
||||
/// Wrap a stored delegation into an invocation addressed to the node.
|
||||
///
|
||||
/// `iss=agent, aud=node, prf=[delegation]` is exactly the shape the node's
|
||||
/// `validate_ucan_chain` expects: it binds `iss` to the request signer and `aud`
|
||||
/// to its own DID, then walks `prf` to the root.
|
||||
///
|
||||
/// Capabilities are copied from the delegation unchanged, so the invocation is
|
||||
/// never broader than what was delegated and cannot fail attenuation. No expiry
|
||||
/// is set: the delegation's own `exp` still bounds the chain, because
|
||||
/// `verify_chain` checks each proof's expiry as it recurses.
|
||||
fn build_invocation(
|
||||
agent: &Keypair,
|
||||
node_did: &gitlawb_core::did::Did,
|
||||
delegation: &gitlawb_core::ucan::Ucan,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<gitlawb_core::ucan::Ucan> {
|
||||
use gitlawb_core::ucan::{caps, Capability};
|
||||
|
||||
let resource = format!("gitlawb://repos/{owner}/{repo}");
|
||||
|
||||
// The whole chain has to grant push to this repository, by the same rule the
|
||||
// node applies (`chain_grants_push_to`): a stored token whose proof is still a
|
||||
// wildcard would mint an invocation the node is certain to refuse, and the push
|
||||
// would fail remotely with a vaguer message than this one. Checked before the
|
||||
// leaf is selected so the message names the actual reason.
|
||||
if !delegation.chain_grants_push_to(owner, repo) {
|
||||
anyhow::bail!(
|
||||
"stored delegation does not grant git/push for {resource} on every link of
|
||||
its chain — a proof behind it may be a wildcard or name another repository.
|
||||
Ask the owner to re-issue it against this repository and re-run
|
||||
`gl ucan import`."
|
||||
);
|
||||
}
|
||||
let source = delegation
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.find(|c| c.grants_push_to(owner, repo))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("stored delegation carries no git/push capability for {resource}")
|
||||
})?;
|
||||
|
||||
// The parent's own resource, verbatim: the node compares `with` by equality via
|
||||
// `is_attenuated_by`, and every capability reaching here already names this repo,
|
||||
// so there is nothing left to narrow.
|
||||
let mut narrowed = Capability::new(source.with.clone(), caps::GIT_PUSH);
|
||||
narrowed.constraints = source.constraints.clone();
|
||||
|
||||
// Carry the delegation's own expiry onto the invocation. The node refuses a
|
||||
// chain with any unbounded link, because without a revocation path an
|
||||
// unbounded write capability can never be withdrawn.
|
||||
let exp = delegation
|
||||
.payload
|
||||
.exp
|
||||
.and_then(|e| chrono::DateTime::from_timestamp(e, 0));
|
||||
|
||||
gitlawb_core::ucan::Ucan::delegate(agent, node_did.clone(), vec![narrowed], exp, delegation)
|
||||
.map_err(|e| anyhow::anyhow!("failed to build UCAN invocation: {e}"))
|
||||
}
|
||||
|
||||
/// Split a pack POST URL into `(node_base, owner, repo)`.
|
||||
///
|
||||
/// ```text
|
||||
/// https://node/zOwner/myrepo/git-receive-pack -> ("https://node", "zOwner", "myrepo")
|
||||
/// https://node/gitlawb/zOwner/myrepo/git-receive-pack -> ("https://node/gitlawb", "zOwner", "myrepo")
|
||||
/// ```
|
||||
///
|
||||
/// Strips the KNOWN trailing `<owner>/<repo>/<service>` rather than reading the
|
||||
/// first two segments, because `GITLAWB_NODE` may carry a path prefix — a
|
||||
/// reverse-proxied `https://host/gitlawb` is a supported base, and `repo_base` is
|
||||
/// built as `{node_base}/{owner}/{repo}` with the service appended after it.
|
||||
/// Reading from the front makes the prefix the owner, which fails the delegation
|
||||
/// lookup and probes the wrong URL for the node DID.
|
||||
///
|
||||
/// This is coupled to how `repo_base` is constructed in `main`; the two must
|
||||
/// change together.
|
||||
fn split_pack_post_url(post_url: &str) -> Option<(String, String, String)> {
|
||||
let path = url_path(post_url);
|
||||
let origin = post_url.strip_suffix(&path)?;
|
||||
|
||||
let segs: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
// owner, repo, service — plus any base-path prefix ahead of them.
|
||||
if segs.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let repo = segs[segs.len() - 2];
|
||||
let owner = segs[segs.len() - 3];
|
||||
let prefix = &segs[..segs.len() - 3];
|
||||
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
if !is_safe_component(owner) || !is_safe_component(repo) {
|
||||
return None;
|
||||
}
|
||||
// Every prefix segment stays part of the base the node DID is fetched from, so
|
||||
// it gets the same allow-list: a `..` here would redirect that probe, and an
|
||||
// encoded separator would smuggle structure past this split.
|
||||
if !prefix.iter().all(|s| is_safe_component(s)) {
|
||||
return None;
|
||||
}
|
||||
let node_base = if prefix.is_empty() {
|
||||
origin.to_string()
|
||||
} else {
|
||||
format!("{origin}/{}", prefix.join("/"))
|
||||
};
|
||||
Some((node_base, owner.to_string(), repo.to_string()))
|
||||
}
|
||||
|
||||
/// Build the `X-Ucan` value for a delegated push, or `None` when this push does
|
||||
/// not need one.
|
||||
///
|
||||
/// Entirely best-effort. A missing delegation, an unreachable node, or an
|
||||
/// unreadable stored token all yield `None` and the push proceeds without the
|
||||
/// header — the node decides whether one was required, and a node denial must
|
||||
/// reach the user rather than being pre-empted by a local guess.
|
||||
fn delegation_header(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
keypair: &Keypair,
|
||||
) -> Option<String> {
|
||||
let (origin, owner, repo) = split_pack_post_url(post_url)?;
|
||||
|
||||
// The owner pushes on their own authority; no delegation is involved.
|
||||
let bare = |d: &str| d.strip_prefix("did:key:").unwrap_or(d).to_string();
|
||||
if bare(&keypair.did().to_string()) == bare(&owner) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dir = resolve_identity_dir()?;
|
||||
let path = delegation_path(&dir, &owner, &repo);
|
||||
let raw = std::fs::read_to_string(&path).ok()?;
|
||||
|
||||
let delegation = match gitlawb_core::ucan::Ucan::decode(raw.trim()) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::warn!("stored delegation at {path:?} is unreadable: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// The invocation must be addressed to the node that will execute it.
|
||||
// The shared client carries a 300s timeout, which is right for a pack transfer
|
||||
// and wrong for a best-effort metadata probe: a stalled node would delay every
|
||||
// delegated push by five minutes before falling back to sending no header.
|
||||
let node_did: gitlawb_core::did::Did = client
|
||||
.get(&origin)
|
||||
.timeout(std::time::Duration::from_secs(NODE_DID_TIMEOUT_SECS))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.ok()
|
||||
.and_then(|r| r.json::<serde_json::Value>().ok())
|
||||
.and_then(|v| v.get("did")?.as_str().map(str::to_owned))
|
||||
.or_else(|| {
|
||||
tracing::warn!("could not read the node DID from {origin}; pushing without X-Ucan");
|
||||
None
|
||||
})?
|
||||
.parse()
|
||||
.ok()?;
|
||||
|
||||
match build_invocation(keypair, &node_did, &delegation, &owner, &repo) {
|
||||
Ok(inv) => inv.encode().ok(),
|
||||
Err(e) => {
|
||||
tracing::warn!("could not build the UCAN invocation: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_pack_post_request(
|
||||
client: &reqwest::blocking::Client,
|
||||
post_url: &str,
|
||||
@@ -426,6 +627,15 @@ fn build_pack_post_request(
|
||||
.header("Signature-Input", signed.signature_input)
|
||||
.header("Signature", signed.signature);
|
||||
tracing::debug!("signed {service} POST (DID: {})", kp.did());
|
||||
|
||||
// A non-owner pushing under a delegation presents it here. Only on the
|
||||
// push: a fetch is gated by read visibility, not by git/push.
|
||||
if service == "git-receive-pack" {
|
||||
if let Some(token) = delegation_header(client, post_url, kp) {
|
||||
tracing::debug!("attaching a delegated push capability");
|
||||
req = req.header("X-Ucan", token);
|
||||
}
|
||||
}
|
||||
} else if service == "git-receive-pack" {
|
||||
tracing::warn!("no identity keypair found, push will be unsigned (v0.1 local alpha only)");
|
||||
}
|
||||
@@ -792,7 +1002,7 @@ fn safe_error_body_excerpt(body: &str) -> String {
|
||||
// ── Keypair loading ───────────────────────────────────────────────────────────
|
||||
|
||||
fn load_keypair() -> Option<Keypair> {
|
||||
let key_path = resolve_key_path();
|
||||
let key_path = resolve_key_path()?;
|
||||
if !key_path.exists() {
|
||||
tracing::debug!("no keypair found at {key_path:?}");
|
||||
return None;
|
||||
@@ -815,16 +1025,38 @@ fn load_keypair() -> Option<Keypair> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_key_path() -> std::path::PathBuf {
|
||||
let path_str =
|
||||
std::env::var("GITLAWB_KEY").unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
|
||||
/// The identity PEM, resolved by the same rules `gl` uses.
|
||||
///
|
||||
/// Shared through `gitlawb-core` rather than reimplemented here: this helper and
|
||||
/// `gl ucan import` have to derive the same delegation store from `GITLAWB_KEY`,
|
||||
/// and the two had drifted — the local version read `env::var` (so a non-UTF-8
|
||||
/// value silently became the default key), expanded only a literal `"~/"`, fell
|
||||
/// back to `"."` when `HOME` was unset, and never required an absolute path.
|
||||
///
|
||||
/// `None` means the value is unusable, not that the key is missing. Git runs this
|
||||
/// helper mid-push, so a misconfiguration is logged and the push continues
|
||||
/// unsigned rather than aborting the transfer.
|
||||
fn resolve_key_path() -> Option<std::path::PathBuf> {
|
||||
gitlawb_core::identity_path::identity_key_path(home_dir().as_deref())
|
||||
.inspect_err(|e| tracing::warn!("cannot resolve the identity key path: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
if let Some(stripped) = path_str.strip_prefix("~/") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
std::path::PathBuf::from(home).join(stripped)
|
||||
} else {
|
||||
std::path::PathBuf::from(path_str)
|
||||
}
|
||||
/// The directory holding `identity.pem` and `delegations/`.
|
||||
fn resolve_identity_dir() -> Option<std::path::PathBuf> {
|
||||
gitlawb_core::identity_path::identity_dir(home_dir().as_deref())
|
||||
.inspect_err(|e| tracing::warn!("cannot resolve the identity directory: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// `dirs`, not `$HOME`: the old code fell back to `"."` when `HOME` was unset,
|
||||
/// which on Windows is always, so the default key resolved against whatever
|
||||
/// directory git happened to invoke the helper from.
|
||||
///
|
||||
/// `None` is not fatal — an absolute `GITLAWB_KEY` resolves without it, and only
|
||||
/// the default and `~/`-prefixed forms need a home at all.
|
||||
fn home_dir() -> Option<std::path::PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -2616,3 +2848,612 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delegated_push_tests {
|
||||
use super::*;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
#[test]
|
||||
fn invocation_wraps_the_delegation_and_targets_the_node() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)],
|
||||
None,
|
||||
)
|
||||
.expect("issue");
|
||||
|
||||
let invocation =
|
||||
build_invocation(&agent, &node.did(), &delegation, "zowner", "r").expect("wrap");
|
||||
|
||||
assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes");
|
||||
assert_eq!(invocation.payload.aud, node.did(), "the node executes");
|
||||
assert_eq!(
|
||||
invocation.payload.prf.len(),
|
||||
1,
|
||||
"exactly one proof: chains are linear"
|
||||
);
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("must verify"),
|
||||
owner.did(),
|
||||
"the chain must still root at the owner after wrapping"
|
||||
);
|
||||
}
|
||||
|
||||
/// An expired delegation cannot be laundered into a live one by wrapping it.
|
||||
///
|
||||
/// Two independent guards now cover this: the invocation inherits the
|
||||
/// delegation's `exp`, so it is expired on its own terms, AND `verify_chain`
|
||||
/// recurses into the proof and rejects it there. Inheriting the expiry is what
|
||||
/// keeps the node's "every link must be bounded" rule satisfiable — a leaf with
|
||||
/// no expiry would be refused outright.
|
||||
#[test]
|
||||
fn an_expired_delegation_cannot_be_laundered_by_wrapping_it() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let expired = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)],
|
||||
Some(chrono::Utc::now() - chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue expired");
|
||||
|
||||
let invocation =
|
||||
build_invocation(&agent, &node.did(), &expired, "zowner", "r").expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation.payload.exp, expired.payload.exp,
|
||||
"the invocation inherits the delegation's expiry, never a longer one"
|
||||
);
|
||||
assert!(
|
||||
!invocation.chain_lifetime_is_bounded() || invocation.is_expired(),
|
||||
"an inherited expiry in the past leaves the invocation expired"
|
||||
);
|
||||
let err = invocation
|
||||
.verify_chain()
|
||||
.expect_err("an expired proof must fail the chain");
|
||||
assert!(
|
||||
err.to_string().contains("expired"),
|
||||
"the failure must name expiry, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipping combination: the owner issues with the FULL DID (what
|
||||
/// `RUN-A-NODE.md` instructs), while the push URL yields the BARE key
|
||||
/// (`parse_gitlawb_url` takes the last colon-delimited segment). Comparing the
|
||||
/// whole resource string never matches, so the delegation is not found, no
|
||||
/// `X-Ucan` is sent, and the delegate gets a 403 telling them to obtain the
|
||||
/// delegation they are already holding.
|
||||
#[test]
|
||||
fn build_invocation_matches_a_full_did_delegation_against_a_bare_owner() {
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let full = owner.did().to_string();
|
||||
let bare = full.strip_prefix("did:key:").unwrap().to_string();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{full}/r"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue");
|
||||
|
||||
let invocation = build_invocation(&agent, &node.did(), &delegation, &bare, "r")
|
||||
.expect("a full-DID delegation must match a bare-owner push URL");
|
||||
|
||||
// The narrowed capability must keep the parent's exact `with`: the node's
|
||||
// `is_attenuated_by` compares it by equality, so emitting the bare form under
|
||||
// a full-DID parent would fail attenuation and be refused at the node.
|
||||
assert_eq!(
|
||||
invocation.payload.att[0].with,
|
||||
format!("gitlawb://repos/{full}/r"),
|
||||
"narrowing must not rewrite a resource that already names this repo"
|
||||
);
|
||||
assert!(
|
||||
invocation.payload.att[0].is_attenuated_by(&delegation.payload.att[0]),
|
||||
"the narrowed capability must still attenuate under its parent"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wildcard delegation is refused locally now, rather than narrowed.
|
||||
///
|
||||
/// Narrowing `*` to the pushed repo is what let one delegation reach every
|
||||
/// repository the owner had or later created: the node saw a concrete leaf and
|
||||
/// the `*` proof behind it satisfied attenuation. The node now requires every
|
||||
/// link to name the repository, so minting from a wildcard could only produce a
|
||||
/// push refused remotely with a vaguer message. Failing here says why.
|
||||
#[test]
|
||||
fn build_invocation_refuses_a_wildcard_delegation() {
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue");
|
||||
|
||||
assert!(
|
||||
build_invocation(&agent, &node.did(), &delegation, "z6MkOwner", "r").is_err(),
|
||||
"a bare wildcard cannot be narrowed into a usable invocation any more"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stored two-link delegation whose leaf names this repo but whose proof is
|
||||
/// `*`. The leaf alone would select fine; the node's chain walk refuses it, so
|
||||
/// the helper refuses it first with a message that says why.
|
||||
#[test]
|
||||
fn build_invocation_refuses_a_concrete_leaf_on_a_wildcard_proof() {
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let owner_bare = owner
|
||||
.did()
|
||||
.to_string()
|
||||
.strip_prefix("did:key:")
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let proof = Ucan::issue(
|
||||
&owner,
|
||||
intermediary.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue");
|
||||
let stored = Ucan::delegate(
|
||||
&intermediary,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_bare}/r"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
&proof,
|
||||
)
|
||||
.expect("re-delegate");
|
||||
|
||||
let err = build_invocation(&agent, &node.did(), &stored, &owner_bare, "r")
|
||||
.expect_err("a wildcard proof must fail closed in the helper");
|
||||
assert!(
|
||||
err.to_string().contains("every link"),
|
||||
"the message must point at the chain, not the leaf: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A delegation holding both a constrained and an unconstrained grant must work
|
||||
/// regardless of their order. The node refuses a constrained leaf outright, so
|
||||
/// picking merely the FIRST push-class capability made a valid delegation
|
||||
/// succeed or fail purely on how the owner happened to order `att`.
|
||||
#[test]
|
||||
fn build_invocation_skips_a_constrained_capability_in_either_order() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = "gitlawb://repos/z6MkOwner/r";
|
||||
|
||||
let mut constrained = Capability::new(resource, caps::GIT_PUSH);
|
||||
constrained.constraints = Some(serde_json::json!({"max_bytes": 1}));
|
||||
let usable = Capability::new(resource, caps::GIT_PUSH);
|
||||
|
||||
for (label, att) in [
|
||||
(
|
||||
"constrained first",
|
||||
vec![constrained.clone(), usable.clone()],
|
||||
),
|
||||
("usable first", vec![usable.clone(), constrained.clone()]),
|
||||
] {
|
||||
let delegation = Ucan::issue(&owner, agent.did(), att, Some(hour)).expect("issue");
|
||||
let invocation = build_invocation(&agent, &node.did(), &delegation, "z6MkOwner", "r")
|
||||
.unwrap_or_else(|e| panic!("{label}: {e}"));
|
||||
assert!(
|
||||
invocation.payload.att[0].constraints.is_none(),
|
||||
"{label}: the invocation must carry the capability the node can authorize"
|
||||
);
|
||||
}
|
||||
|
||||
// With ONLY a constrained grant there is nothing the node would accept, so
|
||||
// failing here beats minting an invocation guaranteed to be refused.
|
||||
let only_constrained =
|
||||
Ucan::issue(&owner, agent.did(), vec![constrained], Some(hour)).expect("issue");
|
||||
assert!(
|
||||
build_invocation(&agent, &node.did(), &only_constrained, "z6MkOwner", "r").is_err(),
|
||||
"a delegation with no unconstrained grant must not produce an invocation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_pack_post_url_separates_origin_owner_and_repo() {
|
||||
assert_eq!(
|
||||
split_pack_post_url("http://127.0.0.1:7545/z6Mk/myrepo.git/git-receive-pack"),
|
||||
Some((
|
||||
"http://127.0.0.1:7545".to_string(),
|
||||
"z6Mk".to_string(),
|
||||
"myrepo".to_string()
|
||||
)),
|
||||
"the origin must come back intact so the node DID can be fetched from it"
|
||||
);
|
||||
// A reverse-proxied GITLAWB_NODE carries a path prefix, which survives into
|
||||
// the pack URL through `repo_base`. Reading the FIRST two segments as
|
||||
// owner/repo makes the prefix the owner: the delegation lookup misses, the
|
||||
// DID probe hits the wrong URL, no X-Ucan is sent, and a valid delegate is
|
||||
// refused with 403 — silently, because every failure here is best-effort.
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://host/gitlawb/z6Mk/myrepo/git-receive-pack"),
|
||||
Some((
|
||||
"https://host/gitlawb".to_string(),
|
||||
"z6Mk".to_string(),
|
||||
"myrepo".to_string()
|
||||
)),
|
||||
"a path-prefixed node base must keep its prefix and still find owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://host/a/b/c/z6Mk/myrepo/git-receive-pack"),
|
||||
Some((
|
||||
"https://host/a/b/c".to_string(),
|
||||
"z6Mk".to_string(),
|
||||
"myrepo".to_string()
|
||||
)),
|
||||
"prefix depth is not fixed"
|
||||
);
|
||||
// The .git suffix is optional on the wire; the delegation is stored under
|
||||
// the bare repo name either way, so both forms must resolve identically.
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://node.example/z6Mk/myrepo/git-receive-pack")
|
||||
.map(|(_, _, r)| r),
|
||||
Some("myrepo".to_string())
|
||||
);
|
||||
// A repo genuinely named "x.git" keeps its name: only one suffix is stripped.
|
||||
assert_eq!(
|
||||
split_pack_post_url("https://node.example/z6Mk/x.git.git/git-receive-pack")
|
||||
.map(|(_, _, r)| r),
|
||||
Some("x.git".to_string())
|
||||
);
|
||||
for bad in [
|
||||
"not-a-url",
|
||||
"https://node.example",
|
||||
"https://node.example/",
|
||||
"https://node.example/onlyowner",
|
||||
// A traversing owner would read an arbitrary file and send it to the
|
||||
// node as X-Ucan, so it must not resolve to a lookup at all.
|
||||
"https://node.example/../../etc/passwd/git-receive-pack",
|
||||
"https://node.example/../x/git-receive-pack",
|
||||
"https://node.example/a%2Fb/x/git-receive-pack",
|
||||
] {
|
||||
assert!(
|
||||
split_pack_post_url(bad).is_none(),
|
||||
"{bad} must not parse as a pack POST URL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegation_path_matches_the_gl_layout() {
|
||||
let base = std::path::Path::new("/tmp/id");
|
||||
let expected = base.join("delegations").join("z6MkAbc__myrepo.ucan");
|
||||
assert_eq!(delegation_path(base, "did:key:z6MkAbc", "myrepo"), expected);
|
||||
assert_eq!(delegation_path(base, "z6MkAbc", "myrepo"), expected);
|
||||
}
|
||||
|
||||
// ── delegation_header ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Everything above tests one piece in isolation. `delegation_header` is where
|
||||
// they compose — URL split, owner comparison, store lookup, node-DID probe,
|
||||
// invocation build — and it is the piece with no safety net: every failure
|
||||
// inside it is deliberately silent, so a regression does not fail loudly, it
|
||||
// just stops attaching `X-Ucan` and the delegate starts getting 403s with no
|
||||
// local explanation.
|
||||
|
||||
/// `delegation_header` resolves its store from `GITLAWB_KEY`, which is
|
||||
/// process-global. Every case that sets it takes this lock.
|
||||
///
|
||||
/// Only these cases need it. `advertisement_and_pack_post_are_signed_…` also
|
||||
/// reaches `delegation_header` (through `build_pack_post_request` on
|
||||
/// `git-receive-pack`), but it asserts on signature headers alone and is
|
||||
/// unaffected by whichever store is in scope.
|
||||
static KEY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point `GITLAWB_KEY` at `dir/identity.pem` for the duration of `f`, so the
|
||||
/// delegation store resolves to `dir/delegations`.
|
||||
fn with_identity_dir<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let restore = std::env::var_os("GITLAWB_KEY");
|
||||
std::env::set_var("GITLAWB_KEY", dir.join("identity.pem"));
|
||||
let out = f();
|
||||
match restore {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write a token where `gl ucan import` would have left it.
|
||||
fn store_delegation(dir: &std::path::Path, owner: &str, repo: &str, raw: &str) {
|
||||
let path = delegation_path(dir, owner, repo);
|
||||
std::fs::create_dir_all(path.parent().expect("delegations dir")).expect("mkdir");
|
||||
std::fs::write(path, raw).expect("write delegation");
|
||||
}
|
||||
|
||||
fn bare(did: &gitlawb_core::did::Did) -> String {
|
||||
did.to_string()
|
||||
.strip_prefix("did:key:")
|
||||
.expect("did:key")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn push_delegation(owner: &Keypair, agent: &Keypair, resource: &str) -> Ucan {
|
||||
Ucan::issue(
|
||||
owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(resource, caps::GIT_PUSH)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue")
|
||||
}
|
||||
|
||||
fn did_body(node: &Keypair) -> String {
|
||||
format!(r#"{{"did":"{}"}}"#, node.did())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegation_header_wraps_a_stored_delegation_and_targets_the_node() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
|
||||
let delegation = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"));
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let did_probe = server
|
||||
.mock("GET", "/")
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(did_body(&node))
|
||||
.create();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(
|
||||
dir.path(),
|
||||
&owner_key,
|
||||
"r",
|
||||
&delegation.encode().expect("encode"),
|
||||
);
|
||||
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let header =
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
|
||||
.expect("a stored delegation must produce an X-Ucan");
|
||||
|
||||
did_probe.assert();
|
||||
|
||||
let invocation = Ucan::decode(&header).expect("the header must decode as a UCAN");
|
||||
assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes");
|
||||
assert_eq!(
|
||||
invocation.payload.aud,
|
||||
node.did(),
|
||||
"addressed to the node that will execute it"
|
||||
);
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("chain must verify"),
|
||||
owner.did(),
|
||||
"the chain must root at the repo owner"
|
||||
);
|
||||
assert!(
|
||||
invocation.chain_lifetime_is_bounded(),
|
||||
"the node refuses an unbounded push chain, so every link must carry an expiry"
|
||||
);
|
||||
}
|
||||
|
||||
/// The owner pushes on their own authority. The comparison has to survive the
|
||||
/// form mismatch — the keypair holds `did:key:z…`, the URL carries the bare key
|
||||
/// — or the owner takes the delegate path, finds nothing, and pays a node
|
||||
/// round-trip on every push. `.expect(0)` is the assertion that matters here.
|
||||
#[test]
|
||||
fn delegation_header_is_skipped_when_the_pusher_is_the_owner() {
|
||||
let owner = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let did_probe = server
|
||||
.mock("GET", "/")
|
||||
.with_body(did_body(&node))
|
||||
.expect(0)
|
||||
.create();
|
||||
|
||||
// A delegation the owner does not need. Present so the assertion is about
|
||||
// the owner check and not about an empty store.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let delegate = Keypair::generate();
|
||||
store_delegation(
|
||||
dir.path(),
|
||||
&owner_key,
|
||||
"r",
|
||||
&push_delegation(&owner, &delegate, &format!("gitlawb://repos/{owner_key}/r"))
|
||||
.encode()
|
||||
.expect("encode"),
|
||||
);
|
||||
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let header =
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &owner));
|
||||
|
||||
assert!(header.is_none(), "the owner needs no delegation");
|
||||
did_probe.assert();
|
||||
}
|
||||
|
||||
/// Best-effort means best-effort: nothing here may panic or block the push. The
|
||||
/// node decides whether a delegation was required, and its denial has to reach
|
||||
/// the user instead of being pre-empted by a local guess.
|
||||
#[test]
|
||||
fn delegation_header_is_absent_without_a_usable_stored_delegation() {
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&Keypair::generate().did());
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let _did = server.mock("GET", "/").with_body(did_body(&node)).create();
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
// Nothing stored at all.
|
||||
let empty = tempfile::tempdir().expect("tempdir");
|
||||
assert!(
|
||||
with_identity_dir(empty.path(), || delegation_header(
|
||||
&client, &post_url, &agent
|
||||
))
|
||||
.is_none(),
|
||||
"an empty store must yield no header, not an error"
|
||||
);
|
||||
|
||||
// Stored, but not a UCAN — a truncated write or a hand-edited file.
|
||||
let garbage = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(garbage.path(), &owner_key, "r", "not a ucan");
|
||||
assert!(
|
||||
with_identity_dir(garbage.path(), || delegation_header(
|
||||
&client, &post_url, &agent
|
||||
))
|
||||
.is_none(),
|
||||
"an unreadable stored token must yield no header, not a panic"
|
||||
);
|
||||
|
||||
// Stored and valid, but for a capability the push path cannot use. `gl ucan
|
||||
// import` refuses these now; a store written by an older `gl` still holds them.
|
||||
let owner = Keypair::generate();
|
||||
let wrong_owner_key = bare(&owner.did());
|
||||
let unusable = tempfile::tempdir().expect("tempdir");
|
||||
let fetch_only = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{wrong_owner_key}/r"),
|
||||
caps::GIT_FETCH,
|
||||
)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue");
|
||||
store_delegation(
|
||||
unusable.path(),
|
||||
&wrong_owner_key,
|
||||
"r",
|
||||
&fetch_only.encode().expect("encode"),
|
||||
);
|
||||
let fetch_url = format!("{}/{wrong_owner_key}/r/git-receive-pack", server.url());
|
||||
assert!(
|
||||
with_identity_dir(unusable.path(), || delegation_header(
|
||||
&client, &fetch_url, &agent
|
||||
))
|
||||
.is_none(),
|
||||
"a git/fetch delegation carries no push capability to wrap"
|
||||
);
|
||||
}
|
||||
|
||||
/// The node DID addresses the invocation, so without it there is nothing to
|
||||
/// build. A node that is down, slow, or serving something other than JSON must
|
||||
/// cost the push a header, never an abort.
|
||||
#[test]
|
||||
fn delegation_header_is_absent_when_the_node_did_cannot_be_read() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
let encoded = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"))
|
||||
.encode()
|
||||
.expect("encode");
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
for (label, status, body) in [
|
||||
("a 500 from the node", 500, "boom"),
|
||||
("a JSON body with no did", 200, r#"{"name":"gitlawb"}"#),
|
||||
("an HTML error page from a proxy", 200, "<html>502</html>"),
|
||||
("a did that does not parse", 200, r#"{"did":"not-a-did"}"#),
|
||||
] {
|
||||
let mut server = mockito::Server::new();
|
||||
let _did = server
|
||||
.mock("GET", "/")
|
||||
.with_status(status)
|
||||
.with_body(body)
|
||||
.create();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(dir.path(), &owner_key, "r", &encoded);
|
||||
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
|
||||
|
||||
assert!(
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
|
||||
.is_none(),
|
||||
"{label} must drop the header, not fail the push"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reverse-proxied `GITLAWB_NODE` carries a path prefix, and that prefix
|
||||
/// survives into the pack URL. `split_pack_post_url` is unit-tested for it, but
|
||||
/// nothing checked that the probe actually goes to the prefixed base — a
|
||||
/// regression there would GET `/` on the proxy host, read whatever landing page
|
||||
/// it serves, and silently drop the header. `.expect(0)` on `/` is the half that
|
||||
/// catches it.
|
||||
#[test]
|
||||
fn delegation_header_probes_the_prefixed_node_base() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_key = bare(&owner.did());
|
||||
|
||||
let mut server = mockito::Server::new();
|
||||
let prefixed = server
|
||||
.mock("GET", "/gitlawb")
|
||||
.with_body(did_body(&node))
|
||||
.create();
|
||||
let root = server.mock("GET", "/").expect(0).create();
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
store_delegation(
|
||||
dir.path(),
|
||||
&owner_key,
|
||||
"r",
|
||||
&push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"))
|
||||
.encode()
|
||||
.expect("encode"),
|
||||
);
|
||||
|
||||
let post_url = format!("{}/gitlawb/{owner_key}/r/git-receive-pack", server.url());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let header =
|
||||
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
|
||||
.expect("a path-prefixed node base must still yield an X-Ucan");
|
||||
|
||||
prefixed.assert();
|
||||
root.assert();
|
||||
assert_eq!(
|
||||
Ucan::decode(&header).expect("decode").payload.aud,
|
||||
node.did()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Where the identity key and the delegation store live.
|
||||
//!
|
||||
//! `gl` and `git-remote-gitlawb` have to agree on this. `gl ucan import` writes a
|
||||
//! delegation to `<dir>/delegations/`, and the helper reads it back from the same
|
||||
//! place when it builds the `X-Ucan` header on push. If the two resolve
|
||||
//! `GITLAWB_KEY` differently the push goes out with no header and the node refuses
|
||||
//! the delegate, with nothing on either side to say why — so the rules live here
|
||||
//! once, in the crate both binaries already depend on, rather than being written
|
||||
//! twice and drifting.
|
||||
//!
|
||||
//! The home directory is a parameter rather than something this module looks up.
|
||||
//! `gitlawb-core` is embedded by every consumer and is held to an explicit
|
||||
//! dependency allowlist (`ci/gitlawb-core-allowed-deps.txt`); the callers already
|
||||
//! carry `dirs`, so taking `home` keeps the rules shared without widening core's
|
||||
//! tree. It also makes every case below testable against a fixed home.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Environment variable naming the identity PEM.
|
||||
pub const KEY_ENV: &str = "GITLAWB_KEY";
|
||||
|
||||
/// Name of the PEM file inside the identity directory.
|
||||
pub const KEY_FILE_NAME: &str = "identity.pem";
|
||||
|
||||
/// Directory under the home directory used when `GITLAWB_KEY` is unset.
|
||||
pub const DEFAULT_DIR_NAME: &str = ".gitlawb";
|
||||
|
||||
/// Absolute path of the identity PEM: `$GITLAWB_KEY`, else
|
||||
/// `<home>/.gitlawb/identity.pem`.
|
||||
///
|
||||
/// An empty `GITLAWB_KEY` counts as unset. That is what a shell leaves behind for
|
||||
/// `FOO=` and for an unset variable expanded into a wrapper script, and reading it
|
||||
/// as a path would resolve against the process working directory instead.
|
||||
pub fn identity_key_path(home: Option<&Path>) -> Result<PathBuf> {
|
||||
// `var_os`, not `var`: `var` folds a non-UTF-8 value into the same `Err` as
|
||||
// unset, so an operator whose key path is not valid UTF-8 would silently get
|
||||
// the default directory rather than theirs — or an error naming the real
|
||||
// problem.
|
||||
match std::env::var_os(KEY_ENV) {
|
||||
Some(raw) if !raw.is_empty() => resolve_key_value(Path::new(&raw), home),
|
||||
_ => Ok(require_home(home)?
|
||||
.join(DEFAULT_DIR_NAME)
|
||||
.join(KEY_FILE_NAME)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory holding `identity.pem` and `delegations/` — the parent of
|
||||
/// [`identity_key_path`].
|
||||
pub fn identity_dir(home: Option<&Path>) -> Result<PathBuf> {
|
||||
let key = identity_key_path(home)?;
|
||||
key.parent().map(Path::to_path_buf).ok_or_else(|| {
|
||||
Error::Key(format!(
|
||||
"{KEY_ENV} has no parent directory: {}",
|
||||
key.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The home directory, demanded only where the value being resolved needs it.
|
||||
///
|
||||
/// An absolute `GITLAWB_KEY` never needs home, so requiring it up front would
|
||||
/// discard a perfectly good key on a host where `dirs::home_dir()` returns `None`
|
||||
/// — no `HOME` and no passwd entry, which is an ordinary container shape. The
|
||||
/// helper would then push unsigned, blaming the home directory for a setting the
|
||||
/// operator had configured correctly.
|
||||
fn require_home(home: Option<&Path>) -> Result<&Path> {
|
||||
home.ok_or_else(|| {
|
||||
Error::Key(format!(
|
||||
"could not determine the home directory, which is needed to resolve this \
|
||||
{KEY_ENV} value. Set {KEY_ENV} to an absolute path to avoid needing it."
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the `GITLAWB_KEY` rules to a raw value.
|
||||
///
|
||||
/// Split out from [`identity_key_path`] so the rules can be tested without setting
|
||||
/// a process-global environment variable, which would make the tests race.
|
||||
fn resolve_key_value(raw: &Path, home: Option<&Path>) -> Result<PathBuf> {
|
||||
let path = expand_tilde(raw, home)?;
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must be an absolute path (got {}). It also determines where \
|
||||
delegations are stored, and `gl` and `git-remote-gitlawb` do not share a \
|
||||
working directory, so a relative path sends them to different stores.",
|
||||
raw.display()
|
||||
)));
|
||||
}
|
||||
if path.parent().is_none() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must name the key file, not the filesystem root (got {}). \
|
||||
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
|
||||
raw.display()
|
||||
)));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Expand a leading `~/`, and only that.
|
||||
///
|
||||
/// `~user` is shell syntax this does not implement; leaving its `~` in place makes
|
||||
/// it fail the absolute-path check with a message that names the real problem,
|
||||
/// which beats resolving it somewhere the operator did not ask for. A bare `~` or
|
||||
/// `~/` is refused outright: it names a directory where a file is required, and
|
||||
/// expanding it to the home directory would put the delegation store beside the
|
||||
/// home directory rather than inside it, since the store is the key's *parent*.
|
||||
///
|
||||
/// Matched on the first path component rather than on a string prefix. That is
|
||||
/// what lets the value stay an `OsStr` end to end: the helper's old
|
||||
/// `str::strip_prefix("~/")` needed a `String` first, which is why it reached for
|
||||
/// `env::var` and folded every non-UTF-8 path into "unset".
|
||||
fn expand_tilde(path: &Path, home: Option<&Path>) -> Result<PathBuf> {
|
||||
let mut components = path.components();
|
||||
match components.next() {
|
||||
Some(Component::Normal(first)) if first == OsStr::new("~") => {
|
||||
let rest = components.as_path();
|
||||
if rest.as_os_str().is_empty() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must name the key file, not a directory (got {}). \
|
||||
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(require_home(home)?.join(rest))
|
||||
}
|
||||
_ => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A home that is absolute on the host running the tests. `/home/op` is not
|
||||
/// absolute on Windows — it has a root but no prefix — so a shared literal
|
||||
/// would make the absolute-path assertions test the wrong thing there.
|
||||
fn home() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\Users\op")
|
||||
} else {
|
||||
PathBuf::from("/home/op")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_value_is_taken_verbatim() {
|
||||
let raw = home().join("data").join("keys").join(KEY_FILE_NAME);
|
||||
assert_eq!(resolve_key_value(&raw, Some(&home())).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_slash_expands_to_the_home_directory() {
|
||||
let resolved = resolve_key_value(Path::new("~/keys/identity.pem"), Some(&home())).unwrap();
|
||||
assert_eq!(resolved, home().join("keys").join(KEY_FILE_NAME));
|
||||
}
|
||||
|
||||
/// The shell-style spelling of the default resolves to the default. Worth
|
||||
/// pinning: this is the form an operator gets by copying a path out of their
|
||||
/// shell, and the two binaries used to reach it by different routes.
|
||||
#[test]
|
||||
fn the_tilde_spelling_of_the_default_resolves_to_the_default() {
|
||||
let resolved =
|
||||
resolve_key_value(Path::new("~/.gitlawb/identity.pem"), Some(&home())).unwrap();
|
||||
assert_eq!(resolved, home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME));
|
||||
}
|
||||
|
||||
/// A relative value resolves against the working directory, and `gl` and
|
||||
/// `git-remote-gitlawb` do not share one: the import would land where the helper
|
||||
/// never looks.
|
||||
#[test]
|
||||
fn relative_values_are_refused() {
|
||||
for raw in ["identity.pem", "keys/identity.pem", "./keys/identity.pem"] {
|
||||
assert!(
|
||||
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
|
||||
"{raw} is relative and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `~user` is shell syntax, not a path, and a bare `~` names a directory where
|
||||
/// a file is required. Refused rather than guessed at.
|
||||
#[test]
|
||||
fn unsupported_tilde_forms_are_refused() {
|
||||
for raw in ["~", "~/", "~someone/keys/identity.pem"] {
|
||||
assert!(
|
||||
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
|
||||
"{raw} must be refused rather than resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The root has no parent, so the delegation store would have nowhere to go.
|
||||
#[test]
|
||||
fn the_filesystem_root_is_refused() {
|
||||
assert!(resolve_key_value(Path::new("/"), Some(&home())).is_err());
|
||||
}
|
||||
|
||||
/// The whole point of `var_os`: a non-UTF-8 value must reach the rules rather
|
||||
/// than being folded into "unset" by `var`. Byte 0xFF is not valid UTF-8 in any
|
||||
/// position, so this value is unreachable through `env::var`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_values_reach_the_rules() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let relative = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
|
||||
assert!(
|
||||
resolve_key_value(Path::new(&relative), Some(&home())).is_err(),
|
||||
"a non-UTF-8 relative path must be refused, not silently defaulted"
|
||||
);
|
||||
|
||||
let mut absolute = OsString::from("/data/");
|
||||
absolute.push(OsString::from_vec(vec![0xFF]));
|
||||
absolute.push("/identity.pem");
|
||||
let resolved = resolve_key_value(Path::new(&absolute), Some(&home())).unwrap();
|
||||
assert_eq!(resolved.as_os_str(), absolute.as_os_str());
|
||||
}
|
||||
|
||||
/// Unset and empty both mean "use the default", and the two accessors must stay
|
||||
/// consistent: the directory is the parent of the key, never a sibling of it.
|
||||
/// The process environment is global, so the two cases share one test and one
|
||||
/// lock rather than racing each other.
|
||||
#[test]
|
||||
fn unset_and_empty_both_select_the_default_directory() {
|
||||
use std::sync::Mutex;
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let restore = std::env::var_os(KEY_ENV);
|
||||
|
||||
std::env::remove_var(KEY_ENV);
|
||||
let unset = (
|
||||
identity_key_path(Some(&home())),
|
||||
identity_dir(Some(&home())),
|
||||
);
|
||||
std::env::set_var(KEY_ENV, "");
|
||||
let empty = (
|
||||
identity_key_path(Some(&home())),
|
||||
identity_dir(Some(&home())),
|
||||
);
|
||||
|
||||
match restore {
|
||||
Some(v) => std::env::set_var(KEY_ENV, v),
|
||||
None => std::env::remove_var(KEY_ENV),
|
||||
}
|
||||
|
||||
for (label, (key, dir)) in [("unset", unset), ("empty", empty)] {
|
||||
assert_eq!(
|
||||
key.unwrap(),
|
||||
home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME),
|
||||
"{label} key path"
|
||||
);
|
||||
assert_eq!(
|
||||
dir.unwrap(),
|
||||
home().join(DEFAULT_DIR_NAME),
|
||||
"{label} directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod no_home_tests {
|
||||
use super::*;
|
||||
|
||||
/// An absolute key needs no home directory. Demanding one up front discarded a
|
||||
/// correctly-configured `GITLAWB_KEY` on any host where `dirs::home_dir()`
|
||||
/// returns `None` — no `HOME` and no passwd entry, an ordinary container shape —
|
||||
/// and the helper then pushed unsigned while blaming the home directory.
|
||||
#[test]
|
||||
fn an_absolute_key_resolves_without_a_home_directory() {
|
||||
let raw = if cfg!(windows) {
|
||||
r"C:\data\keys\identity.pem"
|
||||
} else {
|
||||
"/data/keys/identity.pem"
|
||||
};
|
||||
let resolved = resolve_key_value(Path::new(raw), None)
|
||||
.expect("an absolute key must not need a home directory");
|
||||
assert_eq!(resolved, PathBuf::from(raw));
|
||||
assert_eq!(resolved.parent().unwrap(), Path::new(raw).parent().unwrap());
|
||||
}
|
||||
|
||||
/// The forms that genuinely need a home still say so, rather than resolving
|
||||
/// somewhere arbitrary.
|
||||
#[test]
|
||||
fn the_forms_that_need_a_home_report_its_absence() {
|
||||
let err = resolve_key_value(Path::new("~/keys/identity.pem"), None)
|
||||
.expect_err("a ~/ path cannot resolve without a home directory");
|
||||
assert!(
|
||||
err.to_string().contains("home directory"),
|
||||
"the error must name the missing home directory, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
pub mod identity_path;
|
||||
// `url` is the one dependency here that drags a tail (idna, then the icu
|
||||
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
|
||||
// needs this predicate already parses URLs, so they opt in and nothing else
|
||||
|
||||
@@ -30,10 +30,30 @@ pub fn is_bidi_format(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Both halves of INV-6 in one call: drop every `Cc` control (which defangs
|
||||
/// ANSI and OSC escapes) and every bidi/format control that
|
||||
/// [`is_bidi_format`] names. What remains is safe to write to a terminal.
|
||||
///
|
||||
/// Length is not this function's business: a caller that shows the text
|
||||
/// decides how much of it to show.
|
||||
pub fn strip_terminal_controls(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !is_bidi_format(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_removes_escapes_and_reordering_but_keeps_text() {
|
||||
let hostile = "ok\u{1b}[31m red\u{1b}]0;title\u{7}\u{202E}dlrow\u{202C} \u{0627}\u{200D}";
|
||||
let clean = strip_terminal_controls(hostile);
|
||||
assert_eq!(clean, "ok[31m red]0;titledlrow \u{0627}\u{200D}");
|
||||
assert!(!clean.chars().any(|c| c.is_control() || is_bidi_format(c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_every_reordering_code_point() {
|
||||
// Asserted individually so a partial predicate (e.g. one that covers the
|
||||
|
||||
+741
-29
@@ -16,6 +16,19 @@ use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Proof chains deeper than this fail closed, in every walk over `prf`:
|
||||
/// [`Ucan::verify_chain`], [`Ucan::chain_lifetime_is_bounded`] and
|
||||
/// [`Ucan::chain_grants_push_to`] all stop here. Nothing this codebase mints is
|
||||
/// longer than owner → agent → node. The bound exists for the hand-built token:
|
||||
/// the node verifies the chain on the `X-Ucan` header of any signed request, and
|
||||
/// nothing else says how deep a sender may nest `prf`. In practice not very
|
||||
/// deep — a proof is a JSON string inside the next link's JSON, so each level
|
||||
/// re-escapes the one beneath it and the encoding roughly doubles per link
|
||||
/// (eight links is about 11 KiB, thirteen about 270 KiB) — but a walk should
|
||||
/// refuse on its own terms rather than count on the encoding running the sender
|
||||
/// out of room first.
|
||||
pub const MAX_CHAIN_DEPTH: usize = 8;
|
||||
|
||||
/// A UCAN capability: what resource the token grants access to.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Capability {
|
||||
@@ -49,11 +62,32 @@ impl Capability {
|
||||
/// 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.
|
||||
///
|
||||
/// Constraints (`nb`) participate, and conservatively:
|
||||
///
|
||||
/// | parent | child | verdict |
|
||||
/// |---|---|---|
|
||||
/// | none | anything | attenuated — adding constraints narrows |
|
||||
/// | some | identical | attenuated |
|
||||
/// | some | different | refused — narrowing is unprovable without semantics |
|
||||
/// | some | none | refused — dropping constraints widens |
|
||||
///
|
||||
/// The last row is the one that matters. Ignoring `nb` here let a holder of a
|
||||
/// constrained capability re-delegate the same resource and action with the
|
||||
/// constraints removed, and the chain still verified — so a consumer that
|
||||
/// refuses constrained capabilities at the leaf saw an unconstrained one and
|
||||
/// granted it. Since `nb` has no interpreted semantics yet, "different" cannot
|
||||
/// be shown to be narrower and is refused with it.
|
||||
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
|
||||
let constraints_ok = match (&parent.constraints, &self.constraints) {
|
||||
(None, _) => true,
|
||||
(Some(p), Some(c)) => p == c,
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
resource_ok && action_ok && constraints_ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +105,138 @@ pub mod caps {
|
||||
pub const REPO_ADMIN: &str = "repo/admin";
|
||||
}
|
||||
|
||||
/// Rules shared by every boundary that decides whether a UCAN can push.
|
||||
///
|
||||
/// Four places answer "can this token push to this repository?": `gl ucan
|
||||
/// delegate` and the MCP `ucan_delegate` tool when a token is issued, `gl ucan
|
||||
/// import` when one is stored, `git-remote-gitlawb` when it mints an invocation,
|
||||
/// and the node when it authorizes the push. For three review rounds each of them
|
||||
/// carried its own copy of the answer, and every round found a token that one
|
||||
/// boundary accepted and the next refused: a constrained grant that imported and
|
||||
/// then authorized nothing; a wildcard proof the helper narrowed and the node
|
||||
/// rejected; a resource whose owner the leaf named but the chain root did not.
|
||||
///
|
||||
/// The rules live here, once, in the crate all four already depend on. A boundary
|
||||
/// may add checks of its own — the node anchors the chain root to a repository
|
||||
/// record it holds independently, import binds the audience to the local key —
|
||||
/// but the definition of a *usable push capability* is not one of them.
|
||||
pub mod push {
|
||||
use super::{caps, Capability, Ucan};
|
||||
|
||||
/// The `did:key` representation rule: `did:key:z6Mk…` and bare `z6Mk…` are
|
||||
/// the same identity. Mirror rows store the bare form; canonical rows and
|
||||
/// every token store the full form.
|
||||
///
|
||||
/// Collapses representation only within `did:key`. `did:web` and
|
||||
/// `did:gitlawb` share the base58 space, so a trailing-segment compare would
|
||||
/// treat `did:key:X` and `did:gitlawb:X` as equal; after stripping the prefix,
|
||||
/// a value that still contains `:` is a non-key DID and matches nothing bare.
|
||||
pub fn did_key_eq(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
fn key_id(d: &str) -> &str {
|
||||
d.strip_prefix("did:key:").unwrap_or(d)
|
||||
}
|
||||
let (ka, kb) = (key_id(a), key_id(b));
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// The actions that authorize a push: `git/push` itself, the action wildcard,
|
||||
/// and `repo/admin`, which covers it.
|
||||
pub fn is_push_action(can: &str) -> bool {
|
||||
can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN
|
||||
}
|
||||
|
||||
/// `gitlawb://repos/<owner>/<repo>` → `(owner, repo)`, or `None` for any other
|
||||
/// shape. Exactly two non-empty segments: an owner DID never contains `/`, so
|
||||
/// a third segment, a trailing slash, or an empty half is malformed rather than
|
||||
/// something to interpret.
|
||||
pub fn parse_repo_resource(with: &str) -> Option<(&str, &str)> {
|
||||
let rest = with.strip_prefix("gitlawb://repos/")?;
|
||||
let (owner, repo) = rest.split_once('/')?;
|
||||
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some((owner, repo))
|
||||
}
|
||||
|
||||
/// A push-class capability whose resource is the wildcard. Refused at
|
||||
/// issuance, at import, by the helper, and by the node: a delegation's scope is
|
||||
/// fixed when it is issued, and `*` cannot say which repositories it covered
|
||||
/// at that moment.
|
||||
pub fn is_push_wildcard(with: &str, can: &str) -> bool {
|
||||
with == "*" && is_push_action(can)
|
||||
}
|
||||
|
||||
pub use super::MAX_CHAIN_DEPTH;
|
||||
|
||||
impl Capability {
|
||||
/// Push-class, unconstrained, and naming exactly this repository.
|
||||
///
|
||||
/// Unconstrained because `nb` has no semantics yet: an owner who wrote
|
||||
/// constraints meant to restrict, and honouring the capability while
|
||||
/// ignoring them would grant more than was intended. The owner segment is
|
||||
/// compared with [`did_key_eq`], so a token issued against the full DID
|
||||
/// matches a mirror row keyed on the bare form.
|
||||
pub fn grants_push_to(&self, owner: &str, repo: &str) -> bool {
|
||||
self.constraints.is_none()
|
||||
&& is_push_action(&self.can)
|
||||
&& parse_repo_resource(&self.with)
|
||||
.is_some_and(|(o, r)| did_key_eq(o, owner) && r == repo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ucan {
|
||||
/// Whether this chain — the leaf **and every proof behind it** — carries a
|
||||
/// capability that [`Capability::grants_push_to`] this repository.
|
||||
///
|
||||
/// Every link, not just the leaf. [`Capability::is_attenuated_by`] accepts
|
||||
/// a concrete child under a `*` parent, so a leaf that names the repository
|
||||
/// can sit on a proof that names every repository the root owns — including
|
||||
/// ones created after the delegation was issued. Checking the leaf alone
|
||||
/// let exactly that through.
|
||||
///
|
||||
/// This establishes scope, not trust. It does not verify signatures,
|
||||
/// expiry, or audience; call [`Ucan::verify_chain`] first, and anchor the
|
||||
/// root it returns to something held independently of the token.
|
||||
///
|
||||
/// Fails closed on anything it cannot vouch for: a proof that does not
|
||||
/// decode, more than one proof per link, or a chain deeper than
|
||||
/// [`MAX_CHAIN_DEPTH`].
|
||||
pub fn chain_grants_push_to(&self, owner: &str, repo: &str) -> bool {
|
||||
self.chain_grants_push_to_at(owner, repo, 0)
|
||||
}
|
||||
|
||||
fn chain_grants_push_to_at(&self, owner: &str, repo: &str, depth: usize) -> bool {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.any(|c| c.grants_push_to(owner, repo))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// `verify_chain` refuses more than one proof per link for the same
|
||||
// reason: two proofs mean two roots, and nothing says which authorized
|
||||
// what.
|
||||
if self.payload.prf.len() > 1 {
|
||||
return false;
|
||||
}
|
||||
match self.payload.prf.first() {
|
||||
None => true,
|
||||
Some(token) => match Ucan::decode(token) {
|
||||
Ok(proof) => proof.chain_grants_push_to_at(owner, repo, depth + 1),
|
||||
Err(_) => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The UCAN payload (what gets signed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UcanPayload {
|
||||
@@ -146,6 +312,37 @@ impl Ucan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every link in this chain carries a finite `exp`.
|
||||
///
|
||||
/// `exp` is optional in the format, and [`Self::is_expired`] reports `false`
|
||||
/// when it is absent — so a link without one never expires. With no revocation
|
||||
/// mechanism, a chain containing such a link is a permanent grant: a leaked
|
||||
/// token cannot be withdrawn, and the issuer's only remedy is to rotate the
|
||||
/// identity the resource is keyed on.
|
||||
///
|
||||
/// Consumers that turn a UCAN into write authority should require this. It is
|
||||
/// deliberately not enforced inside [`Self::verify_chain`], because a
|
||||
/// non-expiring token is well-formed and may be perfectly appropriate for a
|
||||
/// read-only or advisory capability; whether an unbounded grant is acceptable
|
||||
/// is the consumer's policy, not the format's.
|
||||
///
|
||||
/// Fails closed past [`MAX_CHAIN_DEPTH`], like every other walk over `prf`.
|
||||
pub fn chain_lifetime_is_bounded(&self) -> bool {
|
||||
self.chain_lifetime_is_bounded_at(0)
|
||||
}
|
||||
|
||||
fn chain_lifetime_is_bounded_at(&self, depth: usize) -> bool {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if self.payload.exp.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.payload.prf.iter().all(|token| {
|
||||
Self::decode(token).is_ok_and(|proof| proof.chain_lifetime_is_bounded_at(depth + 1))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -248,8 +445,33 @@ impl Ucan {
|
||||
/// 3. Check the proof is not expired
|
||||
/// 4. Recursively verify the proof's own chain
|
||||
///
|
||||
/// A UCAN with no proofs (root capability) passes trivially.
|
||||
pub fn verify_chain(&self) -> Result<()> {
|
||||
/// A UCAN with no proofs is its own root, so it returns its own issuer.
|
||||
///
|
||||
/// **This establishes internal consistency, not trust.** `did:key` is
|
||||
/// self-certifying, so anyone can mint a keypair and produce a chain that
|
||||
/// verifies. A caller making an authorization decision MUST compare the
|
||||
/// returned root against an identity it trusts for some reason outside this
|
||||
/// token — a repo owner, a configured value, a registry lookup. Discarding
|
||||
/// the return value is only correct when the caller is checking that a token
|
||||
/// is well-formed and deliberately does not care who issued it.
|
||||
///
|
||||
/// The walk stops at [`MAX_CHAIN_DEPTH`] and fails closed there. The node
|
||||
/// runs this on the `X-Ucan` header of any signed request before it asks
|
||||
/// what the chain grants, so this is the first place sender-chosen depth is
|
||||
/// felt: each link costs a signature check and a decode of everything
|
||||
/// beneath it. The scope walk was bounded and this one was not, which made
|
||||
/// that bound decorative — a deep chain met the unbounded walk first.
|
||||
pub fn verify_chain(&self) -> Result<Did> {
|
||||
self.verify_chain_at(0)
|
||||
}
|
||||
|
||||
fn verify_chain_at(&self, depth: usize) -> Result<Did> {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain deeper than {MAX_CHAIN_DEPTH} links is not accepted"
|
||||
)));
|
||||
}
|
||||
|
||||
// First verify our own signature
|
||||
self.verify_signature()?;
|
||||
|
||||
@@ -261,34 +483,44 @@ impl Ucan {
|
||||
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}")))?;
|
||||
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain broken: proof audience {} does not match issuer {}",
|
||||
proof.payload.aud, self.payload.iss
|
||||
)));
|
||||
}
|
||||
|
||||
// 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()?;
|
||||
if self.payload.prf.len() > 1 {
|
||||
return Err(Error::Ucan(
|
||||
"multi-proof chains are not supported: more than one proof means \
|
||||
more than one root, and which root authorized a given capability \
|
||||
is ambiguous"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let Some(proof_token) = self.payload.prf.first() else {
|
||||
// No proofs: this token is its own root.
|
||||
return Ok(self.payload.iss.clone());
|
||||
};
|
||||
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain broken: proof audience {} does not match issuer {}",
|
||||
proof.payload.aud, self.payload.iss
|
||||
)));
|
||||
}
|
||||
|
||||
// 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse; the root of the proof's chain is the root of ours.
|
||||
proof.verify_chain_at(depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,4 +896,484 @@ mod tests {
|
||||
|
||||
delegated.verify_chain().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_returns_the_root_issuer_of_a_delegated_chain() {
|
||||
// owner -> agent (delegation), agent -> node (invocation).
|
||||
// The root is the owner: that is the identity the whole chain rests on,
|
||||
// and the only one a caller can meaningfully anchor a trust decision to.
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
|
||||
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
|
||||
.expect("wrap invocation");
|
||||
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("chain must verify"),
|
||||
owner.did(),
|
||||
"the root issuer is the owner who started the chain, not the agent presenting it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A chain is only bounded if EVERY link is. An unbounded link anywhere makes
|
||||
/// the whole grant permanent, because `is_expired` reports false for it and
|
||||
/// there is no revocation path to withdraw it.
|
||||
#[test]
|
||||
fn chain_lifetime_is_bounded_requires_an_expiry_on_every_link() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
let hour = Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let bounded_root = Ucan::issue(&owner, agent.did(), cap(), Some(hour)).expect("issue");
|
||||
let unbounded_root = Ucan::issue(&owner, agent.did(), cap(), None).expect("issue");
|
||||
|
||||
assert!(
|
||||
Ucan::delegate(&agent, node.did(), cap(), Some(hour), &bounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"both links finite"
|
||||
);
|
||||
assert!(
|
||||
!Ucan::delegate(&agent, node.did(), cap(), None, &bounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"the leaf has no expiry, so the grant never lapses"
|
||||
);
|
||||
assert!(
|
||||
!Ucan::delegate(&agent, node.did(), cap(), Some(hour), &unbounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"a bounded leaf cannot rescue an unbounded proof: the holder can always \
|
||||
mint a fresh leaf from it"
|
||||
);
|
||||
assert!(
|
||||
!unbounded_root.chain_lifetime_is_bounded(),
|
||||
"a self-issued token with no expiry is itself unbounded"
|
||||
);
|
||||
}
|
||||
|
||||
/// A three-link chain: owner -> lead -> agent, which is the real shape of an
|
||||
/// org delegating to a team lead who delegates to a CI identity.
|
||||
///
|
||||
/// Every other chain here is depth two, where the immediate proof IS the root —
|
||||
/// so nothing distinguishes recursing to the true root from simply returning the
|
||||
/// proof's issuer. Both `assert_eq!` and `assert_ne!` below are load-bearing:
|
||||
/// without the second, returning the middle issuer would still satisfy a test
|
||||
/// that only checked "not the leaf".
|
||||
#[test]
|
||||
fn verify_chain_walks_past_the_immediate_proof_to_the_true_root() {
|
||||
let owner = Keypair::generate();
|
||||
let lead = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let root = Ucan::issue(&owner, lead.did(), cap(), None).expect("owner -> lead");
|
||||
let mid = Ucan::delegate(&lead, agent.did(), cap(), None, &root).expect("lead -> agent");
|
||||
let leaf = Ucan::delegate(&agent, node.did(), cap(), None, &mid).expect("agent -> node");
|
||||
|
||||
let found = leaf.verify_chain().expect("a three-link chain must verify");
|
||||
assert_eq!(
|
||||
found,
|
||||
owner.did(),
|
||||
"the root is the owner who started the chain, two hops up"
|
||||
);
|
||||
assert_ne!(
|
||||
found,
|
||||
lead.did(),
|
||||
"returning the immediate proof's issuer is not walking to the root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_returns_self_as_root_for_a_self_issued_token() {
|
||||
// A token with no proofs roots at its own issuer. This is what makes a
|
||||
// self-minted token useless: the caller compares this against the repo
|
||||
// owner and it will only ever match when the presenter IS the owner.
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let ucan =
|
||||
Ucan::issue(&agent, node.did(), vec![Capability::new("*", "*")], None).expect("issue");
|
||||
|
||||
assert_eq!(
|
||||
ucan.verify_chain().expect("a root token still verifies"),
|
||||
agent.did(),
|
||||
"a self-minted token roots at the minter, however permissive its capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stripping `nb` is a widening, and a widening must fail attenuation.
|
||||
///
|
||||
/// Without this, a constrained delegation is trivially escalated: the holder
|
||||
/// re-delegates the same resource and action with the constraints removed,
|
||||
/// `verify_chain` accepts the chain because attenuation only compared `with`
|
||||
/// and `can`, and a consumer that refuses constrained capabilities at the leaf
|
||||
/// (as the node's push gate does) then sees an unconstrained one and grants it.
|
||||
/// Guarding only the leaf guards the wrong end of the chain.
|
||||
#[test]
|
||||
fn verify_chain_rejects_a_child_that_strips_the_parents_constraints() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }));
|
||||
let delegation = Ucan::issue(&owner, agent.did(), vec![constrained], None)
|
||||
.expect("issue constrained delegation");
|
||||
|
||||
// Same resource, same action, constraints dropped.
|
||||
let widened = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
|
||||
let forged =
|
||||
Ucan::delegate(&agent, node.did(), vec![widened], None, &delegation).expect("wrap");
|
||||
|
||||
let err = forged
|
||||
.verify_chain()
|
||||
.expect_err("dropping the parent's constraints must fail attenuation");
|
||||
assert!(
|
||||
err.to_string().contains("attenuation"),
|
||||
"the failure must name attenuation, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_accepts_a_child_that_keeps_the_parents_constraints() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let nb = serde_json::json!({ "refs": ["refs/heads/feat/*"] });
|
||||
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(nb.clone());
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), vec![constrained.clone()], None).expect("issue");
|
||||
let invocation =
|
||||
Ucan::delegate(&agent, node.did(), vec![constrained], None, &delegation).expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation
|
||||
.verify_chain()
|
||||
.expect("an unchanged constraint must verify"),
|
||||
owner.did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconstrained_parent_still_allows_a_child_to_add_constraints() {
|
||||
// Adding `nb` narrows, which is always a legal attenuation.
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let open = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
|
||||
let delegation = Ucan::issue(&owner, agent.did(), vec![open], None).expect("issue");
|
||||
let narrowed = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/main"] }));
|
||||
let invocation =
|
||||
Ucan::delegate(&agent, node.did(), vec![narrowed], None, &delegation).expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("narrowing must verify"),
|
||||
owner.did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_rejects_a_multi_proof_chain() {
|
||||
// Two proofs mean two roots, and nothing says which root authorized a
|
||||
// given capability. Returning either one would be unsound, so refuse.
|
||||
let owner_a = Keypair::generate();
|
||||
let owner_b = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let proof_a = Ucan::issue(&owner_a, agent.did(), caps_vec.clone(), None).expect("issue a");
|
||||
let proof_b = Ucan::issue(&owner_b, agent.did(), caps_vec.clone(), None).expect("issue b");
|
||||
|
||||
// `delegate` only ever writes one proof, so build the two-proof payload by hand.
|
||||
let payload = UcanPayload {
|
||||
ucan: "1.0.0".to_string(),
|
||||
iss: agent.did(),
|
||||
aud: node.did(),
|
||||
att: caps_vec,
|
||||
exp: None,
|
||||
nbf: None,
|
||||
prf: vec![
|
||||
proof_a.encode().expect("encode a"),
|
||||
proof_b.encode().expect("encode b"),
|
||||
],
|
||||
};
|
||||
let signing_bytes = serde_json::to_vec(&payload).expect("serialize payload");
|
||||
let s = agent.sign_b64(&signing_bytes);
|
||||
let multi = Ucan { payload, s };
|
||||
|
||||
let err = multi
|
||||
.verify_chain()
|
||||
.expect_err("a two-proof chain must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("multi-proof"),
|
||||
"the error must name the reason, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod push_scope_tests {
|
||||
use super::push::*;
|
||||
use super::{caps, Capability, Ucan};
|
||||
use crate::identity::Keypair;
|
||||
|
||||
fn hour() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() + chrono::Duration::hours(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_key_eq_collapses_representation_only_within_did_key() {
|
||||
assert!(did_key_eq("did:key:z6MkAbc", "z6MkAbc"));
|
||||
assert!(did_key_eq("z6MkAbc", "did:key:z6MkAbc"));
|
||||
assert!(did_key_eq("did:key:z6MkAbc", "did:key:z6MkAbc"));
|
||||
assert!(!did_key_eq("did:key:z6MkAbc", "did:key:z6MkXyz"));
|
||||
// A bare id must never match across methods: `did:gitlawb` shares the
|
||||
// base58 space with `did:key`.
|
||||
assert!(!did_key_eq("did:gitlawb:z6MkAbc", "z6MkAbc"));
|
||||
assert!(!did_key_eq("did:key:z6MkAbc", "did:gitlawb:z6MkAbc"));
|
||||
assert!(!did_key_eq("did:web:example.com", "example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repo_resource_requires_exactly_two_segments() {
|
||||
assert_eq!(
|
||||
parse_repo_resource("gitlawb://repos/did:key:z6Mk/r"),
|
||||
Some(("did:key:z6Mk", "r"))
|
||||
);
|
||||
for bad in [
|
||||
"*",
|
||||
"",
|
||||
"gitlawb://repos/",
|
||||
"gitlawb://repos/owner",
|
||||
"gitlawb://repos/owner/",
|
||||
"gitlawb://repos//r",
|
||||
"gitlawb://repos/owner/r/extra",
|
||||
"https://repos/owner/r",
|
||||
] {
|
||||
assert_eq!(parse_repo_resource(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_push_wildcard_is_the_resource_wildcard_with_a_push_action() {
|
||||
for can in [caps::GIT_PUSH, "*", caps::REPO_ADMIN] {
|
||||
assert!(is_push_wildcard("*", can), "{can}");
|
||||
}
|
||||
assert!(!is_push_wildcard("*", caps::GIT_FETCH));
|
||||
assert!(!is_push_wildcard("*", caps::PR_OPEN));
|
||||
assert!(!is_push_wildcard("gitlawb://repos/o/r", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capability_grants_push_only_when_concrete_unconstrained_and_push_class() {
|
||||
let owner = "did:key:z6MkOwner";
|
||||
let ok = Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH);
|
||||
assert!(ok.grants_push_to(owner, "r"));
|
||||
// Bare and full owner forms are the same identity.
|
||||
assert!(ok.grants_push_to("z6MkOwner", "r"));
|
||||
|
||||
assert!(!Capability::new("*", caps::GIT_PUSH).grants_push_to(owner, "r"));
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_FETCH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/other", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOther/r", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({"max_bytes": 1}))
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
for can in ["*", caps::REPO_ADMIN] {
|
||||
assert!(
|
||||
Capability::new("gitlawb://repos/did:key:z6MkOwner/r", can)
|
||||
.grants_push_to(owner, "r"),
|
||||
"{can} covers git/push"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The chain walk, at every depth: a wildcard anywhere denies; concrete
|
||||
/// everywhere grants. The grandparent case is the one a single-level check
|
||||
/// misses, because the immediate proof already names the repo.
|
||||
#[test]
|
||||
fn chain_grants_push_only_when_every_link_names_the_repo() {
|
||||
let owner = Keypair::generate();
|
||||
let a = Keypair::generate();
|
||||
let b = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let concrete = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
let wild = || vec![Capability::new("*", caps::GIT_PUSH)];
|
||||
|
||||
// Single self-issued link.
|
||||
assert!(Ucan::issue(&owner, a.did(), concrete(), Some(hour()))
|
||||
.unwrap()
|
||||
.chain_grants_push_to(&owner_s, "r"));
|
||||
assert!(!Ucan::issue(&owner, a.did(), wild(), Some(hour()))
|
||||
.unwrap()
|
||||
.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Two links: concrete leaf on a wildcard proof must deny.
|
||||
let wild_proof = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
|
||||
let leaf = Ucan::delegate(&a, node.did(), concrete(), Some(hour()), &wild_proof).unwrap();
|
||||
assert!(
|
||||
leaf.verify_chain().is_ok(),
|
||||
"attenuation accepts it — that is why we walk"
|
||||
);
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Three links: wildcard grandparent, concrete parent, concrete leaf.
|
||||
let gp = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
|
||||
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
|
||||
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
|
||||
assert!(leaf.verify_chain().is_ok());
|
||||
assert!(
|
||||
!leaf.chain_grants_push_to(&owner_s, "r"),
|
||||
"a wildcard two links up must deny"
|
||||
);
|
||||
|
||||
// Three links, all concrete.
|
||||
let gp = Ucan::issue(&owner, a.did(), concrete(), Some(hour())).unwrap();
|
||||
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
|
||||
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
|
||||
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
// And the bare owner form is the same repo.
|
||||
assert!(leaf.chain_grants_push_to(owner_s.strip_prefix("did:key:").unwrap(), "r"));
|
||||
// But not another repo.
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "other"));
|
||||
}
|
||||
|
||||
/// Fail closed on anything the walk cannot vouch for.
|
||||
#[test]
|
||||
fn chain_walk_fails_closed_on_undecodable_or_multi_proofs_or_excess_depth() {
|
||||
let owner = Keypair::generate();
|
||||
let a = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
|
||||
let mut broken = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
broken.payload.prf = vec!["not a ucan".into()];
|
||||
assert!(!broken.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
let good = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
let mut two = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
two.payload.prf = vec![good.encode().unwrap(), good.encode().unwrap()];
|
||||
assert!(!two.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Deeper than MAX_CHAIN_DEPTH, all concrete: refused on depth alone.
|
||||
let mut cur = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
let mut signer = a;
|
||||
for _ in 0..MAX_CHAIN_DEPTH {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
assert!(
|
||||
!cur.chain_grants_push_to(&owner_s, "r"),
|
||||
"depth bound must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every walk over `prf` stops at the same depth. The round-ten P1: the scope
|
||||
/// walk was bounded while `verify_chain` and `chain_lifetime_is_bounded` were
|
||||
/// not, and the node runs `verify_chain` on an attacker-supplied `X-Ucan` header
|
||||
/// before the bounded walk ever sees it, so the bound protected nothing.
|
||||
#[cfg(test)]
|
||||
mod chain_depth_tests {
|
||||
use super::{caps, Capability, Ucan, MAX_CHAIN_DEPTH};
|
||||
use crate::identity::Keypair;
|
||||
|
||||
fn hour() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() + chrono::Duration::hours(1)
|
||||
}
|
||||
|
||||
/// A well-formed, fully signed, expiring chain of exactly `links` links, every
|
||||
/// one naming the owner's repository, so the only thing any walk can object
|
||||
/// to is how long it is. Returns the leaf and the owner it roots at.
|
||||
fn concrete_chain(links: usize) -> (Ucan, Keypair) {
|
||||
assert!(links >= 1);
|
||||
let owner = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
|
||||
let mut signer = Keypair::generate();
|
||||
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour())).unwrap();
|
||||
for _ in 1..links {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
(cur, owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_at_the_bound_passes_every_walk() {
|
||||
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH);
|
||||
let owner_s = owner.did().to_string();
|
||||
assert_eq!(
|
||||
leaf.verify_chain()
|
||||
.expect("a chain at the bound must verify"),
|
||||
owner.did()
|
||||
);
|
||||
assert!(leaf.chain_lifetime_is_bounded());
|
||||
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_link_past_the_bound_fails_every_walk_closed() {
|
||||
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH + 1);
|
||||
let owner_s = owner.did().to_string();
|
||||
|
||||
let err = leaf
|
||||
.verify_chain()
|
||||
.expect_err("verify_chain must refuse a chain past the bound");
|
||||
assert!(
|
||||
err.to_string().contains("deeper than"),
|
||||
"the refusal must say depth was the reason, not a fake signature failure: {err}"
|
||||
);
|
||||
assert!(
|
||||
!leaf.chain_lifetime_is_bounded(),
|
||||
"an unwalkable chain cannot be vouched for as bounded"
|
||||
);
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
}
|
||||
|
||||
/// A chain well past the bound is refused from the top, before any per-link
|
||||
/// work. Only a few links past it, deliberately: a proof is embedded as a JSON
|
||||
/// string inside the next link's JSON, so every level re-escapes the one
|
||||
/// inside it and the encoding roughly doubles per link — eight links is about
|
||||
/// 10 KiB, sixteen is megabytes, and a chain of "a few hundred levels" cannot
|
||||
/// physically be encoded, let alone sent as a header. The bound is still
|
||||
/// what stops the reachable dozen-odd levels from each costing a decode of
|
||||
/// everything beneath them.
|
||||
#[test]
|
||||
fn a_chain_well_past_the_bound_is_refused_not_walked() {
|
||||
let (leaf, _) = concrete_chain(MAX_CHAIN_DEPTH + 4);
|
||||
let err = leaf.verify_chain().expect_err("must refuse");
|
||||
assert!(err.to_string().contains("deeper than"), "{err}");
|
||||
assert!(!leaf.chain_lifetime_is_bounded());
|
||||
}
|
||||
}
|
||||
@@ -33,15 +33,9 @@ sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chron
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
bytes = "1"
|
||||
libc = "0.2"
|
||||
async-trait = "0.1"
|
||||
cid = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
# Content etags for repo archives: S3 single-part ETags are the body MD5, so a
|
||||
# client-side MD5 lets the uploader record its *intended* etag before the PUT
|
||||
# (crash-recovery provenance for the pending-upload marker). Not used for
|
||||
# anything security-sensitive.
|
||||
md-5 = "0.10"
|
||||
hmac = { workspace = true }
|
||||
http-body-util = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
@@ -63,7 +57,7 @@ aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "d
|
||||
aws-config = { version = "1", features = ["behavior-version-latest"] }
|
||||
async-compression = { version = "0.4", features = ["tokio", "zstd"] }
|
||||
tar = "0.4"
|
||||
zstd = { version = "0.13", features = ["zstdmt"] }
|
||||
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.
|
||||
|
||||
@@ -2333,7 +2333,7 @@ pub(crate) enum MarkerQuery {
|
||||
|
||||
// Test-only fault-injection seam for the `needs_scan` marker pair
|
||||
// (`pin_sources_at_cap`, `pin_sources_incomplete`), same idea as
|
||||
// `RepoStore::storage_stall`: hold one specific await open so the clamp around it is
|
||||
// `RepoStore::tigris_stall`: hold one specific await open so the clamp around it is
|
||||
// the one observed to fire.
|
||||
//
|
||||
// A `LOCK TABLE` fixture cannot isolate these two. `pin_sources_at_cap` reads
|
||||
@@ -3757,12 +3757,10 @@ mod tests {
|
||||
// parallel test run); the silent local endpoint stalls the HEAD
|
||||
// deterministically.
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let blob: std::sync::Arc<dyn crate::storage::BlobStore> = std::sync::Arc::new(
|
||||
crate::storage::s3::S3BlobStore::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await,
|
||||
);
|
||||
let archive = crate::storage::archive::RepoArchive::new(blob);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(archive), pool);
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.git_acquire_timeout_secs = 1;
|
||||
@@ -3832,12 +3830,10 @@ mod tests {
|
||||
// consults the silent local endpoint and stalls to the 1s timeout
|
||||
// (endpoint-pinned test client, no AWS_* env reads).
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let blob: std::sync::Arc<dyn crate::storage::BlobStore> = std::sync::Arc::new(
|
||||
crate::storage::s3::S3BlobStore::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await,
|
||||
);
|
||||
let archive = crate::storage::archive::RepoArchive::new(blob);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(archive), pool);
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.git_acquire_timeout_secs = 1;
|
||||
state.config = Arc::new(cfg);
|
||||
@@ -7959,12 +7955,10 @@ mod tests {
|
||||
// consults the silent local endpoint and stalls past the budget
|
||||
// (endpoint-pinned test client, no AWS_* env reads).
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let blob: std::sync::Arc<dyn crate::storage::BlobStore> = std::sync::Arc::new(
|
||||
crate::storage::s3::S3BlobStore::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await,
|
||||
);
|
||||
let archive = crate::storage::archive::RepoArchive::new(blob);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(archive), pool);
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.ipfs_request_budget_secs = 1;
|
||||
cfg.git_acquire_timeout_secs = 2;
|
||||
|
||||
@@ -73,32 +73,10 @@ pub async fn create_issue(
|
||||
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to storage only on success.
|
||||
let release_result = guard.release(create_result.is_ok()).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()))?;
|
||||
// A durable-upload failure is recoverable ONLY while the pending-upload
|
||||
// marker protects the committed mutation (the next successful upload
|
||||
// re-syncs storage). Verify the marker actually exists before choosing to
|
||||
// succeed: if the marker write itself also failed, nothing protects the
|
||||
// mutation from a stale-archive rollback, and the request must fail.
|
||||
// (Succeeding here avoids non-idempotent retries: a retried create mints
|
||||
// a second issue UUID and both eventually publish.)
|
||||
if let Err(e) = release_result {
|
||||
if state
|
||||
.repo_store
|
||||
.pending_marker_exists(&record.owner_did, &record.name)
|
||||
{
|
||||
tracing::error!(repo = %record.name, issue = %issue_id, err = %e,
|
||||
"issue committed locally but durable upload failed — storage re-syncs on next upload");
|
||||
} else {
|
||||
tracing::error!(repo = %record.name, issue = %issue_id, err = %e,
|
||||
"issue committed locally with NO durable protection — failing the request");
|
||||
return Err(AppError::Git(format!(
|
||||
"issue stored locally but durability could not be guaranteed: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Bump trust score for the issue author — increment current score by 0.05
|
||||
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only file issues)
|
||||
@@ -271,11 +249,11 @@ pub async fn close_issue(
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
Ok(None) => {
|
||||
let _ = guard.release(false).await;
|
||||
guard.release(false).await;
|
||||
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = guard.release(false).await;
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Git(e.to_string()));
|
||||
}
|
||||
};
|
||||
@@ -284,7 +262,7 @@ pub async fn close_issue(
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author {
|
||||
let _ = guard.release(false).await;
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
@@ -292,29 +270,12 @@ pub async fn close_issue(
|
||||
|
||||
let close_result = git_issues::close_issue(&disk_path, &issue_id);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to storage only on success.
|
||||
let release_result = guard.release(close_result.is_ok()).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()))?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("issue {issue_id} not found")))?;
|
||||
// Recoverable only while the marker protects the committed mutation
|
||||
// (see create_issue).
|
||||
if let Err(e) = release_result {
|
||||
if state
|
||||
.repo_store
|
||||
.pending_marker_exists(&record.owner_did, &record.name)
|
||||
{
|
||||
tracing::error!(repo = %repo, issue = %issue_id, err = %e,
|
||||
"issue close committed locally but durable upload failed — storage re-syncs on next upload");
|
||||
} else {
|
||||
tracing::error!(repo = %repo, issue = %issue_id, err = %e,
|
||||
"issue close committed locally with NO durable protection — failing the request");
|
||||
return Err(AppError::Git(format!(
|
||||
"issue close stored locally but durability could not be guaranteed: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let issue: serde_json::Value = serde_json::from_str(&updated)
|
||||
.map_err(|e| AppError::BadRequest(format!("invalid issue data: {e}")))?;
|
||||
@@ -421,7 +382,7 @@ mod lock_pool_shed_tests {
|
||||
|
||||
// MUST-NOT: with the pool free again the call is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await.ok();
|
||||
held.release(false).await;
|
||||
let admitted = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
@@ -470,7 +431,7 @@ mod lock_pool_shed_tests {
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "close_issue");
|
||||
|
||||
held.release(false).await.ok();
|
||||
held.release(false).await;
|
||||
let admitted = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
|
||||
@@ -68,16 +68,9 @@ pub(crate) async fn authorize_repo_read(
|
||||
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
|
||||
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
|
||||
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
fn key_id(d: &str) -> &str {
|
||||
d.strip_prefix("did:key:").unwrap_or(d)
|
||||
}
|
||||
let (ka, kb) = (key_id(a), key_id(b));
|
||||
// After stripping `did:key:`, a value still containing ':' is a non-key full
|
||||
// DID — do not let it match a bare `did:key` id.
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
// One implementation, shared with `gl` and `git-remote-gitlawb` through core:
|
||||
// every boundary that compares a DID applies the same collapse rule.
|
||||
gitlawb_core::ucan::push::did_key_eq(a, b)
|
||||
}
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
|
||||
@@ -227,31 +227,10 @@ pub async fn merge_pr(
|
||||
&pr.title,
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to storage only on success.
|
||||
let release_result = guard.release(merge_result.is_ok()).await;
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
// A durable-upload failure is recoverable ONLY while the pending-upload
|
||||
// marker protects the merge commit; verify it exists before choosing to
|
||||
// proceed (proceeding keeps the DB status consistent with the already
|
||||
// merged ref, which a retry cannot un-merge). Without the marker nothing
|
||||
// protects the merge from a stale-archive rollback — fail the request so
|
||||
// the inconsistency is surfaced instead of silently losable.
|
||||
if let Err(e) = release_result {
|
||||
if state
|
||||
.repo_store
|
||||
.pending_marker_exists(&record.owner_did, &record.name)
|
||||
{
|
||||
tracing::error!(repo = %record.name, pr = %pr.id, err = %e,
|
||||
"merge committed locally but durable upload failed — storage re-syncs on next upload");
|
||||
} else {
|
||||
tracing::error!(repo = %record.name, pr = %pr.id, err = %e,
|
||||
"merge committed locally with NO durable protection — failing the request");
|
||||
return Err(AppError::Git(format!(
|
||||
"merge applied locally but durability could not be guaranteed: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
state.db.merge_pr(&pr.id, &merger_did).await?;
|
||||
let _ = state.db.touch_repo(&record.id).await;
|
||||
@@ -547,7 +526,7 @@ mod lock_pool_shed_tests {
|
||||
|
||||
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await.ok();
|
||||
held.release(false).await;
|
||||
let admitted = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
|
||||
@@ -253,17 +253,17 @@ pub async fn create_repo(
|
||||
// Request is admissible — spend the proof now, immediately before the write.
|
||||
let verified_proof = proof.consume(&state.db).await?;
|
||||
|
||||
// Claim-first ordering: insert the DB row before creating anything durable.
|
||||
// Within one node's database the row is the claim on (owner, name) — a
|
||||
// concurrent same-name create loses at the insert with nothing on disk or
|
||||
// in storage yet. Across nodes (each fly app has its own Postgres) the
|
||||
// insert arbitrates nothing; the cross-node safety property is that
|
||||
// failure compensation below only ever touches state THIS attempt created
|
||||
// (its own row by id, its own local dir) and never deletes a storage key.
|
||||
// init() publishes under the per-repo advisory lock, so a push arriving
|
||||
// through the just-visible row serializes behind publication and can
|
||||
// never be destroyed by this compensation.
|
||||
let disk_path = store::repo_disk_path(&state.config.repos_dir, &owner_did, &req.name);
|
||||
let disk_path = state
|
||||
.repo_store
|
||||
.init(&owner_did, &req.name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// `{:#}` walks the anyhow chain to the leaf cause; the other git
|
||||
// handlers log their failures, this one didn't.
|
||||
tracing::error!(owner = %owner_did, repo = %req.name, err = %format!("{e:#}"), "repo create failed");
|
||||
AppError::Git(e.to_string())
|
||||
})?;
|
||||
|
||||
let now = Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -278,22 +278,8 @@ pub async fn create_repo(
|
||||
forked_from: None,
|
||||
machine_id: state.machine_id.clone(),
|
||||
};
|
||||
state.db.create_repo(&record).await?;
|
||||
|
||||
// Create the bare repo locally and publish the initial archive (under the
|
||||
// advisory lock). On failure, compensate by removing our own just-inserted
|
||||
// row (keyed by our id) so a retry starts clean; create_published removes
|
||||
// its local dir itself.
|
||||
if let Err(e) = state.repo_store.init(&owner_did, &req.name).await {
|
||||
// `{:#}` walks the anyhow chain to the leaf cause; the other git
|
||||
// handlers log their failures, this one didn't.
|
||||
tracing::error!(owner = %owner_did, repo = %req.name, err = %format!("{e:#}"), "repo create failed");
|
||||
if let Err(db_err) = state.db.delete_repo_by_id(&record.id).await {
|
||||
tracing::warn!(repo = %req.name, err = %db_err,
|
||||
"failed to remove repo row after init failure");
|
||||
}
|
||||
return Err(AppError::Git(e.to_string()));
|
||||
}
|
||||
state.db.create_repo(&record).await?;
|
||||
|
||||
// Persist the proof so it can travel with the repo and a mirroring peer can
|
||||
// re-verify it (enforce-mode origins only; off/shadow yield no proof here).
|
||||
@@ -645,10 +631,10 @@ pub async fn git_info_refs(
|
||||
}
|
||||
|
||||
// Push flood brake on the advertisement phase. A push always hits this
|
||||
// GET first, and for receive-pack it forces a fresh storage download below;
|
||||
// GET first, and for receive-pack it forces a fresh Tigris download below;
|
||||
// throttling only the receive-pack POST would leave the expensive
|
||||
// fresh-acquire reachable unauthenticated and unlimited. Applied before the
|
||||
// acquire so a rejected request does no storage work. Same per-IP limiter and
|
||||
// acquire so a rejected request does no Tigris work. Same per-IP limiter and
|
||||
// trusted-proxy policy as the POST middleware (shared buckets).
|
||||
if service == "git-receive-pack" {
|
||||
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
|
||||
@@ -706,7 +692,7 @@ pub async fn git_info_refs(
|
||||
git_permit(&state.git_read_semaphore)?
|
||||
};
|
||||
|
||||
// For receive-pack (push), download the latest from storage so the client
|
||||
// For receive-pack (push), download the latest from Tigris so the client
|
||||
// sees the same refs that acquire_write() will operate on.
|
||||
//
|
||||
// Bound the acquire under `git_acquire_timeout_secs`: the concurrency permit is
|
||||
@@ -1875,14 +1861,21 @@ fn owner_push_rejection(
|
||||
enforce: bool,
|
||||
record: &crate::db::RepoRecord,
|
||||
caller: Option<&str>,
|
||||
verified: Option<&crate::auth::VerifiedUcan>,
|
||||
) -> Option<AppError> {
|
||||
if !enforce {
|
||||
return None;
|
||||
}
|
||||
match caller {
|
||||
Some(did) if caller_authorized_to_push(record, did) => None,
|
||||
Some(did) if caller_authorized_to_push(record, did, verified) => None,
|
||||
// One message for every refusal. It must not say whether a delegation was
|
||||
// presented, was expired, or named another repository: varying it would turn
|
||||
// the denial into an oracle for which capabilities exist. It only has to be
|
||||
// TRUE in all of those cases, which the previous owner-only wording no
|
||||
// longer was once a delegation could authorize a push.
|
||||
_ => Some(AppError::Forbidden(
|
||||
"push rejected — only the repo owner may push to this repository \
|
||||
"push rejected — you must be the repo owner, or hold a valid \
|
||||
owner-issued git/push delegation for this repository \
|
||||
(GITLAWB_ENFORCE_OWNER_PUSH is enabled)"
|
||||
.into(),
|
||||
)),
|
||||
@@ -2022,6 +2015,10 @@ pub async fn git_receive_pack(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
// `X-Ucan` is optional, so the extension may be absent: axum extracts that as
|
||||
// `None` rather than rejecting the request. Present only when the middleware
|
||||
// validated a chain.
|
||||
verified: Option<Extension<crate::auth::VerifiedUcan>>,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: Bytes,
|
||||
@@ -2069,6 +2066,7 @@ pub async fn git_receive_pack(
|
||||
state.config.enforce_owner_push,
|
||||
&record,
|
||||
Some(auth.0.as_str()),
|
||||
verified.as_ref().map(|Extension(v)| v),
|
||||
) {
|
||||
tracing::warn!(
|
||||
repo = %name,
|
||||
@@ -2080,9 +2078,17 @@ pub async fn git_receive_pack(
|
||||
}
|
||||
|
||||
// ── Branch protection check ──────────────────────────────────────────
|
||||
// Uses the same verified identity as the owner-push gate above. (When that
|
||||
// gate is enabled a non-owner never reaches here; this still applies when it
|
||||
// is off, gating only the branches an owner has explicitly protected.)
|
||||
// Uses the same verified identity as the owner-push gate above, but a STRICTER
|
||||
// predicate: owner-only, deliberately not `caller_authorized_to_push`.
|
||||
//
|
||||
// A delegate can therefore clear the gate above and still be refused here. That
|
||||
// is the intended policy, not an oversight: a protected branch is the owner's
|
||||
// explicit marker that even routine writes should stop, so a `git/push`
|
||||
// delegation must not silently override it. Widening this to accept a
|
||||
// delegation would make every existing protection weaker the moment the owner
|
||||
// issues any capability.
|
||||
//
|
||||
// `delegated_push_is_still_refused_on_a_protected_branch` pins this.
|
||||
for update in &ref_updates {
|
||||
// Strip refs/heads/ prefix to get plain branch name
|
||||
let branch = update
|
||||
@@ -2322,7 +2328,7 @@ pub async fn git_receive_pack(
|
||||
}
|
||||
|
||||
// Always release the advisory lock — even on error — to prevent stale locks
|
||||
// from blocking subsequent pushes. Only upload to storage when the push
|
||||
// from blocking subsequent pushes. Only upload to Tigris when the push
|
||||
// succeeded; uploading a half-applied repo would propagate corruption.
|
||||
// Reclaim the write lock from the shared cell (#173 F2). This is only reachable
|
||||
// once `receive_pack` has returned, so the admission guard's copy can only ever
|
||||
@@ -2333,74 +2339,12 @@ pub async fn git_receive_pack(
|
||||
.expect("repo write-lock mutex poisoned")
|
||||
.take()
|
||||
.expect("the write lock is only taken here, and only once");
|
||||
// `Some` = the guard still needs a synchronous (strict) release; taken by
|
||||
// the write-back path only once its intent marker is durably on disk.
|
||||
let mut strict_guard = Some(reclaimed);
|
||||
if push_succeeded && state.config.async_upload {
|
||||
let guard = strict_guard.take().expect("guard present before release");
|
||||
// Write-back: ack the client now; the durable upload to object storage
|
||||
// and the advisory-lock release run in the background. The lock is held
|
||||
// until the upload finishes, so a concurrent writer on another machine
|
||||
// can't observe a stale archive. If this detached task is cancelled by
|
||||
// runtime shutdown mid-upload, the guard's lock connection is closed
|
||||
// rather than repooled, so Postgres frees the advisory lock (see
|
||||
// `LockedConn`). Durability tradeoff: if the upload fails (or the node
|
||||
// stops first), storage stays stale until this repo's next successful
|
||||
// upload. The persisted pending-upload marker keeps the local copy
|
||||
// authoritative on THIS node in that window — no access rolls it back
|
||||
// to the stale archive — but other nodes still serve the stale archive
|
||||
// until the re-upload lands. Hence async_upload is opt-in.
|
||||
//
|
||||
// The intent marker must be on disk BEFORE the ack: the spawned task
|
||||
// may never be polled if the process stops right after the response,
|
||||
// and without the marker a restart would treat the stale storage
|
||||
// archive as newer and roll the acked push back. If the marker itself
|
||||
// cannot be persisted, do NOT ack early — fall back to the strict
|
||||
// upload-before-ack path below.
|
||||
match guard.mark_pending().await {
|
||||
Ok(()) => {
|
||||
let repo_label = name.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = guard.release(true).await {
|
||||
tracing::error!(repo = %repo_label, err = %e,
|
||||
"write-back durable upload failed after push was acked");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %name, err = %e,
|
||||
"pending-upload marker write failed — falling back to strict upload-before-ack");
|
||||
strict_guard = Some(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(guard) = strict_guard {
|
||||
// Strict path (failed push, async_upload off, or marker write failure):
|
||||
// upload-before-ack.
|
||||
if let Err(e) = guard.release(push_succeeded).await {
|
||||
if push_succeeded {
|
||||
// A successful push whose durable upload then failed — the
|
||||
// client must know the push is not durably stored. The lease
|
||||
// drops on this return, and the advisory lock was already
|
||||
// released inside `release`.
|
||||
tracing::error!(repo = %name, err = %e, "durable upload failed after push");
|
||||
return Err(AppError::Git(format!(
|
||||
"push applied locally but durable upload to storage failed: {e}"
|
||||
)));
|
||||
}
|
||||
// The push itself failed; log the release error but fall through
|
||||
// so the real git failure (below) is what the client sees.
|
||||
tracing::error!(repo = %name, err = %e, "lock release failed after failed push");
|
||||
}
|
||||
}
|
||||
reclaimed.release(push_succeeded).await;
|
||||
// Clean path: clone (a) already dropped inside run_git_service when the receive-pack
|
||||
// group was reaped; clone (b) held here spanned the success-only storage upload that
|
||||
// ran inside release() above (or, under async_upload, only the marker write — the
|
||||
// upload continues under the advisory lock, which is what keeps a second push from
|
||||
// reading a stale archive). Drop it now so a second same-repo push proceeds the
|
||||
// moment this write is durable (or acked), rather than at end of the (longer)
|
||||
// handler tail. On the disconnect path this line is never reached: clone (a) rides
|
||||
// the reaper (F3).
|
||||
// group was reaped; clone (b) held here spanned the success-only Tigris upload that
|
||||
// ran inside release() above. Drop it now so a second same-repo push proceeds the
|
||||
// moment this write is durable, rather than at end of the (longer) handler tail. On
|
||||
// the disconnect path this line is never reached: clone (a) rides the reaper (F3).
|
||||
drop(lease);
|
||||
|
||||
let result = receive_result.map_err(|e| {
|
||||
@@ -3133,7 +3077,7 @@ pub async fn fork_repo(
|
||||
// Request is admissible — spend the proof now, immediately before the write.
|
||||
let verified_proof = proof.consume(&state.db).await?;
|
||||
|
||||
// Ensure source repo is on local disk (downloads from storage on cache miss)
|
||||
// Ensure source repo is on local disk (downloads from Tigris on cache miss)
|
||||
let source_path = state
|
||||
.repo_store
|
||||
.acquire(&source.owner_did, &source.name)
|
||||
@@ -3142,14 +3086,30 @@ pub async fn fork_repo(
|
||||
|
||||
let disk_path = store::repo_disk_path(&state.config.repos_dir, &forker_did, &fork_name);
|
||||
|
||||
// Claim-first ordering: insert the DB row before cloning or uploading
|
||||
// anything. Within one node's database the row is the claim on (owner,
|
||||
// name) — a concurrent same-name fork loses at the insert with nothing on
|
||||
// disk or in storage. Across nodes (per-node Postgres) the insert
|
||||
// arbitrates nothing; the cross-node safety properties are that
|
||||
// publication runs under the per-repo advisory lock (below) and that the
|
||||
// failure compensation only ever removes state THIS attempt created (its
|
||||
// own row by id, its own clone dir) — never a storage archive.
|
||||
// Clone the source repo as a mirror
|
||||
let output = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--mirror",
|
||||
source_path.to_str().unwrap_or(""),
|
||||
disk_path.to_str().unwrap_or(""),
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| AppError::Git(format!("git clone --mirror failed: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(AppError::Git(format!(
|
||||
"git clone --mirror failed: {stderr}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Upload fork to Tigris
|
||||
state
|
||||
.repo_store
|
||||
.release_after_write(&forker_did, &fork_name)
|
||||
.await;
|
||||
|
||||
let now = Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -3164,41 +3124,8 @@ pub async fn fork_repo(
|
||||
forked_from: Some(source.id.clone()),
|
||||
machine_id: state.machine_id.clone(),
|
||||
};
|
||||
state.db.create_repo(&record).await?;
|
||||
|
||||
// The whole clone-and-publish lifecycle runs under the per-repo advisory
|
||||
// lock inside `create_published`: pushes to the just-visible row serialize
|
||||
// on the same lock, so none can execute (let alone be destroyed by the
|
||||
// compensation below) until publication has succeeded or been unwound.
|
||||
// On failure, compensation removes only what THIS attempt created: its
|
||||
// clone dir (inside create_published) and its own row, keyed by our
|
||||
// generated id.
|
||||
let clone_result = state
|
||||
.repo_store
|
||||
.create_published(&forker_did, &fork_name, |dest| {
|
||||
let output = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--mirror",
|
||||
source_path.to_str().unwrap_or(""),
|
||||
dest.to_str().unwrap_or(""),
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("git clone --mirror failed: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git clone --mirror failed: {stderr}");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = clone_result {
|
||||
if let Err(db_err) = state.db.delete_repo_by_id(&record.id).await {
|
||||
tracing::warn!(record_id = %record.id, err = %db_err,
|
||||
"failed to remove fork row after fork error");
|
||||
}
|
||||
return Err(AppError::Git(format!("fork failed: {e}")));
|
||||
}
|
||||
state.db.create_repo(&record).await?;
|
||||
|
||||
// Persist the proof so the fork carries it when it propagates to peers.
|
||||
if let Some(p) = verified_proof {
|
||||
@@ -3716,7 +3643,7 @@ mod tests {
|
||||
#[test]
|
||||
fn enforced_allows_owner_full_did() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID)).is_none());
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3724,36 +3651,92 @@ mod tests {
|
||||
// Owners are accepted in bare-multibase form, matching the rest of the
|
||||
// codebase's owner comparisons.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT)).is_none());
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_rejects_non_owner_with_forbidden() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID)));
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_rejects_missing_did_with_forbidden() {
|
||||
// Fail closed: an absent authenticated identity is rejected, not allowed.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert_forbidden(owner_push_rejection(true, &repo, None));
|
||||
assert_forbidden(owner_push_rejection(true, &repo, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_allows_non_owner_and_missing_did() {
|
||||
// Flag off → legacy behavior: authentication-only, no owner gate.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID)).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, None).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID), None).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, None, None).is_none());
|
||||
}
|
||||
|
||||
/// Build a VerifiedUcan whose chain roots at `root_did` and which carries
|
||||
/// `git/push` for `repo`. The token's own issuer and audience do not matter
|
||||
/// here: the middleware has already bound them before this gate is reached.
|
||||
fn push_delegation(root_did: &str, repo: &crate::db::RepoRecord) -> crate::auth::VerifiedUcan {
|
||||
let agent = gitlawb_core::identity::Keypair::generate();
|
||||
let node = gitlawb_core::identity::Keypair::generate();
|
||||
let ucan = gitlawb_core::ucan::Ucan::issue(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{}/{}", repo.owner_did, repo.name),
|
||||
gitlawb_core::ucan::caps::GIT_PUSH,
|
||||
)],
|
||||
// Finite: a write capability that never lapses is refused, since there
|
||||
// is no revocation path to withdraw a leaked one.
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
crate::auth::VerifiedUcan {
|
||||
ucan,
|
||||
root: root_did.parse().expect("root DID must parse"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_allows_a_non_owner_holding_an_owner_rooted_push_capability() {
|
||||
// The regression owner-only push introduced: a CI or delegated key with a
|
||||
// valid capability was refused exactly like a stranger.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
let verified = push_delegation(OWNER_DID, &repo);
|
||||
assert!(
|
||||
owner_push_rejection(true, &repo, Some(STRANGER_DID), Some(&verified)).is_none(),
|
||||
"a delegation rooted at the owner must let a non-owner push"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_rejects_a_delegation_rooted_at_a_stranger() {
|
||||
// Anchoring is the whole point: a chain nobody the repo trusts started
|
||||
// grants nothing, even carrying a perfectly formed push capability.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
let verified = push_delegation(STRANGER_DID, &repo);
|
||||
assert_forbidden(owner_push_rejection(
|
||||
true,
|
||||
&repo,
|
||||
Some(STRANGER_DID),
|
||||
Some(&verified),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_still_rejects_a_non_owner_with_no_capability() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_authorized_to_push_is_owner_only_in_phase_1() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_DID));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_SHORT));
|
||||
assert!(!caller_authorized_to_push(&repo, STRANGER_DID));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_DID, None));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_SHORT, None));
|
||||
assert!(!caller_authorized_to_push(&repo, STRANGER_DID, None));
|
||||
}
|
||||
|
||||
// ── fork_withheld_blocks (#98 path-scoped fork gate) ──
|
||||
@@ -4786,7 +4769,7 @@ mod tests {
|
||||
|
||||
/// The receive-pack *advertisement* (`GET info/refs?service=git-receive-pack`)
|
||||
/// must be throttled by the per-IP push limiter BEFORE it does the fresh
|
||||
/// storage acquire — otherwise the flood brake on the POST is bypassable via
|
||||
/// Tigris acquire — otherwise the flood brake on the POST is bypassable via
|
||||
/// the cheaper unauthenticated GET (PR #152 review P1). Pre-filling the
|
||||
/// bucket makes the assertion deterministic and keeps the test off the
|
||||
/// acquire path entirely.
|
||||
@@ -4825,7 +4808,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"receive-pack advertisement must be throttled before the storage acquire"
|
||||
"receive-pack advertisement must be throttled before the Tigris acquire"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6033,6 +6016,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6rp4wr".to_string(), "rp4".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(capped)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -6051,6 +6035,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6rp4wr".to_string(), "rp4".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(other)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -6157,6 +6142,7 @@ mod tests {
|
||||
State(state_for_task),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -6244,6 +6230,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -6401,6 +6388,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6MkDisconnectWriteLockProofDidAAAAAAAA".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.81:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -6488,7 +6476,7 @@ mod tests {
|
||||
// observes the same counter at 1: without it, a zero here would pass on any build
|
||||
// where an upload is simply impossible.
|
||||
assert_eq!(
|
||||
state.repo_store.storage_upload_site_reached(),
|
||||
state.repo_store.tigris_upload_site_reached(),
|
||||
0,
|
||||
"a push interrupted by a client disconnect must not reach the Tigris upload \
|
||||
site: publishing a half-applied repo propagates it to every node that later \
|
||||
@@ -6555,6 +6543,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6MkPushSuccessReleaseProofDidAAAAAAAA".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.83:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -6593,7 +6582,7 @@ mod tests {
|
||||
// not at least once: a retried exec race releases with success = false and must
|
||||
// not count.
|
||||
assert_eq!(
|
||||
state.repo_store.storage_upload_site_reached(),
|
||||
state.repo_store.tigris_upload_site_reached(),
|
||||
1,
|
||||
"a completed push must reach the Tigris upload site once"
|
||||
);
|
||||
@@ -6645,6 +6634,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -6658,11 +6648,12 @@ mod tests {
|
||||
|
||||
// MUST-NOT: with the pool free again, the push is not shed as capacity (it fails
|
||||
// later on the nonexistent on-disk repo, which is a git error, not Overloaded).
|
||||
held.release(false).await.ok();
|
||||
held.release(false).await;
|
||||
let admitted = git_receive_pack(
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.72:5000".parse().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -7311,6 +7302,7 @@ mod tests {
|
||||
State(state),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body(new_sha),
|
||||
@@ -7405,6 +7397,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f4fast".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -7465,6 +7458,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f4park".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("2222222222222222222222222222222222222222"),
|
||||
@@ -8690,6 +8684,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f3repo".to_string(), "r1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -8718,6 +8713,7 @@ mod tests {
|
||||
State(state_b),
|
||||
Path(("z6f3repo".to_string(), "r1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.82:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -8814,6 +8810,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6f3clean".to_string(), "c1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -8907,6 +8904,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f3dos".to_string(), "d1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -8934,6 +8932,7 @@ mod tests {
|
||||
State(state_b),
|
||||
Path(("z6f3dos".to_string(), "d1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.72:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -9118,6 +9117,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6u2key".to_string(), "k1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9191,6 +9191,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6ovflow".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("1111111111111111111111111111111111111111"),
|
||||
@@ -9245,6 +9246,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6u1cap".to_string(), "c1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9350,6 +9352,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6u1two".to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(src)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9440,6 +9443,7 @@ mod tests {
|
||||
State(st),
|
||||
Path((owner.to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(edge)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9533,6 +9537,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6f1key".to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9593,6 +9598,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f1none".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9637,6 +9643,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f1seq".to_string(), "s1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(src)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -10147,6 +10154,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
|
||||
@@ -17,16 +17,74 @@ use crate::state::AppState;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// A UCAN that passed full chain validation, with the root issuer the chain
|
||||
/// rests on. Inserted into request extensions by [`require_ucan_chain`] when
|
||||
/// `X-Ucan` is present; absent when the header is.
|
||||
///
|
||||
/// `root` is carried rather than recomputed so the chain is walked once per
|
||||
/// request. Holding this is not itself an authorization decision — a caller must
|
||||
/// still compare `root` against an identity it independently trusts, because
|
||||
/// `did:key` is self-certifying and anyone can mint a chain that verifies.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VerifiedUcan {
|
||||
pub ucan: Ucan,
|
||||
pub root: Did,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// The repo owner, or a caller presenting a verified UCAN whose chain roots at
|
||||
/// that owner and which carries `git/push` for this repo.
|
||||
///
|
||||
/// `verified` is optional because `X-Ucan` is: a push carrying no token reaches
|
||||
/// the same owner-only decision it always did. The owner check is unconditional
|
||||
/// and runs first, so this can only ever turn a refusal into an acceptance,
|
||||
/// never the reverse.
|
||||
pub fn caller_authorized_to_push(
|
||||
record: &crate::db::RepoRecord,
|
||||
caller: &str,
|
||||
verified: Option<&VerifiedUcan>,
|
||||
) -> bool {
|
||||
crate::api::did_matches(caller, &record.owner_did)
|
||||
|| verified.is_some_and(|v| ucan_grants_push(record, v))
|
||||
}
|
||||
|
||||
/// Whether a verified UCAN authorizes a push to `record`.
|
||||
///
|
||||
/// Three conditions, all required:
|
||||
/// 1. The chain roots at this repo's owner. This is the trust anchor — the
|
||||
/// repo record is data the node holds independently of the token, so a
|
||||
/// self-minted chain cannot satisfy it.
|
||||
/// 2. Every link is bounded. There is no revocation path, so an unbounded link
|
||||
/// is a permanent grant.
|
||||
/// 3. Every link — the leaf and every proof behind it — carries an
|
||||
/// unconstrained push-class capability naming this repository. That is
|
||||
/// [`gitlawb_core::ucan::Ucan::chain_grants_push_to`], the same rule
|
||||
/// `gl ucan import` applies before storing a token and `git-remote-gitlawb`
|
||||
/// applies before minting an invocation, so what one boundary accepts the
|
||||
/// next does not refuse.
|
||||
///
|
||||
/// (3) walks the whole chain on purpose. Refusing a wildcard leaf alone did
|
||||
/// nothing: `is_attenuated_by` accepts a concrete child under a `*` parent, and
|
||||
/// that narrowing was exactly what the helper performed, so one owner-issued
|
||||
/// `with: "*"` proof let a delegate mint a concrete leaf for ANY repository the
|
||||
/// owner had — or created later — and both the chain check and the owner-root
|
||||
/// check passed. A delegation's scope is fixed when it is issued.
|
||||
pub fn ucan_grants_push(record: &crate::db::RepoRecord, verified: &VerifiedUcan) -> bool {
|
||||
if !crate::api::did_matches(&verified.root.to_string(), &record.owner_did) {
|
||||
return false;
|
||||
}
|
||||
// A write capability must lapse on its own. `exp` is optional in the format and
|
||||
// there is no revocation path, so a chain with an unbounded link is a permanent
|
||||
// grant: once the token leaks, the owner cannot withdraw it short of rotating
|
||||
// the DID the repo is keyed on. Refusing here is what makes "the damage window
|
||||
// is the token's expiry" a true statement rather than an aspiration.
|
||||
if !verified.ucan.chain_lifetime_is_bounded() {
|
||||
return false;
|
||||
}
|
||||
verified
|
||||
.ucan
|
||||
.chain_grants_push_to(&record.owner_did, &record.name)
|
||||
}
|
||||
|
||||
use gitlawb_core::http_sig::{
|
||||
@@ -270,7 +328,7 @@ fn validate_ucan_chain(
|
||||
token: &str,
|
||||
expected_aud: &Did,
|
||||
signer_did: &Did,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ucan = Ucan::decode(token).map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -298,14 +356,14 @@ fn validate_ucan_chain(
|
||||
)
|
||||
})?;
|
||||
|
||||
ucan.verify_chain().map_err(|e| {
|
||||
let root = ucan.verify_chain().map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
Ok(VerifiedUcan { ucan, root })
|
||||
}
|
||||
|
||||
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
|
||||
@@ -358,11 +416,18 @@ pub async fn require_ucan_chain(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
return (status, body).into_response();
|
||||
}
|
||||
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
Ok(v) => v,
|
||||
Err((status, body)) => return (status, body).into_response(),
|
||||
};
|
||||
|
||||
tracing::debug!(did = %signer_did, "UCAN chain validated");
|
||||
tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated");
|
||||
|
||||
// Park the verified token where a handler can reach it. Validation alone
|
||||
// grants nothing; the authorization decision is made downstream, by a caller
|
||||
// that knows which identity it trusts for the resource being touched.
|
||||
let mut request = request;
|
||||
request.extensions_mut().insert(verified);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -398,6 +463,37 @@ mod tests {
|
||||
Ucan::bootstrap(node, agent_did).unwrap()
|
||||
}
|
||||
|
||||
/// The middleware validated a token and threw the result away, so no handler
|
||||
/// could ever read it and `Ucan::can` had no call site in the node. Validation
|
||||
/// must hand back both the token and the root the chain rests on.
|
||||
#[test]
|
||||
fn validate_ucan_chain_hands_back_the_root_and_the_token() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
|
||||
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
|
||||
.expect("wrap invocation");
|
||||
let token = invocation.encode().expect("encode");
|
||||
|
||||
let verified = validate_ucan_chain(&token, &node.did(), &agent.did())
|
||||
.expect("a well-formed owner-rooted invocation must validate");
|
||||
|
||||
assert_eq!(
|
||||
verified.root,
|
||||
owner.did(),
|
||||
"the root must be the owner, so a caller can anchor against the repo record"
|
||||
);
|
||||
assert_eq!(
|
||||
verified.ucan.payload.iss,
|
||||
agent.did(),
|
||||
"the token itself must come back so a caller can read its capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
|
||||
Ucan::delegate(
|
||||
agent,
|
||||
@@ -637,3 +733,407 @@ mod tests {
|
||||
assert_eq!(body_json["error"], "invalid_ucan");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ucan_push_tests {
|
||||
use super::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
const OWNER_KEY: &str = "z6MkOwnerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/// `RepoRecord` does not derive `Default`, and adding the derive to a
|
||||
/// production DB type purely to serve a test is the wrong direction.
|
||||
fn repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
crate::db::RepoRecord {
|
||||
id: "repo-id".to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/unused".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token's own issuer and audience are irrelevant to this predicate: the
|
||||
/// middleware has already bound `iss` to the request signer and `aud` to this
|
||||
/// node. Only the capabilities and the chain's root matter here.
|
||||
fn verified(root: &str, caps_vec: Vec<Capability>) -> VerifiedUcan {
|
||||
verified_with_exp(
|
||||
root,
|
||||
caps_vec,
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
}
|
||||
|
||||
fn verified_with_exp(
|
||||
root: &str,
|
||||
caps_vec: Vec<Capability>,
|
||||
exp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> VerifiedUcan {
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let ucan = Ucan::issue(&agent, node.did(), caps_vec, exp).expect("issue");
|
||||
VerifiedUcan {
|
||||
ucan,
|
||||
root: root.parse().expect("root DID must parse"),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_full() -> String {
|
||||
format!("did:key:{OWNER_KEY}")
|
||||
}
|
||||
|
||||
fn push_cap_for(owner: &str, name: &str) -> Capability {
|
||||
Capability::new(format!("gitlawb://repos/{owner}/{name}"), caps::GIT_PUSH)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_push_when_the_chain_roots_at_the_owner_and_names_the_repo() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_a_bare_owner_key_against_a_full_did_record() {
|
||||
// Mirror rows store the bare key. A literal string compare would fail
|
||||
// here, denying a delegation that is in fact valid.
|
||||
let rec = repo(OWNER_KEY, "myrepo");
|
||||
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A perpetual grant is refused even when it is otherwise perfectly valid:
|
||||
/// owner-rooted, right repo, right action. Without revocation, an unbounded
|
||||
/// delegation cannot be withdrawn once it leaks.
|
||||
#[test]
|
||||
fn refuses_a_delegation_that_never_expires() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified_with_exp(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo")],
|
||||
None,
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_self_minted_root() {
|
||||
// The whole point: a token nobody delegated grants nothing, however
|
||||
// permissive its capabilities look.
|
||||
let stranger = Keypair::generate();
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(&stranger.did().to_string(), vec![Capability::new("*", "*")]);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_capability_for_a_different_repo() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "otherrepo")],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_capability_carrying_constraints() {
|
||||
// `nb` is not interpreted yet. An owner who writes {"refs": [...]} means
|
||||
// to restrict; honouring the capability while ignoring nb would grant
|
||||
// strictly more than they intended, so it authorizes nothing.
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo")
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }))],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_non_push_capability() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
caps::ISSUE_CREATE,
|
||||
)],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A resource wildcard must NOT authorize a push, even though attenuation
|
||||
/// accepts it. The helper narrows a `*` delegation before signing, but the node
|
||||
/// cannot rely on that: a delegate can sign an invocation that keeps the
|
||||
/// wildcard, and it would otherwise reach every repository the owner has — or
|
||||
/// will later create. This test previously asserted the opposite.
|
||||
#[test]
|
||||
fn refuses_a_resource_wildcard_and_honours_repo_admin() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
|
||||
assert!(
|
||||
!ucan_grants_push(&rec, &wildcard),
|
||||
"a wildcard resource must not authorize a push at the node"
|
||||
);
|
||||
|
||||
// The ACTION wildcard is a different axis and stays: attenuation bounds it,
|
||||
// and it still has to name a concrete repository.
|
||||
let action_wildcard = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
"*",
|
||||
)],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &action_wildcard));
|
||||
|
||||
let admin = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
caps::REPO_ADMIN,
|
||||
)],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &admin));
|
||||
}
|
||||
|
||||
/// The attack the wildcard refusal exists to stop: one `*` delegation reaching a
|
||||
/// repository it was never issued against, including one created afterwards.
|
||||
#[test]
|
||||
fn a_wildcard_delegation_cannot_reach_a_second_repository() {
|
||||
let other = repo(&owner_full(), "a-repo-created-later");
|
||||
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
|
||||
assert!(!ucan_grants_push(&other, &wildcard));
|
||||
}
|
||||
|
||||
/// The round-8 P1, executed rather than reasoned: an owner-issued `*` PROOF with
|
||||
/// a concrete leaf for a repository that did not exist at issuance. Refusing a
|
||||
/// wildcard *leaf* did nothing here — `is_attenuated_by` accepts a concrete child
|
||||
/// under a `*` parent, and that narrowing is exactly what `build_invocation` did,
|
||||
/// so the first-party helper was the working mint path.
|
||||
#[test]
|
||||
fn a_wildcard_proof_cannot_reach_a_repo_created_later() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let parent = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let later = format!("gitlawb://repos/{}/a-repo-created-later", owner.did());
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&later, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("the chain still verifies");
|
||||
let rec = repo(&owner.did().to_string(), "a-repo-created-later");
|
||||
assert!(
|
||||
!ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
),
|
||||
"a wildcard proof must not authorize a repo it never named"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipping flow must keep working: a proof that names the repository
|
||||
/// authorizes a push to it. Guards against fixing the wildcard by refusing
|
||||
/// everything with a `prf`.
|
||||
#[test]
|
||||
fn a_concrete_proof_still_authorizes_the_repo_it_names() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
|
||||
let parent = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("chain verifies");
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// A `*` two links up. The immediate proof names the repository, so a walk that
|
||||
/// checks only one level of `prf` stays green here — this is the case that
|
||||
/// makes the recursion load-bearing rather than incidental.
|
||||
#[test]
|
||||
fn a_wildcard_grandparent_cannot_reach_the_repo_through_a_concrete_parent() {
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
|
||||
let grandparent = Ucan::issue(
|
||||
&owner,
|
||||
intermediary.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let parent = Ucan::delegate(
|
||||
&intermediary,
|
||||
agent.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&grandparent,
|
||||
)
|
||||
.unwrap();
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("chain verifies");
|
||||
assert_eq!(root, owner.did());
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(
|
||||
!ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
),
|
||||
"a wildcard anywhere in the chain must deny, not just in the immediate proof"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the same three-link shape with every link concrete must still grant, so
|
||||
/// the walk is refusing the wildcard and not the depth.
|
||||
#[test]
|
||||
fn a_three_link_concrete_chain_still_authorizes() {
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
|
||||
|
||||
let grandparent = Ucan::issue(&owner, intermediary.did(), cap(), Some(hour)).unwrap();
|
||||
let parent =
|
||||
Ucan::delegate(&intermediary, agent.did(), cap(), Some(hour), &grandparent).unwrap();
|
||||
let invocation = Ucan::delegate(&agent, node.did(), cap(), Some(hour), &parent).unwrap();
|
||||
|
||||
let root = invocation.verify_chain().unwrap();
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// The round-ten P1, pinned where it bites. The middleware verifies the chain
|
||||
/// on the `X-Ucan` header of any signed request before the scope walk runs,
|
||||
/// so a depth bound that lived only in `chain_grants_push_to` protected
|
||||
/// nothing: a hand-built header reached the unbounded recursion first. A
|
||||
/// chain at `MAX_CHAIN_DEPTH` links validates; one link more is refused here,
|
||||
/// with 401 and a message that names depth, before any authorization runs.
|
||||
#[test]
|
||||
fn validate_ucan_chain_stops_at_the_depth_bound() {
|
||||
use gitlawb_core::ucan::MAX_CHAIN_DEPTH;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
|
||||
|
||||
// owner -> k1 -> ... -> signer: MAX_CHAIN_DEPTH - 1 links, all concrete,
|
||||
// all signed, all expiring. Only the length is in question.
|
||||
let mut signer = Keypair::generate();
|
||||
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour)).unwrap();
|
||||
for _ in 2..MAX_CHAIN_DEPTH {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
|
||||
// Wrapped into an invocation for the node: exactly MAX_CHAIN_DEPTH links.
|
||||
let at_bound = Ucan::delegate(&signer, node.did(), cap(), Some(hour), &cur).unwrap();
|
||||
let verified = validate_ucan_chain(&at_bound.encode().unwrap(), &node.did(), &signer.did())
|
||||
.expect("a chain at the bound must still validate");
|
||||
assert_eq!(verified.root, owner.did());
|
||||
|
||||
// One more link and it is past the bound.
|
||||
let next = Keypair::generate();
|
||||
let deeper = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
|
||||
let past_bound = Ucan::delegate(&next, node.did(), cap(), Some(hour), &deeper).unwrap();
|
||||
let (status, body) =
|
||||
match validate_ucan_chain(&past_bound.encode().unwrap(), &node.did(), &next.did()) {
|
||||
Ok(_) => panic!("a chain past the bound must be refused, not walked"),
|
||||
Err(refusal) => refusal,
|
||||
};
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let message = body.0["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("deeper than"),
|
||||
"the refusal must name depth as the reason: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_malformed_resource_uri() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
for bad in [
|
||||
"",
|
||||
"myrepo",
|
||||
"https://repos/x/myrepo",
|
||||
"gitlawb://repos/myrepo",
|
||||
] {
|
||||
let v = verified(&owner_full(), vec![Capability::new(bad, caps::GIT_PUSH)]);
|
||||
assert!(!ucan_grants_push(&rec, &v), "{bad} must not grant push");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,50 +172,11 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_HEARTBEAT_INTERVAL_HOURS", default_value_t = 20)]
|
||||
pub heartbeat_interval_hours: u64,
|
||||
|
||||
/// Tigris (S3-compatible) bucket for repo storage. Legacy alias for
|
||||
/// `s3_bucket` — still honoured so existing deployments keep working.
|
||||
/// Tigris (S3-compatible) bucket for repo storage.
|
||||
/// Leave empty to disable Tigris and use local-only storage.
|
||||
#[arg(long, env = "GITLAWB_TIGRIS_BUCKET", default_value = "")]
|
||||
pub tigris_bucket: String,
|
||||
|
||||
/// Object-storage backend: `s3` (any S3-compatible service), `fs` (local
|
||||
/// directory), or `ipfs` (Kubo MFS). Empty = auto-detect: `s3` when a bucket
|
||||
/// is set, else `fs` when a storage dir is set, else local-only. `ipfs` is
|
||||
/// never auto-selected (`GITLAWB_IPFS_API` alone keeps its pinning-only
|
||||
/// meaning) — set this explicitly to `ipfs` to opt in, and note the ipfs
|
||||
/// backend requires ALL nodes to share one Kubo instance (MFS is
|
||||
/// daemon-local, not shared network storage).
|
||||
#[arg(long, env = "GITLAWB_STORAGE_BACKEND", default_value = "")]
|
||||
pub storage_backend: String,
|
||||
|
||||
/// Bucket for the `s3` backend (Tigris, R2, AWS S3, MinIO, B2). Falls back to
|
||||
/// `tigris_bucket` when empty.
|
||||
#[arg(long, env = "GITLAWB_S3_BUCKET", default_value = "")]
|
||||
pub s3_bucket: String,
|
||||
|
||||
/// Endpoint URL override for the `s3` backend (e.g. R2/MinIO). On Tigris/Fly
|
||||
/// the endpoint is auto-provided via `AWS_ENDPOINT_URL_S3`, so leave empty.
|
||||
#[arg(long, env = "GITLAWB_S3_ENDPOINT", default_value = "")]
|
||||
pub s3_endpoint: String,
|
||||
|
||||
/// Force path-style S3 addressing (required by MinIO and some S3-compatibles).
|
||||
#[arg(long, env = "GITLAWB_S3_FORCE_PATH_STYLE", default_value_t = false)]
|
||||
pub s3_force_path_style: bool,
|
||||
|
||||
/// Directory for the `fs` (local filesystem) storage backend.
|
||||
#[arg(long, env = "GITLAWB_STORAGE_FS_DIR", default_value = "")]
|
||||
pub storage_fs_dir: String,
|
||||
|
||||
/// Acknowledge a push to the client before the durable upload to object
|
||||
/// storage finishes (write-back). Lowers push latency, but opens a
|
||||
/// durability window: if the upload fails or the node stops first, storage
|
||||
/// stays stale until the next successful upload. A persisted pending-upload
|
||||
/// marker keeps the local copy authoritative on this node in that window
|
||||
/// (no rollback of the acked push), but other nodes serve the stale archive
|
||||
/// until the re-upload lands. Off by default (strict upload-before-ack).
|
||||
#[arg(long, env = "GITLAWB_ASYNC_UPLOAD", default_value_t = false)]
|
||||
pub async_upload: bool,
|
||||
|
||||
/// Maximum pack body size for git-receive-pack and git-upload-pack, in bytes.
|
||||
/// Applies only to git smart-HTTP routes — all other API routes keep the 2 MB default.
|
||||
/// Default: 2 GB. Set lower on resource-constrained nodes.
|
||||
|
||||
@@ -1239,17 +1239,6 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compensating delete for creation flows: remove the row a failed
|
||||
/// create/fork just inserted, keyed strictly by our own generated id so a
|
||||
/// concurrent same-name winner's row can never be affected.
|
||||
pub async fn delete_repo_by_id(&self, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM repos WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a mirrored repo from a peer in the local DB so git smart HTTP can serve it.
|
||||
/// Uses INSERT OR IGNORE (SQLite) / ON CONFLICT DO NOTHING (Postgres) so it's idempotent.
|
||||
pub async fn upsert_mirror_repo(
|
||||
|
||||
@@ -3,4 +3,5 @@ pub mod push_delta;
|
||||
pub mod repo_store;
|
||||
pub mod smart_http;
|
||||
pub mod store;
|
||||
pub mod tigris;
|
||||
pub mod visibility_pack;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
||||
//! Tigris (S3-compatible) storage client for git bare repos.
|
||||
//!
|
||||
//! Repos are stored as `repos/v1/{owner_slug}/{repo_name}.tar.zst` — a
|
||||
//! zstd-compressed tar archive of the bare repo directory.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Wrapper around the S3 client with the configured bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct TigrisClient {
|
||||
s3: S3Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl TigrisClient {
|
||||
/// Create a new client. Uses AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
|
||||
/// AWS_ENDPOINT_URL_S3 env vars — all set automatically by Fly for Tigris buckets.
|
||||
pub async fn new(bucket: &str) -> Result<Self> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only constructor with an explicit S3 endpoint, region, and static
|
||||
/// credentials — no env-var reads, so parallel tests cannot race each other's
|
||||
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
|
||||
/// the client at a non-routable endpoint to exercise acquire-stall paths.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(aws_config::Region::new("auto"))
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
Self {
|
||||
s3: S3Client::new(&config),
|
||||
bucket: bucket.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
|
||||
fn repo_key(owner_slug: &str, repo_name: &str) -> String {
|
||||
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
|
||||
}
|
||||
|
||||
/// 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()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
|
||||
pub async fn upload(&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(), "uploading repo to tigris");
|
||||
|
||||
// Create tar.zst in memory
|
||||
let archive_bytes = tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || compress_repo(&local_path)
|
||||
})
|
||||
.await
|
||||
.context("tar task panicked")?
|
||||
.context("compressing repo")?;
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
|
||||
|
||||
self.s3
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.body(body)
|
||||
.content_type("application/zstd")
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris PUT {key}"))?;
|
||||
|
||||
info!(key = %key, "uploaded repo to tigris");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris GET {key}"))?;
|
||||
|
||||
let data = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || decompress_repo(&data, &local_path)
|
||||
})
|
||||
.await
|
||||
.context("extract task panicked")?
|
||||
.context("extracting repo")?;
|
||||
|
||||
info!(key = %key, path = %local_path.display(), "downloaded repo from tigris");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a repo archive from Tigris.
|
||||
#[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()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris DELETE {key}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compress a bare repo directory into a tar.zst byte vector.
|
||||
fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
let buf = Vec::new();
|
||||
let encoder = zstd::stream::Encoder::new(buf, 3)?; // level 3 = fast + decent ratio
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
|
||||
// Append the bare repo directory contents (not the directory itself)
|
||||
tar.append_dir_all(".", repo_path)
|
||||
.context("building tar archive")?;
|
||||
|
||||
let encoder = tar.into_inner().context("finishing tar")?;
|
||||
let compressed = encoder.finish().context("finishing zstd")?;
|
||||
Ok(compressed)
|
||||
}
|
||||
|
||||
/// Per-repo-path lock serializing the publish (swap-into-place) step of
|
||||
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
|
||||
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
|
||||
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
|
||||
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
|
||||
// entry accrues per distinct repo path for the process lifetime. Bounded by
|
||||
// the number of repos a node hosts, so it's negligible for normal use, but
|
||||
// high-volume/churning deployments may want LRU or weak-ref eviction here.
|
||||
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
|
||||
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut map = locks.lock().expect("publish lock map poisoned");
|
||||
map.entry(local_path.to_path_buf())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
/// 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(())
|
||||
}
|
||||
@@ -18,7 +18,6 @@ mod pinata;
|
||||
mod rate_limit;
|
||||
mod server;
|
||||
mod state;
|
||||
mod storage;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
@@ -300,14 +299,22 @@ async fn main() -> Result<()> {
|
||||
info!(" fly machine: {mid}");
|
||||
}
|
||||
|
||||
// Initialize the storage-agnostic blob backend (S3-compatible / filesystem /
|
||||
// IPFS), then wrap it in the repo-archive layer. `None` = local-only mode.
|
||||
// Fail closed: a configured-but-unreachable backend aborts boot rather than
|
||||
// silently running local-only and dropping durability.
|
||||
let blob_store = storage::build(&config)
|
||||
.await
|
||||
.context("initializing object storage backend")?;
|
||||
let archive = blob_store.map(storage::archive::RepoArchive::new);
|
||||
// Initialize Tigris S3 client if bucket is configured
|
||||
let tigris = if !config.tigris_bucket.is_empty() {
|
||||
match git::tigris::TigrisClient::new(&config.tigris_bucket).await {
|
||||
Ok(client) => {
|
||||
info!(bucket = %config.tigris_bucket, "tigris storage enabled");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(err = %e, "failed to initialize Tigris client — using local-only storage");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("tigris storage disabled (no bucket configured)");
|
||||
None
|
||||
};
|
||||
|
||||
// Repo write locks run on their own pool, never the main query pool: each push
|
||||
// holds its connection for the whole receive-pack, so a burst of concurrent
|
||||
@@ -316,42 +323,13 @@ async fn main() -> Result<()> {
|
||||
// whatever the two pools are sized at, which is why the separation is
|
||||
// structural rather than a consequence of the defaults; config validate()
|
||||
// separately requires db_max_connections >= max_concurrent_git_pushes + 8. See
|
||||
// build_lock_pool for the cancellation semantics (#173). The pool's
|
||||
// acquire_timeout bounds only the checkout for a single try-lock round trip
|
||||
// (waiting for a contended LOCK sleeps with no connection held), so it stays
|
||||
// at the ordinary DB acquire timeout rather than the 300s lock-wait budget.
|
||||
// build_lock_pool for the cancellation semantics (#173).
|
||||
let lock_pool = git::repo_store::build_lock_pool(
|
||||
db.pool(),
|
||||
lock_pool_size(config.max_concurrent_git_pushes),
|
||||
std::time::Duration::from_secs(config.db_acquire_timeout_secs),
|
||||
);
|
||||
|
||||
// Sweep swap-phase litter (`.tmp-extract.`/`.bak-` dirs) orphaned by a hard
|
||||
// kill mid-extraction. Must run before any request can start an extraction,
|
||||
// as a live swap owns exactly these names — synchronous here, and cheap:
|
||||
// it's a two-level directory scan that removes only matching orphans.
|
||||
{
|
||||
let removed = storage::archive::sweep_orphaned_swap_dirs(&config.repos_dir);
|
||||
if removed > 0 {
|
||||
info!(removed, "swept orphaned repo swap dirs from previous run");
|
||||
}
|
||||
}
|
||||
|
||||
let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), archive, lock_pool);
|
||||
|
||||
// Re-attempt uploads for repos whose pending-upload marker survived a
|
||||
// crash or failed upload — otherwise a repo with no further writes stays
|
||||
// divergent from storage indefinitely. Background task: it takes the
|
||||
// per-repo advisory locks, so it serializes correctly with live pushes.
|
||||
{
|
||||
let store = repo_store.clone();
|
||||
tokio::spawn(async move {
|
||||
let (reuploaded, still_pending) = store.retry_pending_uploads().await;
|
||||
if reuploaded > 0 || still_pending > 0 {
|
||||
info!(reuploaded, still_pending, "pending-upload marker sweep");
|
||||
}
|
||||
});
|
||||
}
|
||||
let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, lock_pool);
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
//! `gitlawb_pack_size_bytes`
|
||||
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
|
||||
//! * currently-connected peer count — `gitlawb_peers_connected`
|
||||
//! * repos whose local copy is ahead of durable storage —
|
||||
//! `gitlawb_pending_upload_markers`
|
||||
//!
|
||||
//! All metrics live in a single process-wide registry initialized by
|
||||
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
|
||||
@@ -53,7 +51,6 @@ 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();
|
||||
static PENDING_UPLOAD_MARKERS: OnceLock<IntGauge> = OnceLock::new();
|
||||
|
||||
/// One-time initializer. Builds the registry, registers every metric,
|
||||
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
|
||||
@@ -205,18 +202,6 @@ fn init_inner(version: &str, node_did: &str) {
|
||||
.set(peers_connected)
|
||||
.expect("set PEERS_CONNECTED once");
|
||||
|
||||
let pending_markers = IntGauge::with_opts(Opts::new(
|
||||
"gitlawb_pending_upload_markers",
|
||||
"Repos whose local copy is ahead of durable storage (pending-upload markers outstanding)",
|
||||
))
|
||||
.expect("gitlawb_pending_upload_markers definition");
|
||||
registry
|
||||
.register(Box::new(pending_markers.clone()))
|
||||
.expect("register gitlawb_pending_upload_markers");
|
||||
PENDING_UPLOAD_MARKERS
|
||||
.set(pending_markers)
|
||||
.expect("set PENDING_UPLOAD_MARKERS once");
|
||||
|
||||
REGISTRY
|
||||
.set(registry)
|
||||
.expect("set REGISTRY once (init must be called exactly once)");
|
||||
@@ -299,24 +284,6 @@ pub fn set_peers_connected(count: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the outstanding pending-upload marker gauge (repos whose local copy
|
||||
/// is ahead of durable storage).
|
||||
pub fn set_pending_upload_markers(count: i64) {
|
||||
if let Some(g) = PENDING_UPLOAD_MARKERS.get() {
|
||||
g.set(count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the pending-upload marker gauge by a delta (marker created = +1,
|
||||
/// marker removed = -1). The startup sweep seeds the absolute count via
|
||||
/// [`set_pending_upload_markers`]; runtime marker churn keeps it current
|
||||
/// through this.
|
||||
pub fn add_pending_upload_markers(delta: i64) {
|
||||
if let Some(g) = PENDING_UPLOAD_MARKERS.get() {
|
||||
g.add(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct AppState {
|
||||
pub graphql_schema: Arc<crate::graphql::GitlawbSchema>,
|
||||
/// Fly.io machine ID — used for fly-replay routing in multi-machine deployments
|
||||
pub machine_id: Option<String>,
|
||||
/// Centralized repo storage: local disk cache + optional object-store backend
|
||||
/// 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,
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
//! Repo-archive layer: stores a bare git repo as a single
|
||||
//! `repos/v1/{owner_slug}/{repo_name}.tar.zst` object on top of any
|
||||
//! [`BlobStore`] backend. Backend-agnostic replacement for the old
|
||||
//! single-backend (Tigris-only) client.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::BlobStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RepoArchive {
|
||||
store: Arc<dyn BlobStore>,
|
||||
}
|
||||
|
||||
impl RepoArchive {
|
||||
pub fn new(store: Arc<dyn BlobStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Object key for a repo archive.
|
||||
fn key(owner_slug: &str, repo_name: &str) -> String {
|
||||
format!("repos/v1/{owner_slug}/{repo_name}.tar.zst")
|
||||
}
|
||||
|
||||
/// Current archive etag, or `None` if the repo isn't in storage yet.
|
||||
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
|
||||
let key = Self::key(owner_slug, repo_name);
|
||||
Ok(self
|
||||
.store
|
||||
.head(&key)
|
||||
.await?
|
||||
.map(|m| m.etag.unwrap_or_else(|| format!("size:{}", m.size))))
|
||||
}
|
||||
|
||||
/// Whether the repo archive exists in storage.
|
||||
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
|
||||
Ok(self
|
||||
.store
|
||||
.head(&Self::key(owner_slug, repo_name))
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Compress the bare repo and upload it. Returns the new etag (for the
|
||||
/// skip-redundant-download cache).
|
||||
pub async fn upload(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
self.upload_with_intent(owner_slug, repo_name, local_path, |_| Ok(()))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`upload`](Self::upload), but calls `record_intent` with the
|
||||
/// archive's content MD5 after compressing and BEFORE the PUT. On backends
|
||||
/// whose etag is the body MD5 (S3-compatibles for single-part puts; the fs
|
||||
/// backend by construction), that value equals the etag the PUT will
|
||||
/// produce, letting crash recovery recognize "storage holds exactly what
|
||||
/// this node was uploading" — its own completed upload, not divergence.
|
||||
/// `record_intent` returning `Err` ABORTS the upload before the PUT: an
|
||||
/// intent that could not be durably recorded must not be outrun by the
|
||||
/// upload it describes, or a crash after the PUT reads as external
|
||||
/// divergence. Recovery validates a candidate by fetching the remote
|
||||
/// bytes and comparing their MD5, so the mechanism is backend-independent
|
||||
/// (it does not assume the backend's etag is a content hash).
|
||||
pub async fn upload_with_intent<F>(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
record_intent: F,
|
||||
) -> Result<Option<String>>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<()> + Send,
|
||||
{
|
||||
let key = Self::key(owner_slug, repo_name);
|
||||
let archive_bytes = tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || compress_repo(&local_path)
|
||||
})
|
||||
.await
|
||||
.context("tar task panicked")?
|
||||
.context("compressing repo")?;
|
||||
|
||||
record_intent(&content_md5_hex(&archive_bytes))
|
||||
.context("recording upload intent before PUT")?;
|
||||
|
||||
let meta = self
|
||||
.store
|
||||
.put(&key, Bytes::from(archive_bytes))
|
||||
.await
|
||||
.context("uploading repo archive")?;
|
||||
info!(key = %key, backend = self.store.backend_name(), "uploaded repo archive");
|
||||
Ok(meta.etag.or_else(|| Some(format!("size:{}", meta.size))))
|
||||
}
|
||||
|
||||
/// Download the repo archive and extract it to `local_path` (atomic swap).
|
||||
pub async fn download(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
let key = Self::key(owner_slug, repo_name);
|
||||
debug!(key = %key, "downloading repo archive");
|
||||
let data = self
|
||||
.store
|
||||
.get(&key)
|
||||
.await
|
||||
.context("fetching repo archive")?
|
||||
.ok_or_else(|| anyhow::anyhow!("repo archive missing: {key}"))?;
|
||||
|
||||
tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || decompress_repo(&data, &local_path)
|
||||
})
|
||||
.await
|
||||
.context("extract task panicked")?
|
||||
.context("extracting repo")?;
|
||||
info!(key = %key, path = %local_path.display(), "downloaded repo archive");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the raw archive object bytes (no extraction). Recovery-path
|
||||
/// helper: lets the caller validate a heal candidate by hashing the
|
||||
/// actual remote content instead of trusting backend etag semantics.
|
||||
pub async fn fetch_raw(&self, owner_slug: &str, repo_name: &str) -> Result<Option<Bytes>> {
|
||||
self.store.get(&Self::key(owner_slug, repo_name)).await
|
||||
}
|
||||
|
||||
/// Delete a repo archive. No production caller yet (creation flows are
|
||||
/// claim-first, so they never need to delete an archive); kept for the
|
||||
/// repo-deletion path that will need it.
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
|
||||
self.store.delete(&Self::key(owner_slug, repo_name)).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex MD5 of a byte body — the etag an S3-compatible single-part PUT (and
|
||||
/// the fs backend, by construction) will assign to it.
|
||||
pub(crate) fn content_md5_hex(bytes: &[u8]) -> String {
|
||||
use md5::{Digest, Md5};
|
||||
format!("{:x}", Md5::digest(bytes))
|
||||
}
|
||||
|
||||
/// Remove orphaned swap-phase work dirs (`.{repo}.tmp-extract.{uuid}` and
|
||||
/// `.{repo}.bak-{uuid}`) left under `repos_dir/{owner_slug}/` by a process
|
||||
/// killed mid-`decompress_repo`. Safe to run only at startup, before any
|
||||
/// extraction is in flight: a live swap owns exactly these names. Deleting a
|
||||
/// `.bak-` orphan loses no data — these dirs only exist on the download path,
|
||||
/// so storage still holds the archive and the next `acquire()` re-downloads.
|
||||
/// Returns the number of directories removed.
|
||||
pub fn sweep_orphaned_swap_dirs(repos_dir: &Path) -> usize {
|
||||
let mut removed = 0;
|
||||
let Ok(owners) = std::fs::read_dir(repos_dir) else {
|
||||
return 0;
|
||||
};
|
||||
for owner in owners.flatten() {
|
||||
let owner_path = owner.path();
|
||||
if !owner_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&owner_path) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let is_swap_litter =
|
||||
name.starts_with('.') && (name.contains(".tmp-extract.") || name.contains(".bak-"));
|
||||
if is_swap_litter && entry.path().is_dir() {
|
||||
match std::fs::remove_dir_all(entry.path()) {
|
||||
Ok(()) => {
|
||||
info!(dir = %entry.path().display(), "removed orphaned swap dir");
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %entry.path().display(), err = %e,
|
||||
"failed to remove orphaned swap dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Compress a bare repo directory into a tar.zst byte vector.
|
||||
fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
let buf = Vec::new();
|
||||
// Level 3 = fast + decent ratio. Compression dominates the post-push upload
|
||||
// for larger repos; zstd's multithreaded mode splits the stream across
|
||||
// worker threads for a near-linear speedup at the same ratio. Capped so a
|
||||
// single push can't monopolize a small node's cores.
|
||||
let mut encoder = zstd::stream::Encoder::new(buf, 3)?;
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(|n| n.get().min(4) as u32)
|
||||
.unwrap_or(1);
|
||||
if workers > 1 {
|
||||
encoder
|
||||
.multithread(workers)
|
||||
.context("enabling multithreaded zstd")?;
|
||||
}
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
tar.append_dir_all(".", repo_path)
|
||||
.context("building tar archive")?;
|
||||
let encoder = tar.into_inner().context("finishing tar")?;
|
||||
let compressed = encoder.finish().context("finishing zstd")?;
|
||||
Ok(compressed)
|
||||
}
|
||||
|
||||
/// Per-repo-path lock serializing the publish (swap-into-place) step of
|
||||
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
|
||||
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
|
||||
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
|
||||
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
|
||||
// entry accrues per distinct repo path for the process lifetime. Bounded by
|
||||
// the number of repos a node hosts, so it's negligible for normal use, but
|
||||
// high-volume/churning deployments may want LRU or weak-ref eviction here.
|
||||
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
|
||||
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut map = locks.lock().expect("publish lock map poisoned");
|
||||
map.entry(local_path.to_path_buf())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
/// 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.
|
||||
//
|
||||
// Built through the shared sibling barrier rather than a bare `join`: the
|
||||
// name embeds `file_name`, which carries user-provided data from the repo
|
||||
// name, and this path is handed straight to `create_dir_all`, `unpack` and
|
||||
// `remove_dir_all`. See `repo_store::validated_sibling_path`.
|
||||
let tmp_dir = crate::git::repo_store::validated_sibling_path(
|
||||
local_path,
|
||||
&format!(".{file_name}.tmp-extract.{}", uuid::Uuid::new_v4()),
|
||||
)?;
|
||||
|
||||
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
|
||||
|
||||
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");
|
||||
let swap = (|| -> Result<()> {
|
||||
// Move any existing repo aside to a backup first, rather than deleting
|
||||
// it up front: if the rename of the new copy then fails, we restore the
|
||||
// backup so `local_path` is never left without a valid repo. (Most
|
||||
// platforms refuse to rename onto a non-empty dir, hence the move-aside.)
|
||||
let backup = if local_path.exists() {
|
||||
// Same sibling barrier as the temp-extract dir above: the name
|
||||
// embeds the user-provided repo file name, and this path is renamed
|
||||
// onto and later removed recursively.
|
||||
let b = crate::git::repo_store::validated_sibling_path(
|
||||
local_path,
|
||||
&format!(".{file_name}.bak-{}", uuid::Uuid::new_v4()),
|
||||
)?;
|
||||
std::fs::rename(local_path, &b).context("moving existing repo to backup")?;
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place") {
|
||||
Ok(()) => {
|
||||
if let Some(b) = backup {
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Restore the previous copy so the repo isn't left missing.
|
||||
if let Some(b) = backup {
|
||||
let _ = std::fs::rename(&b, local_path);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
})();
|
||||
if swap.is_err() {
|
||||
// Don't leak the extracted temp dir if the swap failed.
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
}
|
||||
swap
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn seed_repo(dir: &std::path::Path) {
|
||||
fs::create_dir_all(dir.join("refs/heads")).unwrap();
|
||||
fs::write(dir.join("HEAD"), b"ref: refs/heads/main\n").unwrap();
|
||||
fs::write(dir.join("refs/heads/main"), b"abc123\n").unwrap();
|
||||
fs::write(dir.join("config"), b"[core]\n\tbare = true\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_decompress_round_trip_preserves_files() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
seed_repo(src.path());
|
||||
|
||||
let bytes = compress_repo(src.path()).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
|
||||
let out_parent = tempfile::tempdir().unwrap();
|
||||
let out = out_parent.path().join("restored.git");
|
||||
decompress_repo(&bytes, &out).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read(out.join("HEAD")).unwrap(),
|
||||
b"ref: refs/heads/main\n"
|
||||
);
|
||||
assert_eq!(fs::read(out.join("refs/heads/main")).unwrap(), b"abc123\n");
|
||||
assert_eq!(
|
||||
fs::read(out.join("config")).unwrap(),
|
||||
b"[core]\n\tbare = true\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_swap_replaces_existing_dir_atomically() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
fs::write(src.path().join("HEAD"), b"new\n").unwrap();
|
||||
let bytes = compress_repo(src.path()).unwrap();
|
||||
|
||||
// Pre-existing copy with stale junk that the swap must fully replace.
|
||||
let out_parent = tempfile::tempdir().unwrap();
|
||||
let out = out_parent.path().join("repo.git");
|
||||
fs::create_dir_all(&out).unwrap();
|
||||
fs::write(out.join("STALE"), b"old\n").unwrap();
|
||||
|
||||
decompress_repo(&bytes, &out).unwrap();
|
||||
assert_eq!(fs::read(out.join("HEAD")).unwrap(), b"new\n");
|
||||
assert!(
|
||||
!out.join("STALE").exists(),
|
||||
"stale content must be gone after the swap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_corrupt_archive_leaves_existing_copy_untouched() {
|
||||
let out_parent = tempfile::tempdir().unwrap();
|
||||
let out = out_parent.path().join("repo.git");
|
||||
fs::create_dir_all(&out).unwrap();
|
||||
fs::write(out.join("HEAD"), b"good\n").unwrap();
|
||||
|
||||
// Garbage is not a valid tar.zst: unpack fails before the swap, so the
|
||||
// existing copy is preserved (atomicity claim).
|
||||
assert!(decompress_repo(b"not a real archive", &out).is_err());
|
||||
assert_eq!(fs::read(out.join("HEAD")).unwrap(), b"good\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_removes_only_orphaned_swap_dirs() {
|
||||
let repos = tempfile::tempdir().unwrap();
|
||||
let owner = repos.path().join("did_key_z6MkAlice");
|
||||
|
||||
// Litter from an interrupted swap...
|
||||
let tmp_extract = owner.join(".repo.git.tmp-extract.1234-uuid");
|
||||
let bak = owner.join(".repo.git.bak-5678-uuid");
|
||||
// ...alongside things the sweep must not touch.
|
||||
let live_repo = owner.join("repo.git");
|
||||
let dotfile = owner.join(".keep");
|
||||
fs::create_dir_all(&tmp_extract).unwrap();
|
||||
fs::create_dir_all(&bak).unwrap();
|
||||
fs::create_dir_all(&live_repo).unwrap();
|
||||
fs::write(&dotfile, b"").unwrap();
|
||||
|
||||
assert_eq!(sweep_orphaned_swap_dirs(repos.path()), 2);
|
||||
assert!(!tmp_extract.exists());
|
||||
assert!(!bak.exists());
|
||||
assert!(live_repo.exists());
|
||||
assert!(dotfile.exists());
|
||||
|
||||
// Idempotent on a clean tree.
|
||||
assert_eq!(sweep_orphaned_swap_dirs(repos.path()), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_download_round_trip_over_fs_backend() {
|
||||
let store_dir = tempfile::tempdir().unwrap();
|
||||
let store: Arc<dyn BlobStore> =
|
||||
Arc::new(crate::storage::fs::FsBlobStore::new(store_dir.path()).unwrap());
|
||||
let archive = RepoArchive::new(store);
|
||||
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
seed_repo(src.path());
|
||||
|
||||
assert!(!archive.exists("owner", "repo").await.unwrap());
|
||||
let etag = archive.upload("owner", "repo", src.path()).await.unwrap();
|
||||
assert!(etag.is_some());
|
||||
assert!(archive.exists("owner", "repo").await.unwrap());
|
||||
|
||||
let out_parent = tempfile::tempdir().unwrap();
|
||||
let out = out_parent.path().join("repo.git");
|
||||
archive.download("owner", "repo", &out).await.unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join("HEAD")).unwrap(),
|
||||
b"ref: refs/heads/main\n"
|
||||
);
|
||||
assert_eq!(fs::read(out.join("refs/heads/main")).unwrap(), b"abc123\n");
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
//! Local filesystem blob backend.
|
||||
//!
|
||||
//! Stores each object as a file under a configured root directory, using the
|
||||
//! object key as a relative path. For self-hosters without S3 and for tests of
|
||||
//! the storage abstraction. The etag is the body's MD5 (matching S3 semantics
|
||||
//! for single-part puts) persisted in a `.etag` sidecar at write time, so the
|
||||
//! skip-redundant-download optimization can rely on "etag unchanged ⇒ content
|
||||
//! unchanged" even on filesystems with coarse timestamps, and an uploader can
|
||||
//! predict the etag its PUT will produce (crash-recovery provenance). Objects
|
||||
//! predating the sidecar fall back to a size-mtime fingerprint.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
|
||||
use super::{validate_key, BlobStore, ObjectMeta};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FsBlobStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsBlobStore {
|
||||
pub fn new(root: impl AsRef<Path>) -> Result<Self> {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
std::fs::create_dir_all(&root)
|
||||
.with_context(|| format!("creating storage dir {}", root.display()))?;
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
fn path_for(&self, key: &str) -> Result<PathBuf> {
|
||||
validate_key(key)?;
|
||||
let path = self.root.join(key);
|
||||
// Defence in depth: the resolved path must stay under root.
|
||||
if !path.starts_with(&self.root) {
|
||||
anyhow::bail!("blob key escaped storage root: {key}");
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Sidecar file persisting the object's etag: the body's MD5, computed at
|
||||
/// write time. RepoStore treats etag equality as proof the local copy is
|
||||
/// current, so different content must always yield a different token — a
|
||||
/// `size-mtime` fingerprint cannot guarantee that on mounted filesystems
|
||||
/// with coarse timestamp precision (two different same-size writes in one
|
||||
/// tick collide); a content hash can. Identical content re-written yields
|
||||
/// the same etag, which is semantically exact for a freshness token.
|
||||
fn sidecar_of(path: &Path) -> PathBuf {
|
||||
let mut os = path.as_os_str().to_owned();
|
||||
os.push(".etag");
|
||||
PathBuf::from(os)
|
||||
}
|
||||
|
||||
/// Striped lock serializing the sidecar+blob publish step of `put`.
|
||||
/// The two renames are only pairwise-consistent when same-key puts don't
|
||||
/// interleave: unserialized, put A's content can land under put B's etag,
|
||||
/// and "etag unchanged ⇒ content unchanged" breaks in the direction that
|
||||
/// serves stale data as current. A fixed stripe array (same-key puts
|
||||
/// always hash to the same stripe) bounds memory regardless of how many
|
||||
/// distinct keys the process ever writes; cross-key collisions only cost
|
||||
/// a moment of extra serialization on the cheap rename pair.
|
||||
fn put_publish_lock(path: &Path) -> &'static std::sync::Mutex<()> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
const STRIPES: usize = 64;
|
||||
static LOCKS: OnceLock<Vec<Mutex<()>>> = OnceLock::new();
|
||||
let locks = LOCKS.get_or_init(|| (0..STRIPES).map(|_| Mutex::new(())).collect());
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
&locks[(hasher.finish() as usize) % STRIPES]
|
||||
}
|
||||
|
||||
/// Fallback fingerprint for objects written before the sidecar existed.
|
||||
fn legacy_etag(md: &std::fs::Metadata) -> String {
|
||||
let mtime = md
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("{}-{}", md.len(), mtime)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobStore for FsBlobStore {
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"fs"
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> Result<Option<Bytes>> {
|
||||
let path = self.path_for(key)?;
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(data) => Ok(Some(Bytes::from(data))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).context(format!("reading {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(&self, key: &str, body: Bytes) -> Result<ObjectMeta> {
|
||||
let path = self.path_for(key)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("blob path has no parent")?
|
||||
.to_path_buf();
|
||||
// Unique temp name per write: a fixed suffix would let concurrent puts
|
||||
// to the same key overwrite each other's temp file and corrupt the blob.
|
||||
let tmp = path.with_extension(format!("{}.tmp-put", uuid::Uuid::new_v4()));
|
||||
let path2 = path.clone();
|
||||
// Atomic write: temp file in the same dir, then rename into place. On any
|
||||
// failure, remove the temp file so a failed write can't leak it. The
|
||||
// trailing stat + etag-sidecar write run inside the same blocking task —
|
||||
// no synchronous fs call ever touches the async runtime.
|
||||
tokio::task::spawn_blocking(move || -> Result<ObjectMeta> {
|
||||
std::fs::create_dir_all(&parent).context("creating blob parent dir")?;
|
||||
let etag = crate::storage::archive::content_md5_hex(&body);
|
||||
let sidecar = Self::sidecar_of(&path2);
|
||||
let sidecar_tmp = sidecar.with_extension(format!("{}.tmp-put", uuid::Uuid::new_v4()));
|
||||
// Remember the published etag so a failed blob rename can restore
|
||||
// it: leaving the NEW etag over the OLD content is no longer a
|
||||
// harmless redundant download — under the pending-marker rules an
|
||||
// unexplained remote etag is terminal divergence.
|
||||
let prev_etag = std::fs::read_to_string(&sidecar).ok();
|
||||
let write_and_swap = (|| -> Result<()> {
|
||||
std::fs::write(&tmp, &body).context("writing temp blob")?;
|
||||
// Publish the new etag BEFORE the blob (each via its own
|
||||
// tmp+rename): a crash between the two renames then yields
|
||||
// new-etag/old-content — a redundant download, or at worst a
|
||||
// loud-and-safe pending-marker divergence — instead of
|
||||
// old-etag/new-content, which etag-equality consumers would
|
||||
// silently serve as current while stale. The publish pair is
|
||||
// serialized per key so concurrent same-key puts can't
|
||||
// interleave one put's etag with another's content.
|
||||
let _guard = Self::put_publish_lock(&path2)
|
||||
.lock()
|
||||
.expect("put publish lock poisoned");
|
||||
std::fs::write(&sidecar_tmp, &etag).context("writing etag sidecar")?;
|
||||
std::fs::rename(&sidecar_tmp, &sidecar).context("publishing etag sidecar")?;
|
||||
std::fs::rename(&tmp, &path2).context("renaming blob into place")?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = write_and_swap {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let _ = std::fs::remove_file(&sidecar_tmp);
|
||||
// Roll the sidecar back to describe the content actually on
|
||||
// disk (the sidecar may have been published before the blob
|
||||
// rename failed).
|
||||
match prev_etag {
|
||||
Some(prev) => {
|
||||
let _ = std::fs::write(&sidecar, prev);
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&sidecar);
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
let md = std::fs::metadata(&path2).context("stat blob after write")?;
|
||||
Ok(ObjectMeta {
|
||||
size: md.len(),
|
||||
etag: Some(etag),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.context("fs put task panicked")?
|
||||
}
|
||||
|
||||
async fn head(&self, key: &str) -> Result<Option<ObjectMeta>> {
|
||||
let path = self.path_for(key)?;
|
||||
// Probe existence by io error kind, not path.exists(): a permission/IO
|
||||
// error must surface, not be silently reported as "not found".
|
||||
let md = match tokio::fs::metadata(&path).await {
|
||||
Ok(md) => md,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e).context(format!("stat {}", path.display())),
|
||||
};
|
||||
let etag = match tokio::fs::read_to_string(Self::sidecar_of(&path)).await {
|
||||
Ok(tag) => tag.trim().to_string(),
|
||||
// Object written before the sidecar existed — legacy fingerprint.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::legacy_etag(&md),
|
||||
Err(e) => {
|
||||
return Err(e).context(format!("reading etag sidecar for {}", path.display()))
|
||||
}
|
||||
};
|
||||
Ok(Some(ObjectMeta {
|
||||
size: md.len(),
|
||||
etag: Some(etag),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<()> {
|
||||
let path = self.path_for(key)?;
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e).context(format!("deleting {}", path.display())),
|
||||
}
|
||||
match tokio::fs::remove_file(Self::sidecar_of(&path)).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e).context(format!("deleting etag sidecar for {}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only helper: enumerate stored keys. `list` was dropped from the
|
||||
/// `BlobStore` trait until a production consumer (GC/admin/migration) exists;
|
||||
/// the tests here still need it to assert on stored state.
|
||||
#[cfg(test)]
|
||||
impl FsBlobStore {
|
||||
async fn list(&self, prefix: &str) -> Result<Vec<String>> {
|
||||
let root = self.root.clone();
|
||||
let prefix = prefix.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
|
||||
let mut keys = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
// Propagate read errors rather than skipping: a partial listing
|
||||
// reported as success would mislead GC/admin/migration callers.
|
||||
let rd = std::fs::read_dir(&dir)
|
||||
.with_context(|| format!("listing {}", dir.display()))?;
|
||||
for entry in rd {
|
||||
// Propagate per-entry errors rather than dropping them via
|
||||
// flatten(): a partial listing must not look like success.
|
||||
let entry =
|
||||
entry.with_context(|| format!("reading entry under {}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Ok(rel) = path.strip_prefix(&root) {
|
||||
let key = rel.to_string_lossy().replace('\\', "/");
|
||||
// Etag sidecars are backend metadata, not objects.
|
||||
if key.starts_with(&prefix) && !key.ends_with(".etag") {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
})
|
||||
.await
|
||||
.context("fs list task panicked")?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_head_delete_list_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsBlobStore::new(dir.path()).unwrap();
|
||||
|
||||
// Absent key
|
||||
assert!(store.get("repos/v1/a/x.tar.zst").await.unwrap().is_none());
|
||||
assert!(store.head("repos/v1/a/x.tar.zst").await.unwrap().is_none());
|
||||
|
||||
// Put then get
|
||||
let body = Bytes::from_static(b"hello blob");
|
||||
let meta = store
|
||||
.put("repos/v1/a/x.tar.zst", body.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(meta.size, body.len() as u64);
|
||||
assert!(meta.etag.is_some());
|
||||
let got = store.get("repos/v1/a/x.tar.zst").await.unwrap().unwrap();
|
||||
assert_eq!(got, body);
|
||||
|
||||
// Head returns matching etag (stable across reads)
|
||||
let h = store.head("repos/v1/a/x.tar.zst").await.unwrap().unwrap();
|
||||
assert_eq!(h.etag, meta.etag);
|
||||
|
||||
// List by prefix
|
||||
store
|
||||
.put("repos/v1/b/y.tar.zst", Bytes::from_static(b"y"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut keys = store.list("repos/v1/").await.unwrap();
|
||||
keys.sort();
|
||||
assert_eq!(keys, vec!["repos/v1/a/x.tar.zst", "repos/v1/b/y.tar.zst"]);
|
||||
|
||||
// Delete is idempotent
|
||||
store.delete("repos/v1/a/x.tar.zst").await.unwrap();
|
||||
store.delete("repos/v1/a/x.tar.zst").await.unwrap();
|
||||
assert!(store.get("repos/v1/a/x.tar.zst").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn etag_is_persisted_content_md5() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsBlobStore::new(dir.path()).unwrap();
|
||||
let key = "repos/v1/a/x.tar.zst";
|
||||
let body = Bytes::from_static(b"same bytes");
|
||||
|
||||
// Content-addressed: different same-size content must yield a
|
||||
// different etag even inside one coarse-filesystem timestamp tick
|
||||
// (the size-mtime failure mode); identical content is the same token.
|
||||
let m1 = store.put(key, body.clone()).await.unwrap();
|
||||
let m2 = store
|
||||
.put(key, Bytes::from_static(b"diff bytes"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(m1.etag, m2.etag, "different content ⇒ different etag");
|
||||
let m3 = store.put(key, body).await.unwrap();
|
||||
assert_eq!(m1.etag, m3.etag, "identical content ⇒ identical etag");
|
||||
assert_eq!(
|
||||
m3.etag.as_deref(),
|
||||
Some(crate::storage::archive::content_md5_hex(b"same bytes").as_str()),
|
||||
"etag must be the body MD5 (predictable pre-PUT)"
|
||||
);
|
||||
|
||||
// head() reports the persisted sidecar etag, not a recomputed
|
||||
// fingerprint.
|
||||
let h = store.head(key).await.unwrap().unwrap();
|
||||
assert_eq!(h.etag, m3.etag);
|
||||
let sidecar = dir.path().join(format!("{key}.etag"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&sidecar).unwrap(),
|
||||
m3.etag.clone().unwrap(),
|
||||
"etag must come from the persisted sidecar"
|
||||
);
|
||||
|
||||
// delete() removes the sidecar with the object.
|
||||
store.delete(key).await.unwrap();
|
||||
assert!(store.head(key).await.unwrap().is_none());
|
||||
assert!(store.list("repos/v1/").await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_key_traversal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsBlobStore::new(dir.path()).unwrap();
|
||||
assert!(store.get("../escape").await.is_err());
|
||||
assert!(store.put("a/../../etc/passwd", Bytes::new()).await.is_err());
|
||||
// Backslashes are separators on Windows — must be rejected as keys.
|
||||
assert!(store.get("a\\..\\escape").await.is_err());
|
||||
assert!(store.put("repos\\v1\\x", Bytes::new()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_puts_same_key_do_not_corrupt_or_leak_temps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsBlobStore::new(dir.path()).unwrap();
|
||||
let key = "repos/v1/a/x.tar.zst";
|
||||
let body = Bytes::from_static(b"the-one-true-blob");
|
||||
|
||||
// Many concurrent writers of the same key: with a fixed temp name they
|
||||
// would clobber each other's temp file mid-write and corrupt the result.
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let store = store.clone();
|
||||
let body = body.clone();
|
||||
handles.push(tokio::spawn(async move { store.put(key, body).await }));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
// Final content is intact...
|
||||
assert_eq!(store.get(key).await.unwrap().unwrap(), body);
|
||||
// ...and no unique-suffixed temp files were left behind.
|
||||
let leftovers: Vec<String> = store
|
||||
.list("repos/v1/")
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|k| k.contains("tmp-put"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "leaked temp files: {leftovers:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//! IPFS (content-addressed) blob backend over a Kubo node's Mutable File System.
|
||||
//!
|
||||
//! Kubo's MFS (`/api/v0/files/*`) provides a path-addressed, mutable namespace
|
||||
//! backed by content-addressed IPFS objects — a natural fit for a key→blob store.
|
||||
//! Each object's etag is its IPFS CID (from `files/stat`), giving true
|
||||
//! content-addressing for the skip-redundant-download optimization.
|
||||
//!
|
||||
//! Requires a reachable Kubo HTTP API (`GITLAWB_IPFS_API`, e.g.
|
||||
//! `http://127.0.0.1:5001`). Objects written here are also retrievable by CID
|
||||
//! from the wider IPFS network once pinned/announced by the node.
|
||||
//!
|
||||
//! DEPLOYMENT RESTRICTION: MFS is the mutable namespace of ONE Kubo daemon —
|
||||
//! nothing here publishes a shared CID registry or IPNS mapping. Every
|
||||
//! gitlawb node in a network using this backend MUST point at the same Kubo
|
||||
//! instance; separate daemons produce disjoint namespaces where a push
|
||||
//! accepted on one node is invisible to the others. `storage::build` logs a
|
||||
//! startup warning to the same effect.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
|
||||
use super::{validate_key, BlobStore, ObjectMeta};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IpfsBlobStore {
|
||||
api: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl IpfsBlobStore {
|
||||
pub fn new(api: &str) -> Result<Self> {
|
||||
// Bound requests so an unresponsive Kubo API can't hang push/write flows
|
||||
// indefinitely. connect_timeout guards the dial; the generous total
|
||||
// timeout still allows large repo-archive transfers.
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.context("building IPFS HTTP client")?;
|
||||
Ok(Self {
|
||||
api: api.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// MFS path for a key: `/gitlawb/<key>` (namespaced to avoid clobbering
|
||||
/// other MFS users on a shared node).
|
||||
fn mfs_path(key: &str) -> String {
|
||||
format!("/gitlawb/{key}")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobStore for IpfsBlobStore {
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"ipfs"
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> Result<Option<Bytes>> {
|
||||
validate_key(key)?;
|
||||
let url = format!("{}/api/v0/files/read", self.api);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.query(&[("arg", Self::mfs_path(key).as_str())])
|
||||
.send()
|
||||
.await
|
||||
.context("IPFS files/read")?;
|
||||
if resp.status().is_success() {
|
||||
Ok(Some(resp.bytes().await.context("reading IPFS body")?))
|
||||
} else {
|
||||
// Kubo returns 500 with a JSON message when the path is absent.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if body.contains("does not exist") || body.contains("no link named") {
|
||||
Ok(None)
|
||||
} else {
|
||||
anyhow::bail!("IPFS files/read {key}: {body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(&self, key: &str, body: Bytes) -> Result<ObjectMeta> {
|
||||
validate_key(key)?;
|
||||
let size = body.len() as u64;
|
||||
let url = format!("{}/api/v0/files/write", self.api);
|
||||
// Stream the body instead of copying it via to_vec — avoids doubling
|
||||
// peak memory for large archives. Length is known, so set it explicitly.
|
||||
let part = reqwest::multipart::Part::stream_with_length(reqwest::Body::from(body), size)
|
||||
.file_name("blob");
|
||||
let form = reqwest::multipart::Form::new().part("data", part);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.query(&[
|
||||
("arg", Self::mfs_path(key).as_str()),
|
||||
("create", "true"),
|
||||
("parents", "true"),
|
||||
("truncate", "true"),
|
||||
])
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.context("IPFS files/write")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let b = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("IPFS files/write {key} returned {status}: {b}");
|
||||
}
|
||||
// etag = CID from stat. The write already succeeded, so a failed stat
|
||||
// must not fail the put — just return without an etag (callers treat a
|
||||
// missing etag as "always re-check", never as a lost write).
|
||||
let etag = match self.head(key).await {
|
||||
Ok(m) => m.and_then(|m| m.etag),
|
||||
Err(e) => {
|
||||
tracing::warn!(key = %key, err = %e, "IPFS stat after write failed — returning no etag");
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(ObjectMeta { size, etag })
|
||||
}
|
||||
|
||||
async fn head(&self, key: &str) -> Result<Option<ObjectMeta>> {
|
||||
validate_key(key)?;
|
||||
let url = format!("{}/api/v0/files/stat", self.api);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.query(&[("arg", Self::mfs_path(key).as_str())])
|
||||
.send()
|
||||
.await
|
||||
.context("IPFS files/stat")?;
|
||||
if resp.status().is_success() {
|
||||
let v: serde_json::Value = resp.json().await.context("parsing files/stat")?;
|
||||
Ok(Some(ObjectMeta {
|
||||
size: v.get("Size").and_then(|s| s.as_u64()).unwrap_or(0),
|
||||
etag: v
|
||||
.get("Hash")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
}))
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if body.contains("does not exist") || body.contains("no link named") {
|
||||
Ok(None)
|
||||
} else {
|
||||
anyhow::bail!("IPFS files/stat {key}: {body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<()> {
|
||||
validate_key(key)?;
|
||||
let url = format!("{}/api/v0/files/rm", self.api);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.query(&[("arg", Self::mfs_path(key).as_str()), ("force", "true")])
|
||||
.send()
|
||||
.await
|
||||
.context("IPFS files/rm")?;
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if body.contains("does not exist") || body.contains("no link named") {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("IPFS files/rm {key}: {body}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
//! Storage-agnostic blob layer.
|
||||
//!
|
||||
//! Repos are persisted to a pluggable object store behind the [`BlobStore`]
|
||||
//! trait. Backends:
|
||||
//! - [`s3::S3BlobStore`] — any S3-compatible service (Tigris, Cloudflare R2,
|
||||
//! AWS S3, MinIO, Backblaze B2). Selected by default when a bucket is set.
|
||||
//! - [`fs::FsBlobStore`] — a local/mounted directory; for self-hosters & tests.
|
||||
//! - [`ipfs::IpfsBlobStore`] — content-addressed storage over a Kubo (IPFS) node
|
||||
//! using its Mutable File System (MFS) for a key→blob namespace.
|
||||
//!
|
||||
//! Higher layers ([`archive::RepoArchive`]) compose a bare repo into a single
|
||||
//! `repos/v1/{slug}/{repo}.tar.zst` object on top of whichever backend is active,
|
||||
//! so the repo-storage semantics are identical regardless of backend.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub mod archive;
|
||||
pub mod fs;
|
||||
pub mod ipfs;
|
||||
pub mod s3;
|
||||
|
||||
/// Metadata about a stored object. `etag` is an opaque change-detection token
|
||||
/// (S3 ETag, IPFS CID, or the persisted content MD5 for the filesystem
|
||||
/// backend).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObjectMeta {
|
||||
pub size: u64,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
/// A backend-agnostic key→bytes object store.
|
||||
///
|
||||
/// Keys are forward-slash-delimited paths (e.g. `repos/v1/slug/repo.tar.zst`).
|
||||
/// Implementations must reject `..` traversal in keys.
|
||||
#[async_trait]
|
||||
pub trait BlobStore: Send + Sync {
|
||||
/// Short backend name, for logs.
|
||||
fn backend_name(&self) -> &'static str;
|
||||
|
||||
/// Fetch an object. Returns `None` if the key does not exist.
|
||||
async fn get(&self, key: &str) -> Result<Option<Bytes>>;
|
||||
|
||||
/// Store an object, returning its metadata (including the new etag).
|
||||
async fn put(&self, key: &str, body: Bytes) -> Result<ObjectMeta>;
|
||||
|
||||
/// Fetch object metadata without the body. Returns `None` if absent.
|
||||
async fn head(&self, key: &str) -> Result<Option<ObjectMeta>>;
|
||||
|
||||
/// Delete an object. Succeeds (no-op) if the key does not exist.
|
||||
async fn delete(&self, key: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Build the configured blob store.
|
||||
///
|
||||
/// Returns `Ok(None)` only when no backend is configured at all (local-only
|
||||
/// passthrough mode). A misconfigured backend (missing required setting, or a
|
||||
/// client that fails to construct) returns `Err`: we fail closed rather than
|
||||
/// silently degrading to local-only, which would accept writes without the
|
||||
/// intended durable backend and risk cross-node persistence drift.
|
||||
///
|
||||
/// Note: this validates configuration and client construction, not live
|
||||
/// connectivity — e.g. the S3 client builds successfully against an unreachable
|
||||
/// or wrong bucket, and that surfaces as an error on the first real request.
|
||||
///
|
||||
/// Selection order:
|
||||
/// 1. Explicit `GITLAWB_STORAGE_BACKEND` (`s3` | `fs` | `ipfs`).
|
||||
/// 2. Auto: `s3` if a bucket is configured (incl. legacy `GITLAWB_TIGRIS_BUCKET`),
|
||||
/// else `fs` if `GITLAWB_STORAGE_FS_DIR` is set,
|
||||
/// else local-only.
|
||||
///
|
||||
/// The `ipfs` backend is never auto-selected: `GITLAWB_IPFS_API` predates this
|
||||
/// layer and configures the per-object encrypted pinning path, so treating its
|
||||
/// presence as "store repo archives in IPFS" would silently repurpose an
|
||||
/// existing pinning config on upgrade. Routing repo archives into IPFS MFS
|
||||
/// requires the explicit `GITLAWB_STORAGE_BACKEND=ipfs` opt-in.
|
||||
pub async fn build(config: &Config) -> Result<Option<Arc<dyn BlobStore>>> {
|
||||
let bucket = if !config.s3_bucket.is_empty() {
|
||||
config.s3_bucket.clone()
|
||||
} else {
|
||||
config.tigris_bucket.clone()
|
||||
};
|
||||
|
||||
let backend = if !config.storage_backend.is_empty() {
|
||||
config.storage_backend.to_ascii_lowercase()
|
||||
} else if !bucket.is_empty() {
|
||||
"s3".to_string()
|
||||
} else if !config.storage_fs_dir.is_empty() {
|
||||
"fs".to_string()
|
||||
} else {
|
||||
info!("object storage disabled (no backend configured) — local-only mode");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// A backend was selected (explicitly or by auto-detection); fail closed from
|
||||
// here — a missing required setting or an init failure is a hard error.
|
||||
match backend.as_str() {
|
||||
"s3" => {
|
||||
if bucket.is_empty() {
|
||||
anyhow::bail!(
|
||||
"storage backend=s3 but no bucket configured (set GITLAWB_S3_BUCKET)"
|
||||
);
|
||||
}
|
||||
let endpoint = (!config.s3_endpoint.is_empty()).then(|| config.s3_endpoint.clone());
|
||||
let s = s3::S3BlobStore::new(&bucket, endpoint, config.s3_force_path_style)
|
||||
.await
|
||||
.context("initializing S3 storage")?;
|
||||
info!(bucket = %bucket, backend = "s3", "object storage enabled");
|
||||
Ok(Some(Arc::new(s) as Arc<dyn BlobStore>))
|
||||
}
|
||||
"fs" => {
|
||||
if config.storage_fs_dir.is_empty() {
|
||||
anyhow::bail!("storage backend=fs but GITLAWB_STORAGE_FS_DIR is empty");
|
||||
}
|
||||
let s = fs::FsBlobStore::new(&config.storage_fs_dir)
|
||||
.context("initializing filesystem storage")?;
|
||||
info!(dir = %config.storage_fs_dir, backend = "fs", "object storage enabled");
|
||||
Ok(Some(Arc::new(s) as Arc<dyn BlobStore>))
|
||||
}
|
||||
"ipfs" => {
|
||||
if config.ipfs_api.is_empty() {
|
||||
anyhow::bail!("storage backend=ipfs but GITLAWB_IPFS_API is empty");
|
||||
}
|
||||
let s =
|
||||
ipfs::IpfsBlobStore::new(&config.ipfs_api).context("initializing IPFS storage")?;
|
||||
// MFS is a namespace of ONE Kubo daemon, not a shared network
|
||||
// pointer: no CID registry or IPNS mapping is published, so two
|
||||
// nodes pointed at separate daemons read and write disjoint
|
||||
// storage and every cross-node archive assumption breaks.
|
||||
tracing::warn!(api = %config.ipfs_api, backend = "ipfs",
|
||||
"IPFS backend stores archives in the Kubo daemon's LOCAL MFS namespace — \
|
||||
ALL nodes sharing this network must point at the SAME Kubo instance; \
|
||||
separate daemons produce disjoint, silently-diverging storage");
|
||||
info!(api = %config.ipfs_api, backend = "ipfs", "object storage enabled");
|
||||
Ok(Some(Arc::new(s) as Arc<dyn BlobStore>))
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("unknown GITLAWB_STORAGE_BACKEND: {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject keys that could escape the namespace (`..`) or are absolute.
|
||||
pub(crate) fn validate_key(key: &str) -> Result<()> {
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("blob key is empty");
|
||||
}
|
||||
if key.split('/').any(|seg| seg == ".." || seg == ".") {
|
||||
anyhow::bail!("blob key contains traversal segment: {key}");
|
||||
}
|
||||
// Backslash is rejected outright: keys are forward-slash-delimited, and on
|
||||
// Windows `PathBuf::join` treats `\` as a separator, which would let a key
|
||||
// smuggle path components past the segment checks above.
|
||||
if key.starts_with('/') || key.contains('\\') || key.contains('\0') {
|
||||
anyhow::bail!("blob key is absolute, contains '\\', or contains null byte: {key}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//! S3-compatible blob backend.
|
||||
//!
|
||||
//! Works with any S3 API implementation: Tigris, Cloudflare R2, AWS S3, MinIO,
|
||||
//! Backblaze B2. Credentials and (for Tigris on Fly) the endpoint are read from
|
||||
//! the standard AWS env vars (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
|
||||
//! `AWS_ENDPOINT_URL_S3`, `AWS_REGION`). `endpoint`/`force_path_style` override
|
||||
//! those for self-hosted services like MinIO.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use bytes::Bytes;
|
||||
use tracing::debug;
|
||||
|
||||
use super::{validate_key, BlobStore, ObjectMeta};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct S3BlobStore {
|
||||
s3: S3Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl S3BlobStore {
|
||||
/// Build a client. `endpoint` overrides `AWS_ENDPOINT_URL_S3` (for R2/MinIO);
|
||||
/// `force_path_style` is required by MinIO and some S3-compatibles.
|
||||
pub async fn new(
|
||||
bucket: &str,
|
||||
endpoint: Option<String>,
|
||||
force_path_style: bool,
|
||||
) -> Result<Self> {
|
||||
let shared = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
||||
let mut builder = aws_sdk_s3::config::Builder::from(&shared);
|
||||
if let Some(ep) = endpoint {
|
||||
builder = builder.endpoint_url(ep);
|
||||
}
|
||||
if force_path_style {
|
||||
builder = builder.force_path_style(true);
|
||||
}
|
||||
// Bound requests so a hung endpoint can't block the calling task forever
|
||||
// (under async_upload it would hold the advisory lock and stall every
|
||||
// later push). attempt_timeout bounds a single try; operation_timeout
|
||||
// bounds the whole call incl. retries — generous enough for large
|
||||
// archive transfers.
|
||||
let timeouts = aws_sdk_s3::config::timeout::TimeoutConfig::builder()
|
||||
.operation_attempt_timeout(std::time::Duration::from_secs(60))
|
||||
.operation_timeout(std::time::Duration::from_secs(300))
|
||||
.build();
|
||||
builder = builder.timeout_config(timeouts);
|
||||
let s3 = S3Client::from_conf(builder.build());
|
||||
Ok(Self {
|
||||
s3,
|
||||
bucket: bucket.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only constructor with an explicit S3 endpoint and static
|
||||
/// credentials — no env-var reads, so parallel tests cannot race each
|
||||
/// other's `AWS_*` environment the way the env-based `new` would. Lets a
|
||||
/// test point the client at a non-routable endpoint to exercise
|
||||
/// acquire-stall paths; deliberately no operation timeouts, so the stall
|
||||
/// is bounded only by the caller's own deadline.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(aws_config::Region::new("auto"))
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
Self {
|
||||
s3: S3Client::new(&config),
|
||||
bucket: bucket.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobStore for S3BlobStore {
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"s3"
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> Result<Option<Bytes>> {
|
||||
validate_key(key)?;
|
||||
match self
|
||||
.s3
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let data = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.context("reading S3 response body")?
|
||||
.into_bytes();
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(e) => {
|
||||
if e.as_service_error().is_some_and(|e| e.is_no_such_key()) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("S3 GET {key}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(&self, key: &str, body: Bytes) -> Result<ObjectMeta> {
|
||||
validate_key(key)?;
|
||||
let size = body.len() as u64;
|
||||
let resp = self
|
||||
.s3
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.send()
|
||||
.await
|
||||
.context(format!("S3 PUT {key}"))?;
|
||||
debug!(key = %key, size, "s3 put");
|
||||
Ok(ObjectMeta {
|
||||
size,
|
||||
etag: resp.e_tag().map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn head(&self, key: &str) -> Result<Option<ObjectMeta>> {
|
||||
validate_key(key)?;
|
||||
match self
|
||||
.s3
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => Ok(Some(ObjectMeta {
|
||||
size: resp.content_length().unwrap_or(0).max(0) as u64,
|
||||
etag: resp.e_tag().map(|s| s.to_string()),
|
||||
})),
|
||||
Err(e) => {
|
||||
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("S3 HEAD {key}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<()> {
|
||||
validate_key(key)?;
|
||||
self.s3
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.context(format!("S3 DELETE {key}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2390,6 +2390,456 @@ mod tests {
|
||||
authorization"
|
||||
);
|
||||
}
|
||||
/// Delegated push, end to end through both auth layers.
|
||||
///
|
||||
/// A non-owner presenting an invocation whose chain roots at the repo owner
|
||||
/// clears the owner-push gate; the same signer without one, and with one that
|
||||
/// names a different repository, are both refused. This is the regression the
|
||||
/// owner-push default introduced: a CI or delegated key holding a valid
|
||||
/// `git/push` capability was refused exactly like a stranger.
|
||||
///
|
||||
/// Status codes are the discriminators. 500 means the request passed
|
||||
/// `require_signature` (not 401), passed `require_ucan_chain` (not 401), and
|
||||
/// cleared the owner gate (not 403), then reached git on a repo with no disk
|
||||
/// backing — the same shape `git_upload_pack_post_is_read_gated_on_private_repo`
|
||||
/// relies on. A bare `!= 403` would let a 401 regression pass.
|
||||
///
|
||||
/// Not `#[cfg(unix)]`: no fake-git shim is involved, only HTTP and the gate.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_clears_the_owner_gate(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
// Explicit rather than relying on the shipped default, so this test states
|
||||
// the configuration it is about.
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "deleg"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(crate::auth::require_signature))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
|
||||
let path = format!("/{short}/deleg.git/git-receive-pack");
|
||||
let body = b"0000".to_vec();
|
||||
|
||||
// owner -> agent delegation, then agent -> node invocation carrying it.
|
||||
// Both links carry a finite expiry: a write capability that never lapses is
|
||||
// refused, since there is no revocation path to withdraw a leaked one.
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let invocation_with_exp = |resource: String, exp: Option<chrono::DateTime<chrono::Utc>>| {
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(resource, caps::GIT_PUSH)],
|
||||
exp,
|
||||
)
|
||||
.expect("issue delegation");
|
||||
Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
exp,
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap invocation")
|
||||
.encode()
|
||||
.expect("encode invocation")
|
||||
};
|
||||
let invocation_for = |resource: String| invocation_with_exp(resource, Some(hour));
|
||||
|
||||
let signed_push = |ucan: Option<String>| {
|
||||
let signed = sign_request(&agent, "POST", &path, &body);
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature);
|
||||
if let Some(token) = ucan {
|
||||
req = req.header("x-ucan", token);
|
||||
}
|
||||
req.body(Body::from(body.clone())).expect("request")
|
||||
};
|
||||
|
||||
// 1. Valid delegation for THIS repo: clears the gate.
|
||||
let resp = router()
|
||||
.oneshot(signed_push(Some(invocation_for(format!(
|
||||
"gitlawb://repos/{owner_did}/deleg"
|
||||
)))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"an owner-rooted git/push delegation must clear the owner gate and reach git"
|
||||
);
|
||||
|
||||
// 2. Delegation naming a DIFFERENT repo: refused.
|
||||
let other = router()
|
||||
.oneshot(signed_push(Some(invocation_for(format!(
|
||||
"gitlawb://repos/{owner_did}/someotherrepo"
|
||||
)))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
other.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a delegation for another repository must not authorize this push"
|
||||
);
|
||||
let other_body = axum::body::to_bytes(other.into_body(), 4096).await.unwrap();
|
||||
|
||||
// 3. No delegation at all: refused, with a byte-identical body. A caller
|
||||
// must not be able to tell a non-applicable delegation from none, or the
|
||||
// denial becomes an oracle for which capabilities exist.
|
||||
let none = router().oneshot(signed_push(None)).await.unwrap();
|
||||
assert_eq!(
|
||||
none.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a non-owner with no delegation must still be refused"
|
||||
);
|
||||
let none_body = axum::body::to_bytes(none.into_body(), 4096).await.unwrap();
|
||||
assert_eq!(
|
||||
other_body, none_body,
|
||||
"an inapplicable delegation and no delegation must be indistinguishable"
|
||||
);
|
||||
|
||||
// 4. A delegation that never expires: refused, however otherwise valid.
|
||||
// Owner-rooted, right repo, right action — but with no revocation path an
|
||||
// unbounded grant cannot be withdrawn once the token leaks.
|
||||
let perpetual = router()
|
||||
.oneshot(signed_push(Some(invocation_with_exp(
|
||||
format!("gitlawb://repos/{owner_did}/deleg"),
|
||||
None,
|
||||
))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
perpetual.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a delegation with no expiry must not authorize a push"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two-gate contract on a PRIVATE repository, end to end.
|
||||
///
|
||||
/// A `git push` crosses two independent gates. Gate 1 is read visibility on
|
||||
/// the `info/refs` advertisement (both services): the owner, anyone on a public
|
||||
/// repository, or a caller a rule names in `reader_dids`. Gate 2 is push
|
||||
/// authorization on the `git-receive-pack` POST, the owner gate a delegation
|
||||
/// clears. A UCAN speaks only to gate 2 and grants no read, so a delegate on a
|
||||
/// private repository needs BOTH a push delegation AND a reader rule. That
|
||||
/// composition — not a POST defect — is what "delegated push does not work on
|
||||
/// my private repo" turns out to be; the sibling test above runs on a public
|
||||
/// repository and never meets gate 1.
|
||||
///
|
||||
/// Status codes discriminate as in the sibling: 404 is gate 1 withholding the
|
||||
/// advertisement, 403 is gate 2 refusing the push, 500 is a request that cleared
|
||||
/// every gate and reached git on a repo with no disk backing.
|
||||
///
|
||||
/// Each gate is mounted under the middleware production gives it (`server.rs`):
|
||||
/// `optional_signature` alone on `info/refs`, `require_signature` plus
|
||||
/// `require_ucan_chain` on the receive-pack POST.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_on_a_private_repo_needs_read_visibility_too(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
let mut repo = seed_repo(&owner_did, "priv-deleg");
|
||||
repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
|
||||
let advertisement = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let receive_pack = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(crate::auth::require_signature))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
|
||||
// A delegation that is valid in every respect the push gate checks.
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/priv-deleg"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
Some(hour),
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap invocation")
|
||||
.encode()
|
||||
.expect("encode invocation");
|
||||
|
||||
let advert_path = format!("/{short}/priv-deleg.git/info/refs?service=git-receive-pack");
|
||||
let signed_advert = |ucan: Option<&str>| {
|
||||
let signed = sign_request(&agent, "GET", &advert_path, b"");
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&advert_path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature);
|
||||
if let Some(token) = ucan {
|
||||
req = req.header("x-ucan", token);
|
||||
}
|
||||
req.body(Body::empty()).expect("request")
|
||||
};
|
||||
let push_path = format!("/{short}/priv-deleg.git/git-receive-pack");
|
||||
let push_body = b"0000".to_vec();
|
||||
let signed_push = |ucan: Option<&str>| {
|
||||
let signed = sign_request(&agent, "POST", &push_path, &push_body);
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&push_path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature);
|
||||
if let Some(token) = ucan {
|
||||
req = req.header("x-ucan", token);
|
||||
}
|
||||
req.body(Body::from(push_body.clone())).expect("request")
|
||||
};
|
||||
|
||||
// 1. Delegation, no reader rule. Gate 1 withholds the advertisement even
|
||||
// though the delegate presents the token there too: a UCAN grants no read.
|
||||
let resp = advertisement()
|
||||
.oneshot(signed_advert(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a push delegation must not open the advertisement of a private repo"
|
||||
);
|
||||
// Gate 2, taken on its own, is satisfied by the same delegation: the two
|
||||
// gates are independent, which is exactly why both must be arranged.
|
||||
let resp = receive_pack()
|
||||
.oneshot(signed_push(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the push gate must clear on the delegation alone and reach git"
|
||||
);
|
||||
|
||||
// 2. The owner makes the agent a reader of the whole repository — what
|
||||
// `gl visibility set / --repo <repo> --readers <agent>` does.
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(
|
||||
&repo.id,
|
||||
"/",
|
||||
crate::db::VisibilityMode::B,
|
||||
&[agent.did().to_string()],
|
||||
&owner_did,
|
||||
)
|
||||
.await
|
||||
.expect("reader rule");
|
||||
let resp = advertisement()
|
||||
.oneshot(signed_advert(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"a reader clears the advertisement gate and reaches git"
|
||||
);
|
||||
let resp = receive_pack()
|
||||
.oneshot(signed_push(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"with both gates arranged the push reaches git"
|
||||
);
|
||||
|
||||
// 3. Reader rule, no delegation: reading is not pushing.
|
||||
let resp = advertisement().oneshot(signed_advert(None)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the reader rule alone still opens the advertisement"
|
||||
);
|
||||
let resp = receive_pack().oneshot(signed_push(None)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a reader with no delegation must still be refused on the push"
|
||||
);
|
||||
}
|
||||
|
||||
/// A valid delegation clears the owner gate but is still refused on a branch
|
||||
/// the owner has explicitly protected.
|
||||
///
|
||||
/// Two predicates deliberately disagree: the owner gate accepts a delegate, the
|
||||
/// branch-protection loop is owner-only. A protected branch is the owner's
|
||||
/// marker that even routine writes should stop, so a `git/push` delegation must
|
||||
/// not silently override it — otherwise issuing any capability would weaken
|
||||
/// every protection the owner had already set.
|
||||
///
|
||||
/// The 403 body is the discriminator: it must name the branch, proving the
|
||||
/// request reached branch protection rather than being turned away by the owner
|
||||
/// gate for lacking a delegation.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_is_still_refused_on_a_protected_branch(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
const ZERO: &str = "0000000000000000000000000000000000000000";
|
||||
let new_sha = "1111111111111111111111111111111111111111";
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
let rec = seed_repo(&owner_did, "protrepo");
|
||||
state.db.create_repo(&rec).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.protect_branch(&rec.id, "main", &owner_did)
|
||||
.await
|
||||
.expect("protect main");
|
||||
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/protrepo"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
Some(hour),
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap")
|
||||
.encode()
|
||||
.expect("encode");
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(crate::auth::require_signature))
|
||||
.with_state(state.clone());
|
||||
|
||||
let path = format!("/{short}/protrepo.git/git-receive-pack");
|
||||
let line = format!("{ZERO} {new_sha} refs/heads/main");
|
||||
let body = format!("{:04x}{}0000", line.len() + 4, line).into_bytes();
|
||||
|
||||
let signed = sign_request(&agent, "POST", &path, &body);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.header("x-ucan", invocation)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a delegation must not override branch protection"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains("protected"),
|
||||
"the refusal must come from branch protection, not the owner gate; got {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after
|
||||
/// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack
|
||||
@@ -10687,9 +11137,6 @@ mod tests {
|
||||
"TigrisClient",
|
||||
".download(",
|
||||
"tigris",
|
||||
"RepoArchive",
|
||||
"BlobStore",
|
||||
"fetch_raw",
|
||||
] {
|
||||
assert!(
|
||||
!code.contains(shape),
|
||||
|
||||
@@ -210,21 +210,14 @@ fn inv22_concurrency_gates_present_and_not_bypassed() {
|
||||
}
|
||||
}
|
||||
|
||||
/// F4 (repo_store advisory-unlock cancellation safety): the advisory unlock must
|
||||
/// be awaited while its owner still holds the pooled connection, and the owner
|
||||
/// must not mark itself `released` until that await resolves. Either shape,
|
||||
/// F4 (repo_store advisory-unlock cancellation safety): `RepoWriteGuard::release`
|
||||
/// must await `pg_advisory_unlock` while `self` still owns the pooled connection,
|
||||
/// and must not mark itself `released` until that await resolves. Either shape,
|
||||
/// reintroduced, re-opens the mid-unlock cancellation leak: taking the connection
|
||||
/// early leaves `Drop` with `conn == None`, and setting `released = true` early
|
||||
/// leaves the `Drop` backstop inert — both strand the session lock on cancellation.
|
||||
///
|
||||
/// The guarded code MOVED with the storage-abstraction layer: the unlock used to
|
||||
/// live in `RepoWriteGuard::release`, and now lives in `LockedConn::unlock`, the
|
||||
/// type that owns a lock's whole pinned-connection lifetime (the guard delegates
|
||||
/// to it, and the write-back path drives it from a detached task, which is the
|
||||
/// cancellation shape this gate exists for). The invariant is unchanged, so this
|
||||
/// gate follows the code rather than being deleted with it.
|
||||
///
|
||||
/// Scoped to the `unlock` fn body: the `Drop` impl legitimately takes the
|
||||
/// Scoped to the `release` fn body: the `Drop` impl legitimately takes the
|
||||
/// connection and unlocks, so a whole-file scan would match it and read as a false
|
||||
/// pass. Reverting either ordering turns this red (proven load-bearing).
|
||||
#[test]
|
||||
@@ -232,12 +225,12 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
let repo_store = src("git/repo_store.rs");
|
||||
|
||||
let rel_start = repo_store
|
||||
.find("async fn unlock(mut self")
|
||||
.expect("F4 gate: repo_store.rs no longer defines LockedConn::unlock");
|
||||
.find("pub async fn release(mut self")
|
||||
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
|
||||
let rel_end = repo_store[rel_start..]
|
||||
.find("impl Drop for LockedConn")
|
||||
.find("impl Drop for RepoWriteGuard")
|
||||
.map(|off| rel_start + off)
|
||||
.expect("F4 gate: unlock fn / Drop impl markers moved — update this guard");
|
||||
.expect("F4 gate: release fn / Drop impl markers moved — update this guard");
|
||||
let release_body = &repo_store[rel_start..rel_end];
|
||||
|
||||
let unlock = release_body
|
||||
@@ -248,7 +241,7 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
// (a) the connection must still be owned by `self` at the unlock await.
|
||||
assert!(
|
||||
!before_unlock.contains("self.conn.take()"),
|
||||
"F4 regression: LockedConn::unlock takes self.conn BEFORE awaiting \
|
||||
"F4 regression: RepoWriteGuard::release takes self.conn BEFORE awaiting \
|
||||
pg_advisory_unlock. A cancellation during the unlock await then strands the \
|
||||
session advisory lock (Drop sees conn == None and skips its backstop). \
|
||||
Unlock through the still-owned connection instead."
|
||||
@@ -257,7 +250,7 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
// reintroduction shape a single-reorder check on (a) alone is blind to.
|
||||
assert!(
|
||||
!before_unlock.contains("released = true"),
|
||||
"F4 regression: LockedConn::unlock sets `released = true` BEFORE awaiting \
|
||||
"F4 regression: RepoWriteGuard::release sets `released = true` BEFORE awaiting \
|
||||
pg_advisory_unlock. A cancellation during the await then leaves the Drop \
|
||||
backstop inert (it early-returns on released). Set released only AFTER the \
|
||||
unlock await resolves."
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -73,17 +73,18 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
println!("gl doctor — checking your gitlawb setup");
|
||||
println!();
|
||||
|
||||
let dir = args.dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
// The same resolver every other command uses. `doctor` reporting on
|
||||
// `~/.gitlawb` while `gl register` writes to the parent of `GITLAWB_KEY` would
|
||||
// make the one command whose job is to explain a broken setup the one that
|
||||
// misreports it.
|
||||
let args_dir = args.dir.clone();
|
||||
let dir = crate::identity::gitlawb_dir(args.dir)?;
|
||||
|
||||
let mut checks = Vec::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
// ── 1. Identity ───────────────────────────────────────────────────────
|
||||
let pem_path = dir.join("identity.pem");
|
||||
let pem_path = crate::identity::key_path_for(args_dir.as_deref())?;
|
||||
if pem_path.exists() {
|
||||
match std::fs::read_to_string(&pem_path)
|
||||
.ok()
|
||||
|
||||
@@ -323,8 +323,8 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
|
||||
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
pub(crate) fn sanitize_node_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
gitlawb_core::sanitize::strip_terminal_controls(s)
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
+239
-20
@@ -9,7 +9,8 @@ use std::path::{Path, PathBuf};
|
||||
pub enum IdentityCmd {
|
||||
/// Generate a new Ed25519 keypair and DID
|
||||
New {
|
||||
/// Output directory for key files (default: ~/.gitlawb)
|
||||
/// Output directory for key files
|
||||
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Overwrite existing keys if present
|
||||
@@ -63,16 +64,51 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
/// Resolve the identity directory, honouring an explicit override.
|
||||
/// Public so sibling commands (`gl ucan import`, `gl doctor`) look in the same
|
||||
/// place the identity itself lives.
|
||||
///
|
||||
/// Without an override this is [`gitlawb_core::identity_path::identity_dir`] — the
|
||||
/// parent of `GITLAWB_KEY`, else `~/.gitlawb`. The rules live in `gitlawb-core`
|
||||
/// because `git-remote-gitlawb` needs the identical answer: an operator who moved
|
||||
/// their key (`GITLAWB_KEY=/data/keys/identity.pem`, the shape `.env.example`
|
||||
/// documents) would otherwise have `gl ucan import` write the delegation to
|
||||
/// `~/.gitlawb/delegations` while the helper reads `/data/keys/delegations` and
|
||||
/// finds it empty. The push then goes out with no `X-Ucan` and the delegate is
|
||||
/// refused, with nothing on either side to indicate why.
|
||||
pub fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(d) = override_dir {
|
||||
return Ok(d);
|
||||
}
|
||||
let home = dirs::home_dir().context("could not determine home directory")?;
|
||||
Ok(home.join(".gitlawb"))
|
||||
// `home_dir()` is passed as an Option rather than demanded here: an absolute
|
||||
// GITLAWB_KEY resolves without a home directory, and a host that has none is a
|
||||
// normal container shape, not a reason to refuse a correctly-configured key.
|
||||
let home = dirs::home_dir();
|
||||
gitlawb_core::identity_path::identity_dir(home.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
|
||||
fn key_path(dir: &Path) -> PathBuf {
|
||||
dir.join("identity.pem")
|
||||
/// The identity PEM to read or write.
|
||||
///
|
||||
/// With an explicit `--dir` this is `<dir>/identity.pem`, the conventional layout.
|
||||
/// Without one it is [`gitlawb_core::identity_path::identity_key_path`] — the whole
|
||||
/// of `GITLAWB_KEY`, basename included.
|
||||
///
|
||||
/// Taking only the parent and re-appending `identity.pem` was a real divergence,
|
||||
/// not a tidy-up: `GITLAWB_KEY` is documented as a path to a PEM, and
|
||||
/// `git-remote-gitlawb` opens exactly that path. With
|
||||
/// `GITLAWB_KEY=/data/keys/ci-agent.pem`, `gl identity new` wrote
|
||||
/// `/data/keys/identity.pem` while every push loaded `/data/keys/ci-agent.pem`, so
|
||||
/// the two either disagreed on identity or the helper found no key at all — and
|
||||
/// owner enforcement and the delegation proof both key off that identity.
|
||||
pub(crate) fn key_path_for(dir: Option<&Path>) -> Result<PathBuf> {
|
||||
match dir {
|
||||
Some(d) => Ok(d.join(gitlawb_core::identity_path::KEY_FILE_NAME)),
|
||||
None => {
|
||||
let home = dirs::home_dir();
|
||||
gitlawb_core::identity_path::identity_key_path(home.as_deref())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
@@ -81,15 +117,14 @@ fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
|
||||
/// Load keypair from an optional directory override.
|
||||
/// Used by other modules (register, repo, mcp).
|
||||
///
|
||||
/// Routed through [`gitlawb_dir`] rather than reaching for `~/.gitlawb` directly:
|
||||
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so a second
|
||||
/// resolver here would have every other command read a different file than the one
|
||||
/// just created — `gl ucan delegate` would either fail to find an identity or sign
|
||||
/// with a stale DID that is not the repo owner.
|
||||
pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
let path = key_path(&base);
|
||||
let path = key_path_for(dir)?;
|
||||
let pem = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no identity found at {}\nRun `gl identity new` to create one",
|
||||
@@ -108,8 +143,11 @@ async fn cmd_new_with_reader(
|
||||
force: bool,
|
||||
reader: &mut impl std::io::BufRead,
|
||||
) -> Result<()> {
|
||||
let dir = gitlawb_dir(dir)?;
|
||||
let path = key_path(&dir);
|
||||
let path = key_path_for(dir.as_deref())?;
|
||||
let dir = path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if path.exists() {
|
||||
if force {
|
||||
@@ -183,8 +221,7 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let src = key_path(&base);
|
||||
let src = key_path_for(dir.as_deref())?;
|
||||
|
||||
let pem = fs::read_to_string(&src).with_context(|| {
|
||||
format!(
|
||||
@@ -239,8 +276,11 @@ async fn cmd_restore_with_reader(
|
||||
// Verify it's a valid keypair before writing anything
|
||||
let keypair = Keypair::from_pem(&pem).context("backup file is not a valid identity PEM")?;
|
||||
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let dest = key_path(&base);
|
||||
let dest = key_path_for(dir.as_deref())?;
|
||||
let base = dest
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if dest.exists() {
|
||||
if force {
|
||||
@@ -509,3 +549,182 @@ mod tests {
|
||||
assert_eq!(original_did, dst_kp.did());
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoped `GITLAWB_KEY` for tests, shared crate-wide.
|
||||
///
|
||||
/// The process environment is global and more than one suite in this crate
|
||||
/// depends on it — the resolver's own cases here, and `gl register`'s check that
|
||||
/// the bootstrap token lands beside the key. They take one lock rather than each
|
||||
/// declaring its own, which would not serialise them against each other.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_env {
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Restores the previous value and releases the lock on drop.
|
||||
pub(crate) struct KeyEnv {
|
||||
_guard: MutexGuard<'static, ()>,
|
||||
restore: Option<OsString>,
|
||||
}
|
||||
|
||||
impl Drop for KeyEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.restore.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `GITLAWB_KEY` set, restoring it afterwards.
|
||||
pub(crate) fn with_key<T, V: AsRef<OsStr>>(value: Option<V>, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = set_key(value);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Set `GITLAWB_KEY` (or remove it, for `None`) until the guard drops.
|
||||
pub(crate) fn set_key<V: AsRef<OsStr>>(value: Option<V>) -> KeyEnv {
|
||||
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let restore = std::env::var_os("GITLAWB_KEY");
|
||||
match value {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
KeyEnv {
|
||||
_guard: guard,
|
||||
restore,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gitlawb_dir_tests {
|
||||
use super::{gitlawb_dir, load_keypair_from_dir};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Run `f` with `GITLAWB_KEY` set to `value` (or removed for `None`), restoring
|
||||
/// whatever was there before.
|
||||
fn with_key_env<T>(value: Option<OsString>, f: impl FnOnce() -> T) -> T {
|
||||
let _env = crate::identity::test_env::set_key(value);
|
||||
f()
|
||||
}
|
||||
|
||||
/// An explicit --dir always wins and is never validated against GITLAWB_KEY.
|
||||
#[test]
|
||||
fn explicit_override_wins() {
|
||||
let d = PathBuf::from("/tmp/explicit");
|
||||
assert_eq!(gitlawb_dir(Some(d.clone())).unwrap(), d);
|
||||
}
|
||||
|
||||
/// A relative GITLAWB_KEY must fail loudly. `gl` and `git-remote-gitlawb` run
|
||||
/// from different working directories, so resolving one relatively sends the
|
||||
/// import and the lookup to different stores; a one-component value yields an
|
||||
/// empty parent and puts the store in `./delegations`.
|
||||
#[test]
|
||||
fn relative_key_paths_are_refused() {
|
||||
for raw in ["identity.pem", "keys/identity.pem"] {
|
||||
let result = with_key_env(Some(OsString::from(raw)), || gitlawb_dir(None));
|
||||
assert!(result.is_err(), "{raw} is relative and must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty value is what a shell leaves behind for `FOO=` and for an unset
|
||||
/// variable expanded into a wrapper script. Treated as unset, not as an error.
|
||||
#[test]
|
||||
fn an_empty_key_path_is_treated_as_unset() {
|
||||
let result = with_key_env(Some(OsString::new()), || gitlawb_dir(None));
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
dirs::home_dir().unwrap().join(".gitlawb"),
|
||||
"an empty value selects the default directory"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reason `gitlawb_dir` reads through `var_os`: `env::var` folds a non-UTF-8
|
||||
/// value into the same `Err` as unset, so a bad path would silently resolve to
|
||||
/// `~/.gitlawb` instead of being reported. Byte 0xFF is not valid UTF-8 in any
|
||||
/// position, so this value is unreachable through `env::var`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_utf8_key_path_is_not_mistaken_for_unset() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let raw = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
|
||||
let result = with_key_env(Some(raw), || gitlawb_dir(None));
|
||||
|
||||
let err = result.expect_err("a non-UTF-8 relative path must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("absolute"),
|
||||
"the error must name the real problem, not fall back to the default: {err}"
|
||||
);
|
||||
|
||||
let mut absolute = OsString::from("/data/");
|
||||
absolute.push(OsString::from_vec(vec![0xFF]));
|
||||
absolute.push("/identity.pem");
|
||||
let resolved = with_key_env(Some(absolute.clone()), || gitlawb_dir(None)).unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from(&absolute).parent().unwrap(),
|
||||
"an absolute non-UTF-8 path resolves to its own parent, not to ~/.gitlawb"
|
||||
);
|
||||
}
|
||||
|
||||
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so every
|
||||
/// other command has to read it back from there. `load_keypair_from_dir(None)`
|
||||
/// used to hardcode `~/.gitlawb`, which made `gl ucan delegate` sign with a
|
||||
/// stale DID — or fail outright — for exactly the operators who moved the key.
|
||||
#[test]
|
||||
fn load_keypair_from_dir_honours_the_key_env() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = dir.path().join("identity.pem");
|
||||
let expected = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
|
||||
|
||||
let loaded = with_key_env(Some(key.into_os_string()), || load_keypair_from_dir(None))
|
||||
.expect("the identity beside GITLAWB_KEY must be found");
|
||||
|
||||
assert_eq!(loaded.did(), expected.did());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod key_basename_tests {
|
||||
use super::*;
|
||||
|
||||
/// `GITLAWB_KEY` names a FILE. `gl` used to keep only its parent and re-append
|
||||
/// `identity.pem`, while `git-remote-gitlawb` opened the configured path — so
|
||||
/// with `GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI and the push path loaded
|
||||
/// different files, and owner enforcement and the delegation proof both key off
|
||||
/// whichever identity that was.
|
||||
#[test]
|
||||
fn a_non_default_key_basename_is_honoured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = dir.path().join("ci-agent.pem");
|
||||
let expected = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
|
||||
|
||||
let (resolved, loaded) = crate::identity::test_env::with_key(Some(key.clone()), || {
|
||||
(key_path_for(None).unwrap(), load_keypair_from_dir(None))
|
||||
});
|
||||
|
||||
assert_eq!(resolved, key, "the configured basename must survive");
|
||||
assert_eq!(
|
||||
loaded.expect("the key at GITLAWB_KEY must load").did(),
|
||||
expected.did(),
|
||||
"gl must load the same file the helper opens"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit --dir keeps the conventional layout.
|
||||
#[test]
|
||||
fn an_explicit_dir_still_uses_identity_pem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
key_path_for(Some(dir.path())).unwrap(),
|
||||
dir.path().join("identity.pem")
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-13
@@ -22,7 +22,7 @@ pub struct InitArgs {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -101,10 +101,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
// Save UCAN if returned
|
||||
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
|
||||
if !ucan.is_empty() {
|
||||
let ucan_dir = args
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
|
||||
let ucan_dir = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
std::fs::create_dir_all(&ucan_dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
@@ -221,18 +218,15 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
let path = crate::identity::key_path_for(dir)?;
|
||||
let base = path
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
std::fs::create_dir_all(&base)?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = base.join("identity.pem");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ pub enum IpfsCmd {
|
||||
List {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
+415
-33
@@ -283,7 +283,7 @@ fn tool_definitions() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "ucan_show",
|
||||
"description": "Show the saved bootstrap UCAN token for this agent.",
|
||||
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
|
||||
"inputSchema": { "type": "object", "properties": {} }
|
||||
},
|
||||
{
|
||||
@@ -560,7 +560,7 @@ fn tool_definitions() -> Value {
|
||||
// ── UCAN delegation tools ───────────────────────────────────────────
|
||||
{
|
||||
"name": "ucan_delegate",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["to", "resource", "action"],
|
||||
@@ -568,13 +568,13 @@ fn tool_definitions() -> Value {
|
||||
"to": { "type": "string", "description": "Audience DID — who receives this capability (e.g. did:key:z6Mk...)" },
|
||||
"resource": { "type": "string", "description": "Resource URI (e.g. gitlawb://repos/owner/repo)" },
|
||||
"action": { "type": "string", "description": "Action to grant (e.g. git/push, pr/open, repo/admin)" },
|
||||
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
|
||||
"expiry_hours": { "type": "integer", "minimum": 1, "description": "Expiry in hours (default: 720, the same as `gl ucan delegate`). Every link of a push chain must expire, so there is no unbounded option." }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_verify",
|
||||
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
|
||||
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
@@ -583,6 +583,17 @@ fn tool_definitions() -> Value {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_import",
|
||||
"description": "Store a delegation received from a repository owner where `git push` (git-remote-gitlawb) will present it. Same checks as `gl ucan import`: addressed to this identity, owner-rooted, every link bounded and naming one repository. Requires agent identity.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
"properties": {
|
||||
"token": { "type": "string", "description": "UCAN token JSON, as returned by ucan_delegate" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Issue tools ─────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -759,15 +770,45 @@ async fn call_tool(
|
||||
]))?),
|
||||
|
||||
"ucan_show" => {
|
||||
let ucan_path = dirs::home_dir()
|
||||
.context("no home dir")?
|
||||
.join(".gitlawb/ucan.json");
|
||||
if ucan_path.exists() {
|
||||
let content = std::fs::read_to_string(ucan_path)?;
|
||||
Ok(content)
|
||||
} else {
|
||||
Ok("No UCAN saved. Run `gl register` or use the agent_register tool.".to_string())
|
||||
// The directory the server was started with, like every sibling tool.
|
||||
// Reading the default here while the rest honour `--dir` splits one MCP
|
||||
// session across two identity directories.
|
||||
let ucan_path = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?
|
||||
.join("ucan.json");
|
||||
if !ucan_path.exists() {
|
||||
return Ok(
|
||||
"No UCAN saved. Run `gl register` or use the agent_register tool.".to_string(),
|
||||
);
|
||||
}
|
||||
let content = std::fs::read_to_string(&ucan_path)?;
|
||||
// Decoded fields, matching what `gl ucan show` reports — the two are the
|
||||
// same question asked through different surfaces and should not answer
|
||||
// it in different shapes.
|
||||
//
|
||||
// Returning the file verbatim also handed the caller the bootstrap token
|
||||
// itself. That is the credential the agent presents, and an MCP response
|
||||
// travels further than a terminal: into a model's context, transcripts,
|
||||
// and logs. The fields below are what a caller actually needs to know
|
||||
// whether it is registered and until when.
|
||||
let ucan = crate::ucan_cmd::decode_saved_ucan(&content).with_context(|| {
|
||||
format!("could not read the saved UCAN at {}", ucan_path.display())
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"issuer": ucan.payload.iss.to_string(),
|
||||
"audience": ucan.payload.aud.to_string(),
|
||||
"version": ucan.payload.ucan,
|
||||
"capabilities": ucan.payload.att.iter()
|
||||
.map(|c| json!({ "with": c.with, "can": c.can }))
|
||||
.collect::<Vec<_>>(),
|
||||
"expires": ucan.payload.exp.map(|e| {
|
||||
chrono::DateTime::from_timestamp(e, 0)
|
||||
.map(|d| d.to_rfc3339())
|
||||
.unwrap_or_else(|| e.to_string())
|
||||
}),
|
||||
"expired": ucan.is_expired(),
|
||||
"signature_valid": ucan.verify_signature().is_ok(),
|
||||
"path": ucan_path.display().to_string(),
|
||||
}))?)
|
||||
}
|
||||
|
||||
"did_resolve" => {
|
||||
@@ -1125,14 +1166,35 @@ async fn call_tool(
|
||||
let resource = args["resource"].as_str().context("missing 'resource'")?;
|
||||
let action = args["action"].as_str().context("missing 'action'")?;
|
||||
|
||||
// The same refusal `gl ucan delegate` applies. A push-class wildcard cannot
|
||||
// be honoured anywhere downstream — import refuses it, the helper refuses
|
||||
// it, the node refuses it — so issuing one only manufactures a token that
|
||||
// fails later with less context than this.
|
||||
if gitlawb_core::ucan::push::is_push_wildcard(resource, action) {
|
||||
anyhow::bail!(
|
||||
"a wildcard resource cannot carry a push capability: a delegation is \n scoped to the repositories it names when issued, and `*` cannot say \n which those were. Use resource gitlawb://repos/<owner>/<repo>."
|
||||
);
|
||||
}
|
||||
|
||||
let audience: gitlawb_core::did::Did = to_str
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let exp = args
|
||||
.get("expiry_hours")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
|
||||
// The CLI defaults to `DEFAULT_DELEGATION_EXPIRY_HOURS` and has no way
|
||||
// to issue an unbounded token. An omitted field here used to mean "no
|
||||
// expiry", which for a push capability is dead on arrival: import
|
||||
// refuses it and so does the node (`chain_lifetime_is_bounded`). Same
|
||||
// default, same rule.
|
||||
let hours = match args.get("expiry_hours") {
|
||||
None | Some(Value::Null) => crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64,
|
||||
Some(v) => v
|
||||
.as_i64()
|
||||
.context("invalid expiry_hours: expected an integer")?,
|
||||
};
|
||||
if hours < 1 {
|
||||
anyhow::bail!("expiry_hours must be at least 1: a token that has already expired grants nothing");
|
||||
}
|
||||
let exp = Some(chrono::Utc::now() + chrono::Duration::hours(hours));
|
||||
|
||||
let ucan = gitlawb_core::ucan::Ucan::issue(
|
||||
&kp,
|
||||
@@ -1155,23 +1217,26 @@ async fn call_tool(
|
||||
let token = args["token"].as_str().context("missing 'token'")?;
|
||||
let ucan =
|
||||
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
|
||||
let sig_valid = ucan.verify_signature().is_ok();
|
||||
let expired = ucan.is_expired();
|
||||
let caps: Vec<Value> = ucan
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.map(|c| json!({ "with": c.with, "can": c.can }))
|
||||
.collect();
|
||||
// The same report `gl ucan verify` prints, so the two cannot disagree
|
||||
// about what "valid" means. In particular the proof chain is walked:
|
||||
// a leaf with a good signature on a broken chain used to report valid
|
||||
// here and then fail at import and at the node.
|
||||
let report = crate::ucan_cmd::VerifyReport::of(&ucan);
|
||||
Ok(serde_json::to_string_pretty(&report.to_json())?)
|
||||
}
|
||||
|
||||
"ucan_import" => {
|
||||
let token = args["token"].as_str().context("missing 'token'")?;
|
||||
// `gl ucan import` with a JSON answer: one set of checks, one store, so
|
||||
// an MCP-only agent can complete the delegated-push workflow without
|
||||
// a shell and without a second copy of the rules.
|
||||
let imported = crate::ucan_cmd::import_delegation(token, dir).await?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"valid": sig_valid && !expired,
|
||||
"signature_valid": sig_valid,
|
||||
"expired": expired,
|
||||
"issuer": ucan.payload.iss.to_string(),
|
||||
"audience": ucan.payload.aud.to_string(),
|
||||
"capabilities": caps,
|
||||
"expires": ucan.payload.exp,
|
||||
"owner": imported.owner,
|
||||
"repo": imported.repo,
|
||||
"root_issuer": imported.root,
|
||||
"expires": imported.expires,
|
||||
"path": imported.path.display().to_string(),
|
||||
}))?)
|
||||
}
|
||||
|
||||
@@ -1564,6 +1629,7 @@ mod tests {
|
||||
.collect();
|
||||
assert!(names.contains(&"ucan_delegate"));
|
||||
assert!(names.contains(&"ucan_verify"));
|
||||
assert!(names.contains(&"ucan_import"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1730,6 +1796,38 @@ mod tests {
|
||||
assert_eq!(parsed["capability"]["can"], "git/push");
|
||||
}
|
||||
|
||||
/// The same refusal the CLI applies. `test_ucan_delegate_via_mcp` uses a named
|
||||
/// resource, so it could never catch the MCP arm issuing a wildcard the CLI
|
||||
/// refuses.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_delegate_via_mcp_refuses_a_push_class_wildcard() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
|
||||
for action in ["git/push", "*", "repo/admin"] {
|
||||
let err = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "*",
|
||||
"action": action,
|
||||
"expiry_hours": 24,
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("MCP must refuse a push-class wildcard like the CLI does");
|
||||
assert!(err.to_string().contains("wildcard"), "{action}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ucan_verify_via_mcp() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
@@ -1758,6 +1856,204 @@ mod tests {
|
||||
assert_eq!(parsed["valid"], true);
|
||||
assert_eq!(parsed["signature_valid"], true);
|
||||
assert_eq!(parsed["expired"], false);
|
||||
assert_eq!(parsed["chain_valid"], true);
|
||||
assert_eq!(parsed["root_issuer"], kp.did().to_string());
|
||||
}
|
||||
|
||||
/// The round-ten P2: `ucan_verify` answered from the leaf alone. A leaf with a
|
||||
/// good signature sitting on a proof that never named its issuer reported
|
||||
/// `valid: true` here, and was then refused by import and by the node.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_verify_via_mcp_walks_the_proof_chain() {
|
||||
let alice = gitlawb_core::identity::Keypair::generate();
|
||||
let bob = gitlawb_core::identity::Keypair::generate();
|
||||
let eve = gitlawb_core::identity::Keypair::generate();
|
||||
let node = gitlawb_core::identity::Keypair::generate();
|
||||
let cap = || {
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
"gitlawb://repos/alice/r",
|
||||
"git/push",
|
||||
)]
|
||||
};
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
// Alice grants Bob; Eve presents Alice's grant as if it were hers. Eve's
|
||||
// own signature is fine — the break is in the chain.
|
||||
let root = gitlawb_core::ucan::Ucan::issue(&alice, bob.did(), cap(), Some(hour)).unwrap();
|
||||
let forged =
|
||||
gitlawb_core::ucan::Ucan::delegate(&eve, node.did(), cap(), Some(hour), &root).unwrap();
|
||||
assert!(
|
||||
forged.verify_signature().is_ok(),
|
||||
"fixture: the leaf signature is valid"
|
||||
);
|
||||
|
||||
let result = call_tool(
|
||||
"ucan_verify",
|
||||
json!({"token": forged.encode().unwrap()}),
|
||||
"http://localhost",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["signature_valid"], true);
|
||||
assert_eq!(parsed["expired"], false);
|
||||
assert_eq!(parsed["chain_valid"], false);
|
||||
assert_eq!(
|
||||
parsed["valid"], false,
|
||||
"a broken proof chain must not report valid: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["chain_error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("proof chain broken"),
|
||||
"the reason must be reported: {parsed}"
|
||||
);
|
||||
assert!(parsed["root_issuer"].is_null());
|
||||
}
|
||||
|
||||
/// The workflow an MCP-only agent could not finish: it could be issued a
|
||||
/// delegation and verify it, but not store it where `git-remote-gitlawb`
|
||||
/// looks. `ucan_import` is `gl ucan import` behind a tool — same checks, same
|
||||
/// file — so a token the CLI would store lands in the same place, and one the
|
||||
/// CLI would refuse is refused with the same reason.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_import_via_mcp_stores_where_the_helper_looks() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let me = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
me.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let owner = gitlawb_core::identity::Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let token = gitlawb_core::ucan::Ucan::issue(
|
||||
&owner,
|
||||
me.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/ci-repo"),
|
||||
"git/push",
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
let result = call_tool(
|
||||
"ucan_import",
|
||||
json!({"token": token}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect("a delegation addressed to this identity must import");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["owner"], owner_did);
|
||||
assert_eq!(parsed["repo"], "ci-repo");
|
||||
assert_eq!(parsed["root_issuer"], owner_did);
|
||||
assert_eq!(parsed["expires"], hour.timestamp());
|
||||
|
||||
let stored = crate::ucan_cmd::delegation_path(dir.path(), &owner_did, "ci-repo");
|
||||
assert_eq!(parsed["path"], stored.display().to_string());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&stored).unwrap(),
|
||||
token,
|
||||
"the token must be stored byte-for-byte where the helper reads it"
|
||||
);
|
||||
|
||||
// Same refusals as the CLI: a token addressed to someone else is unusable
|
||||
// here however well-formed it is.
|
||||
let stranger = gitlawb_core::identity::Keypair::generate();
|
||||
let misaddressed = gitlawb_core::ucan::Ucan::issue(
|
||||
&owner,
|
||||
stranger.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/ci-repo"),
|
||||
"git/push",
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
let err = call_tool(
|
||||
"ucan_import",
|
||||
json!({"token": misaddressed}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a token addressed to another identity must be refused");
|
||||
assert!(err.to_string().contains("addressed to"), "{err}");
|
||||
}
|
||||
|
||||
/// The round-ten P2: an omitted `expiry_hours` issued an unbounded token, which
|
||||
/// import and the node both refuse for push. The default is now the CLI's.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_delegate_via_mcp_defaults_to_the_cli_expiry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
|
||||
let before = chrono::Utc::now();
|
||||
let result = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "gitlawb://repos/test/repo",
|
||||
"action": "git/push",
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
let expires = parsed["expires"]
|
||||
.as_i64()
|
||||
.expect("an omitted expiry_hours must still produce an expiring token");
|
||||
let expected = (before
|
||||
+ chrono::Duration::hours(crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64))
|
||||
.timestamp();
|
||||
assert!(
|
||||
(expires - expected).abs() <= 60,
|
||||
"expiry must default to the CLI's {} hours, got {expires} vs {expected}",
|
||||
crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS
|
||||
);
|
||||
let token = gitlawb_core::ucan::Ucan::decode(parsed["token"].as_str().unwrap()).unwrap();
|
||||
assert!(
|
||||
token.chain_lifetime_is_bounded(),
|
||||
"the token must satisfy the rule import and the node apply"
|
||||
);
|
||||
|
||||
// A token that has already expired grants nothing, so the request is a
|
||||
// mistake to report, not a token to mint.
|
||||
for bad in [json!(0), json!(-5)] {
|
||||
let err = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "gitlawb://repos/test/repo",
|
||||
"action": "git/push",
|
||||
"expiry_hours": bad,
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a non-positive expiry must be refused");
|
||||
assert!(err.to_string().contains("expiry_hours"), "{bad}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2027,9 +2323,95 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_count_is_42() {
|
||||
fn test_tool_count_matches_the_shipped_list() {
|
||||
let tools = tool_definitions();
|
||||
let count = tools.as_array().unwrap().len();
|
||||
assert_eq!(count, 40, "expected 40 tools, got {count}");
|
||||
// 41 with `ucan_import`. Bump this when a tool is added or removed; the
|
||||
// point is that no tool is added without someone noticing.
|
||||
assert_eq!(count, 41, "expected 41 tools, got {count}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ucan_show_tests {
|
||||
use super::*;
|
||||
|
||||
/// The MCP tool and `gl ucan show` answer the same question and must answer it
|
||||
/// in the same shape — decoded fields, not the file. Returning the file verbatim
|
||||
/// also handed back the bootstrap token, which an MCP response carries much
|
||||
/// further than a terminal does.
|
||||
#[tokio::test]
|
||||
async fn ucan_show_returns_decoded_fields_and_withholds_the_token() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let issuer = gitlawb_core::identity::Keypair::generate();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
let token = gitlawb_core::ucan::Ucan::issue(
|
||||
&issuer,
|
||||
audience.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
"gitlawb://repos/z6MkAbc/myrepo",
|
||||
gitlawb_core::ucan::caps::GIT_PUSH,
|
||||
)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
// The envelope shape every writer produces.
|
||||
std::fs::write(
|
||||
dir.path().join("ucan.json"),
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"ucan": token,
|
||||
"node": "https://node.gitlawb.com",
|
||||
"did": issuer.did().to_string(),
|
||||
"saved_at": "2026-08-18T00:00:00Z",
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
|
||||
.await
|
||||
.expect("ucan_show must read the envelope every writer produces");
|
||||
let v: serde_json::Value = serde_json::from_str(&out).expect("must be JSON");
|
||||
|
||||
assert_eq!(v["issuer"], issuer.did().to_string());
|
||||
assert_eq!(v["audience"], audience.did().to_string());
|
||||
assert_eq!(v["capabilities"][0]["can"], "git/push");
|
||||
assert_eq!(v["expired"], false);
|
||||
assert_eq!(v["signature_valid"], true);
|
||||
|
||||
// NOT `!out.contains(&token)`: the token is itself JSON, so embedding it in
|
||||
// a pretty-printed response escapes every quote and that assertion passes
|
||||
// whether or not the token leaked. The signature is a bare base64 string that
|
||||
// survives escaping unchanged, so it is the substring that actually proves
|
||||
// absence.
|
||||
let sig = serde_json::from_str::<serde_json::Value>(&token).unwrap()["s"]
|
||||
.as_str()
|
||||
.expect("a UCAN carries its signature in `s`")
|
||||
.to_string();
|
||||
assert!(!sig.is_empty());
|
||||
assert!(
|
||||
!out.contains(&sig),
|
||||
"the bootstrap token must not be returned to an MCP caller"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(&out)
|
||||
.unwrap()
|
||||
.get("ucan")
|
||||
.is_none(),
|
||||
"no field may carry the token itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unregistered agent gets a usable message, not a decode error.
|
||||
#[tokio::test]
|
||||
async fn ucan_show_reports_no_saved_ucan() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.contains("No UCAN saved"), "got: {out}");
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -168,16 +168,8 @@ pub async fn run(args: NameArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
|
||||
dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
})
|
||||
}
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
@@ -190,7 +182,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
}
|
||||
|
||||
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
@@ -22,7 +22,7 @@ pub enum NodeCmd {
|
||||
Status {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
@@ -328,12 +328,10 @@ pub async fn cmd_unstake(
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
let base = dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
let path = base.join("identity.pem");
|
||||
// The shared resolver, not a local `~/.gitlawb`: `gl identity new` writes the
|
||||
// key wherever `GITLAWB_KEY` points, and the old fallback to `.` on a missing
|
||||
// home made the answer depend on the working directory.
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
+46
-20
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -39,19 +39,26 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!("and create your first repository.");
|
||||
println!();
|
||||
|
||||
let dir = args.dir.clone().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
// The wizard generates the identity AND stores the bootstrap UCAN, so it has to
|
||||
// land where every later command reads from — the parent of `GITLAWB_KEY`, not
|
||||
// an unconditional `~/.gitlawb`.
|
||||
let dir = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
|
||||
// ── Step 1: Identity ──────────────────────────────────────────────────
|
||||
println!("── Step 1: Identity ─────────────────────────────────────────────────");
|
||||
println!();
|
||||
|
||||
let pem_path = dir.join("identity.pem");
|
||||
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
|
||||
let keypair = if pem_path.exists() {
|
||||
match load_keypair_from_dir(Some(&dir)) {
|
||||
// `args.dir.as_deref()`, not `Some(&dir)`. Passing the DIRECTORY made the
|
||||
// loader re-derive the basename as `identity.pem`, so with
|
||||
// `GITLAWB_KEY=/data/keys/ci-agent.pem` the check above found `ci-agent.pem`
|
||||
// while the load looked for a sibling that need not exist — and the Err arm
|
||||
// below then regenerated ONTO `ci-agent.pem`, destroying a working key and
|
||||
// changing the DID that repository ownership, registrations, and delegations
|
||||
// are all tied to. Regeneration must follow a failure to read the file that
|
||||
// was actually selected, never the absence of a conventional sibling.
|
||||
match load_keypair_from_dir(args.dir.as_deref()) {
|
||||
Ok(kp) => {
|
||||
let did = kp.did();
|
||||
println!(" ✓ Identity already exists");
|
||||
@@ -59,14 +66,15 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!();
|
||||
kp
|
||||
}
|
||||
Err(_) => {
|
||||
println!(" Identity file exists but is unreadable. Regenerating...");
|
||||
generate_identity(&dir)?
|
||||
Err(e) => {
|
||||
println!(" Identity at {} is unreadable: {e}", pem_path.display());
|
||||
println!(" Regenerating — the previous key cannot be recovered.");
|
||||
generate_identity(&dir, &pem_path)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" No identity found. Generating a new Ed25519 keypair...");
|
||||
generate_identity(&dir)?
|
||||
generate_identity(&dir, &pem_path)?
|
||||
};
|
||||
|
||||
let did = keypair.did().to_string();
|
||||
@@ -99,7 +107,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let ucan = payload["ucan"].as_str().unwrap_or("");
|
||||
if !ucan.is_empty() {
|
||||
// The messaging below derives from what was actually persisted, not
|
||||
// from the 2xx: a success response carrying no `ucan` skips the write,
|
||||
// and saying "UCAN saved" anyway sends the operator looking for a file
|
||||
// that was never created — then resurfaces later as a push rejection
|
||||
// with nothing pointing back here.
|
||||
let saved = if ucan.is_empty() {
|
||||
false
|
||||
} else {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
@@ -108,11 +123,20 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
}
|
||||
true
|
||||
};
|
||||
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
|
||||
println!(" ✓ Registered successfully");
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
if saved {
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
} else {
|
||||
println!(" The node returned no bootstrap UCAN, so registration-gated");
|
||||
println!(
|
||||
" workflows are unavailable. Retry with: gl register --node {}",
|
||||
args.node
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
Ok(resp) => {
|
||||
@@ -226,22 +250,24 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
|
||||
fn generate_identity(
|
||||
dir: &PathBuf,
|
||||
path: &std::path::Path,
|
||||
) -> Result<gitlawb_core::identity::Keypair> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = dir.join("identity.pem");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
std::fs::write(path, pem.as_bytes())?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
std::fs::write(path, pem.as_bytes())?;
|
||||
}
|
||||
|
||||
let did = keypair.did();
|
||||
|
||||
+77
-17
@@ -1,7 +1,8 @@
|
||||
//! `gl register` — register this agent identity with a gitlawb node.
|
||||
//!
|
||||
//! Sends a signed POST /api/register request and saves the returned bootstrap
|
||||
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
|
||||
//! UCAN token as `ucan.json` beside the identity key, where the other commands
|
||||
//! look for it.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
@@ -29,7 +30,7 @@ pub struct RegisterArgs {
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -69,17 +70,20 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
// Save bootstrap UCAN
|
||||
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if !ucan.is_empty() {
|
||||
let ucan_path = ucan_path(args.dir.as_deref())?;
|
||||
let saved_to = if ucan.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let path = ucan_path(args.dir.as_deref())?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
tracing::debug!("saved UCAN to {}", ucan_path.display());
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&record)?)?;
|
||||
tracing::debug!("saved UCAN to {}", path.display());
|
||||
Some(path)
|
||||
};
|
||||
|
||||
let trust = payload
|
||||
.get("trust_score")
|
||||
@@ -99,21 +103,35 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN expires: {expires}");
|
||||
println!();
|
||||
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
|
||||
println!(" You are now a verified agent on the gitlawb network.");
|
||||
// The real path, not the default one: `GITLAWB_KEY` moves it, and an operator
|
||||
// told to look in `~/.gitlawb` would find nothing there.
|
||||
// Registration without a stored token means the registration-gated
|
||||
// capabilities never arrived, so the closing line must not claim they did.
|
||||
match &saved_to {
|
||||
Some(path) => {
|
||||
println!(" Bootstrap UCAN saved to {}", path.display());
|
||||
println!(" You are now a verified agent on the gitlawb network.");
|
||||
}
|
||||
None => {
|
||||
println!(" The node returned no bootstrap UCAN, so this identity is not");
|
||||
println!(" a verified agent yet. Re-run `gl register` once the node issues one.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the bootstrap UCAN is stored: beside the identity key, always.
|
||||
///
|
||||
/// Routed through `gitlawb_dir` rather than resolving `~/.gitlawb` locally. The
|
||||
/// key is READ from the parent of `GITLAWB_KEY`, so writing the token anywhere
|
||||
/// else splits the two: registration succeeds, and `gl doctor`, `gl ucan show`,
|
||||
/// `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the key's
|
||||
/// directory and report an unregistered identity.
|
||||
fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
std::fs::create_dir_all(&base)?;
|
||||
let base = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?;
|
||||
std::fs::create_dir_all(&base)
|
||||
.with_context(|| format!("failed to create {}", base.display()))?;
|
||||
Ok(base.join("ucan.json"))
|
||||
}
|
||||
|
||||
@@ -160,6 +178,48 @@ mod tests {
|
||||
assert_eq!(content["node"].as_str().unwrap(), server.url());
|
||||
}
|
||||
|
||||
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
|
||||
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
|
||||
/// silent split-brain: registration prints success, and every command that later
|
||||
/// reads `ucan.json` — `gl doctor`, `gl ucan show`, `gl init`, `gl mcp` — looks
|
||||
/// beside the key, finds nothing, and reports an unregistered identity.
|
||||
#[tokio::test]
|
||||
async fn register_saves_the_bootstrap_ucan_beside_the_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
// No --dir: the destination has to come from GITLAWB_KEY alone.
|
||||
let _env = crate::identity::test_env::set_key(Some(dir.path().join("identity.pem")));
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
run(RegisterArgs {
|
||||
node: server.url(),
|
||||
capabilities: vec!["git:push".to_string()],
|
||||
model: None,
|
||||
dir: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let beside_the_key = dir.path().join("ucan.json");
|
||||
assert!(
|
||||
beside_the_key.exists(),
|
||||
"the bootstrap UCAN must land beside GITLAWB_KEY, not in ~/.gitlawb"
|
||||
);
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(beside_the_key).unwrap()).unwrap();
|
||||
assert_eq!(content["ucan"].as_str().unwrap(), "eyJhbGci.test.token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
+1644
-39
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WhoamiArgs {
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Node URL to query for registration info
|
||||
|
||||
+79
-11
@@ -143,9 +143,11 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
|
||||
|
||||
## Owner-only push
|
||||
|
||||
The node requires the authenticated pusher to be the repo owner on **every**
|
||||
branch. A push whose authenticated DID is not the repo owner is rejected with
|
||||
HTTP 403 before any ref update is applied. The owner is matched in both the full
|
||||
On the `git-receive-pack` POST — the push itself — the node requires the
|
||||
authenticated pusher to be the repo owner, or to present a UCAN whose proof chain
|
||||
roots at the owner and carries `git/push` for this repository (see *Delegating
|
||||
push to a CI agent* below). Anyone else is rejected with HTTP 403 before any ref
|
||||
update is applied, on **every** branch. The owner is matched in both the full
|
||||
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
|
||||
This is on by default, and the default is the point. The node authenticates every
|
||||
@@ -155,6 +157,20 @@ Authentication is not authorization, so without this gate every signed caller ca
|
||||
push to every repository — private ones included — on any branch that is not
|
||||
explicitly protected.
|
||||
|
||||
A `git push` crosses two independent gates, and this section is about the second:
|
||||
|
||||
1. **Read visibility**, on the `info/refs` advertisement that precedes every fetch
|
||||
*and* every push. The node admits the owner, anyone on a public repository, and
|
||||
a caller that a visibility rule names among its readers; everyone else gets a
|
||||
404, since a private repository is not confirmed to exist for a non-reader. A
|
||||
UCAN plays no part here — a delegation grants no read access.
|
||||
2. **Push authorization**, on the `git-receive-pack` POST: the owner, or an
|
||||
owner-rooted, bounded, repository-scoped push delegation.
|
||||
|
||||
Both must pass. On a public repository the first is open to everyone, so a
|
||||
delegation alone is enough; on a private one the delegate must also be a reader —
|
||||
see *Private repositories* below.
|
||||
|
||||
### Turning it off
|
||||
|
||||
```bash
|
||||
@@ -168,16 +184,68 @@ Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
|
||||
treat it as temporary: while it is off, your node accepts a push to any repository
|
||||
from anyone who can generate a keypair.
|
||||
|
||||
### Caution: delegated and CI keys are non-owners
|
||||
- **When `true`** — a push whose authenticated DID is not the repo owner is
|
||||
rejected (HTTP 403) before any ref update is applied. The owner is matched in
|
||||
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
- **A delegated key can still push.** A non-owner clears this gate by presenting a
|
||||
UCAN whose proof chain roots at the repo owner and which carries `git/push` for
|
||||
this repository. See *Delegating push to a CI agent* below.
|
||||
|
||||
Push authorization is owner-only today. A UCAN `git/push` capability is verified
|
||||
but **not yet honored for authorization**, so a delegated key cannot push while
|
||||
this gate is on, even when it holds a valid capability for the repo.
|
||||
### Delegating push to a CI agent
|
||||
|
||||
If your automation pushes under its own DID rather than the owner's, it will start
|
||||
getting 403s. Either have it push as the owner, or set
|
||||
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
|
||||
rights land — that work is what removes this trade-off.
|
||||
The owner issues a capability, the agent stores it, and the git helper presents it
|
||||
automatically on every push:
|
||||
|
||||
```bash
|
||||
# Owner, once per agent per repo:
|
||||
gl ucan delegate --to did:key:z6MkAgent… \
|
||||
--cap gitlawb://repos/<owner-did>/<repo> --can git/push --expiry 168
|
||||
|
||||
# Agent:
|
||||
gl ucan import <token-or-path>
|
||||
git push origin main # git-remote-gitlawb attaches it as X-Ucan
|
||||
```
|
||||
|
||||
What the node requires, and why:
|
||||
|
||||
| Requirement | Reason |
|
||||
|---|---|
|
||||
| The chain's **root issuer** is the repo owner | A `did:key` is self-certifying, so anyone can mint a chain. The owner is the only anchor the node holds independently of the token. |
|
||||
| **Every link** names this repository | A delegation's scope is fixed when it is issued. The node walks the whole proof chain, so a `*` resource authorizes nothing — not in the leaf and not in any proof behind it. Refusing only the leaf would not have been enough: attenuation permits narrowing a `*` parent to a concrete child, so one wildcard grant could otherwise reach every repository the owner has or later creates. `gl ucan delegate` therefore refuses to issue a push-class `*` at all. |
|
||||
| **Every link carries an expiry** | There is no revocation path yet. An unbounded delegation could never be withdrawn once leaked, so the node refuses one outright. `gl ucan delegate` defaults to 30 days. |
|
||||
| No `nb` constraints | Constraints are reserved but not yet interpreted, so a capability carrying them authorizes nothing rather than silently granting more than the owner intended. |
|
||||
| The token is addressed to the pusher | The node requires the proof's audience to equal the invocation's issuer, and `gl ucan import` refuses a token addressed to another identity rather than storing one that can only fail. |
|
||||
|
||||
**Private repositories.** A delegation clears the push gate only. The `info/refs`
|
||||
advertisement that `git push` fetches first is read-gated, and a UCAN grants no
|
||||
read access, so on a private repository the delegate must also be a reader:
|
||||
|
||||
```bash
|
||||
# Owner: let the agent read the whole repository
|
||||
gl visibility set / --repo <repo> --readers did:key:z6MkAgent…
|
||||
```
|
||||
|
||||
Without it the agent's push fails at the advertisement with a 404 — the same
|
||||
answer a stranger gets, because a private repository is not confirmed to exist
|
||||
for a non-reader — even though the delegation itself is valid and would clear the
|
||||
push gate. On a public repository this step is unnecessary. The two gates are
|
||||
independent in the other direction as well: a reader with no delegation is still
|
||||
refused on the push.
|
||||
|
||||
**One repository per token.** `gl ucan delegate` issues one capability per token,
|
||||
and `gl ucan import` stores one repository per token. A hand-built token that
|
||||
names several repositories is refused at import rather than applied to some of
|
||||
them; ask the owner for one delegation per repository.
|
||||
|
||||
**A delegation does not override branch protection.** A protected branch is your
|
||||
explicit marker that even routine writes should stop, so a delegate is still
|
||||
refused there and only the owner may push. That is deliberate: if a delegation
|
||||
overrode it, issuing any capability would weaken every protection you had set.
|
||||
|
||||
**Withdrawal is by expiry only.** There is no revocation today. If a delegated
|
||||
token leaks, it remains valid until its `exp`, and the only faster remedy is
|
||||
rotating the owner DID the repository is keyed on. Choose `--expiry` accordingly —
|
||||
short lifetimes reissued often are safer than one long-lived grant.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user