mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5cb7d0be | ||
|
|
e3314c611c | ||
|
|
8b91f2841a | ||
|
|
fb831b1037 | ||
|
|
1da425158a | ||
|
|
4dc62b21ca | ||
|
|
f79309b63c | ||
|
|
04e794129f | ||
|
|
9cf48f52ba | ||
|
|
89fa647027 | ||
|
|
bfc44f926d | ||
|
|
65f5989a1d | ||
|
|
4725146db2 | ||
|
|
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
+12
-23
@@ -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
|
||||
|
||||
@@ -24,26 +27,11 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
# ── Database pool & startup resilience ────────────────────────────────────
|
||||
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
|
||||
# connections open lazily. Size against the DB server's max_connections,
|
||||
# remembering admin tooling opens its own pool.
|
||||
# remembering admin tooling opens its own pool. Each concurrent write pins one
|
||||
# connection for its whole duration (the connection-affine advisory lock), so the
|
||||
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
|
||||
# headroom — keep this comfortably above that (default 48 for pushes 32).
|
||||
GITLAWB_DB_MAX_CONNECTIONS=48
|
||||
# Maximum connections in the DEDICATED advisory-lock pool, separate from the
|
||||
# pool above. Every in-flight repo write pins one connection here for its whole
|
||||
# duration, so this is a hard ceiling on simultaneous writes node-wide: size it
|
||||
# to expected peak concurrent writers, not small. Keeping it separate is what
|
||||
# stops a push burst from starving ordinary request handlers. Budget
|
||||
# (GITLAWB_DB_MAX_CONNECTIONS + this) per node against the server's
|
||||
# max_connections, times node count, plus admin tooling.
|
||||
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=40
|
||||
# Upper bound, in seconds, on any object-storage transfer that runs while a
|
||||
# per-repo write lock is HELD (the archive download inside acquire_write and the
|
||||
# upload inside release). These were free before the lock's connection was
|
||||
# pinned to the guard; now an unbounded stall holds a lock-pool slot, and enough
|
||||
# stalls deny every write on the node. The bound applies PER SPAN and there are
|
||||
# two (the acquire-side refresh, which covers the existence check and download
|
||||
# together, and the release-side upload), so worst-case slot occupancy is about
|
||||
# twice this value plus the git work between them. Read it together with the pool
|
||||
# size above and with GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
|
||||
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS=300
|
||||
# Seconds a request waits for a pool connection before failing with 503.
|
||||
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
|
||||
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
|
||||
@@ -113,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
@@ -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",
|
||||
|
||||
@@ -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,7 +393,7 @@ 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. |
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -3758,13 +3758,9 @@ mod tests {
|
||||
// deterministically.
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir,
|
||||
Some(tigris),
|
||||
pool,
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
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;
|
||||
@@ -3835,13 +3831,9 @@ mod tests {
|
||||
// (endpoint-pinned test client, no AWS_* env reads).
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir,
|
||||
Some(tigris),
|
||||
pool,
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
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);
|
||||
@@ -7964,18 +7956,9 @@ mod tests {
|
||||
// (endpoint-pinned test client, no AWS_* env reads).
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir,
|
||||
Some(tigris),
|
||||
pool,
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6f3budget", "ghost", "/unused-ghost", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
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;
|
||||
|
||||
@@ -61,30 +61,20 @@ pub async fn create_issue(
|
||||
let json_str = serde_json::to_string(&issue)
|
||||
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). This path holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
let create_result = git_issues::create_issue(guard.path(), &issue_id, &json_str);
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
|
||||
let git_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
|
||||
let deadline = std::time::Instant::now() + git_timeout;
|
||||
let git_bin = state.git_bin.clone();
|
||||
let issue_id_for_comp = issue_id.clone();
|
||||
|
||||
let release_result = if create_result.is_ok() {
|
||||
guard
|
||||
.release_compensating(true, move |path| {
|
||||
git_issues::delete_issue_ref(&git_bin, path, &issue_id_for_comp, deadline)
|
||||
})
|
||||
.await
|
||||
.into_result()
|
||||
} else {
|
||||
guard.release(false).await.into_result()
|
||||
};
|
||||
release_result?;
|
||||
// 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()))?;
|
||||
|
||||
@@ -235,8 +225,6 @@ pub async fn close_issue(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
headers: axum::http::HeaderMap,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
@@ -244,161 +232,8 @@ pub async fn close_issue(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// READ-GATE before any snapshot work or rate charging. The author fallback below
|
||||
// needs the issue blob, which needs the repo tree, so authorship cannot be established
|
||||
// without a download; but a caller who cannot even READ the repo must be
|
||||
// stopped here, cheaply, before any Tigris transfer or extraction happens.
|
||||
// Without this, any signed non-owner could issue parallel close requests for
|
||||
// arbitrary issue ids and drive unbounded downloads and blocking extraction
|
||||
// (a disposable-identity DoS), because the route has no other pre-authorization.
|
||||
//
|
||||
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden repo
|
||||
// from a missing one (INV-12 read-denial status contract).
|
||||
//
|
||||
// mirror-rows-handled: a repo row synced from a peer is stored public and
|
||||
// carries none of the owner's visibility rules, so for such a row this check
|
||||
// can only return allow. That is deliberate rather than overlooked, and it is
|
||||
// the same verdict every other read gate in this API reaches for one. Refusing
|
||||
// instead would deny the repo's real owner and the issue's real author on any
|
||||
// node whose only copy of the repo is a synced one, which is an ordinary state
|
||||
// here, and it would not buy the protection it appears to, because a synced
|
||||
// row's recorded owner comes from the peer that sent it. The expensive work
|
||||
// this check guards is bounded ahead of it by the per-IP limiter and the read
|
||||
// pool slot taken below, and the authoritative owner-or-author decision still
|
||||
// runs below and again under the write lock.
|
||||
{
|
||||
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.0.as_str();
|
||||
if crate::visibility::visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
Some(caller),
|
||||
"/",
|
||||
) == crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
|
||||
// policy as the push advertisement. The pre-lock snapshot downloads the
|
||||
// whole archive and runs a blocking extraction, so an unlimited route would
|
||||
// let disposable identities drive unbounded transfer/CPU/disk with parallel
|
||||
// close requests for arbitrary issue ids. Applied after the read gate so a
|
||||
// denied reader still sees 404, not 429.
|
||||
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
|
||||
if !state.close_issue_rate_limiter.check(&key).await {
|
||||
tracing::warn!(repo = %repo, key = %key, "close_issue rate limited");
|
||||
return Err(AppError::TooManyRequests(
|
||||
"rate limit exceeded — try again later".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// AUTHORIZE BEFORE ACQUIRING. The per-repo advisory lock genuinely excludes
|
||||
// now, so taking it first would hand any caller with read access a way to hold
|
||||
// that lock on demand and be refused afterwards, while a legitimate writer
|
||||
// burned its retry budget against it. On a public repo that is every
|
||||
// permissionless identity. The lock must not be reachable by a caller who is
|
||||
// about to be refused the write.
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
if !is_owner {
|
||||
// Cap concurrent snapshot work on the shared read pool. The hourly rate
|
||||
// bucket above is not a concurrent-work brake; without this, parallel close
|
||||
// attempts from read-capable callers could each drive a full archive
|
||||
// download and blocking extraction before the author denial below.
|
||||
let _read_permit = state
|
||||
.git_read_semaphore
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
tracing::warn!(
|
||||
repo = %repo,
|
||||
"close_issue snapshot refused — git read pool at capacity"
|
||||
);
|
||||
AppError::Overloaded("git service at capacity, retry shortly".into())
|
||||
})?;
|
||||
|
||||
// Not the owner, so the author fallback decides it, and the author lives in
|
||||
// the issue's git-JSON blob rather than a DB column.
|
||||
//
|
||||
// Read it WITHOUT the write lock, from a NON-MUTATING SNAPSHOT. The
|
||||
// justification is NOT that authorship is immutable — it is not:
|
||||
// `refs/gitlawb/**` is pushable, so a forged author blob can be pushed
|
||||
// (tracked separately; it is what makes this fallback only as trustworthy
|
||||
// as push authorization). The justification is that this read is only a
|
||||
// PRE-CHECK, deciding whether to take the lock at all. It is NOT the
|
||||
// authorization decision: `acquire_write` re-downloads the archive after
|
||||
// locking, so the tree that gets mutated is routinely not this one, and the
|
||||
// authoritative owner-or-author check runs again under the guard below.
|
||||
// Refusing here early just keeps a caller who is already visibly
|
||||
// unauthorized from reaching the lock.
|
||||
//
|
||||
// `read_snapshot`, not `acquire_fresh`: acquire's fast path returns as soon
|
||||
// as the directory exists and never contacts object storage, so on a node
|
||||
// with a stale copy the author's own issue would be invisible and the
|
||||
// cannot-establish-authorship arm below would 403 a legitimate author.
|
||||
// read_snapshot refreshes the same way, but unpacks into a throwaway temp
|
||||
// dir instead of publishing into the live repo path — an unlocked
|
||||
// pre-check must not delete or swap the directory under a concurrent
|
||||
// guarded write on the same path.
|
||||
// Pre-lock authorization only: bound with the read acquire budget, not the
|
||||
// under-lock transfer timeout a guarded write may hold for minutes.
|
||||
let snapshot_bound_secs = state.config.git_acquire_timeout_secs;
|
||||
let snapshot = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(snapshot_bound_secs),
|
||||
state
|
||||
.repo_store
|
||||
.read_snapshot(&record.owner_did, &record.name),
|
||||
)
|
||||
.await
|
||||
.map_err(|_elapsed| {
|
||||
tracing::warn!(
|
||||
repo = %repo,
|
||||
bound_secs = snapshot_bound_secs,
|
||||
"close_issue snapshot exceeded the read acquire bound — shedding as a retryable refusal"
|
||||
);
|
||||
AppError::RepoUnavailable
|
||||
})??;
|
||||
let snapshot_path = snapshot.path().to_path_buf();
|
||||
|
||||
let author_did: Option<String> = match git_issues::get_issue(&snapshot_path, &issue_id) {
|
||||
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
// Cannot establish authorship, so fail closed. Deliberately 403 rather
|
||||
// than 404 for a non-owner: a caller who is not authorized to write
|
||||
// should not learn from this route whether the issue exists. Both arms
|
||||
// below return None; they are split only so a read failure is visible
|
||||
// to operators, since a genuinely absent issue and an unreadable one
|
||||
// are the same answer to the client but not the same event.
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
repo = %repo,
|
||||
issue = %issue_id,
|
||||
err = %e,
|
||||
"get_issue failed during close_issue authorship pre-check"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let is_author = author_did
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_author {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Authorized. Only now is the lock taken.
|
||||
// Propagate rather than stringify: AppError's From<anyhow::Error> downcasts to
|
||||
// sqlx::Error so a pool timeout or a database outage surfaces as a retryable
|
||||
// 503. Calling .to_string() first destroys that and reports both as a 500.
|
||||
// Same capacity shed as create_issue above (#173 F1): an exhausted write-lock
|
||||
// pool is a 503 + Retry-After, not a 500 git error.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
@@ -406,56 +241,37 @@ pub async fn close_issue(
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
// Re-read under the guard and RE-AUTHORIZE against what we read, rather than
|
||||
// only confirming the issue still exists. The pre-lock read decided whether to
|
||||
// take the lock; it cannot be the authorization decision, because acquire_write
|
||||
// re-downloads the archive after locking, so this is frequently a different tree
|
||||
// than the one the author was read from. Checking existence alone would leave the
|
||||
// whole decision resting on the earlier read of a tree we are no longer looking
|
||||
// at. The blob is already in hand here, so this costs a deserialize.
|
||||
match git_issues::get_issue(&disk_path, &issue_id) {
|
||||
Ok(Some(raw)) => {
|
||||
let author_now: Option<String> = serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author);
|
||||
let is_author_now = author_now
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author_now {
|
||||
// Consumed, NOT propagated, and that is deliberate at all three
|
||||
// `release(false)` sites below. These release without
|
||||
// publishing, so there is nothing for the store to refuse, and
|
||||
// mapping the outcome here would let a 503 shadow the
|
||||
// authorization answer this route exists to give.
|
||||
let _ = guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Owner OR issue author may close. The author lives in the issue's git-JSON
|
||||
// blob (not a DB column); a None author (legacy issues) falls back to
|
||||
// owner-only. Read it under the write guard, before mutating.
|
||||
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
|
||||
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
Ok(None) => {
|
||||
let _ = guard.release(false).await;
|
||||
// The owner keeps the informative 404; a non-owner must not learn from
|
||||
// this route whether the issue exists, matching the pre-check above.
|
||||
return Err(if is_owner {
|
||||
AppError::NotFound(format!("issue {issue_id} not found"))
|
||||
} else {
|
||||
AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
)
|
||||
});
|
||||
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()));
|
||||
}
|
||||
};
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
let is_author = author_did
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let close_result = git_issues::close_issue(&disk_path, &issue_id);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
// Same short-circuit as create_issue, and before the 200 body below.
|
||||
guard.release(close_result.is_ok()).await.into_result()?;
|
||||
guard.release(close_result.is_ok()).await;
|
||||
|
||||
let updated = close_result
|
||||
.map_err(|e| AppError::Git(e.to_string()))?
|
||||
@@ -469,344 +285,12 @@ pub async fn close_issue(
|
||||
Ok(Json(issue))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// U7: once the advisory lock actually excludes, taking it BEFORE authorizing
|
||||
/// turns close_issue into a wedge primitive. Any caller with repo read access
|
||||
/// (on a public repo, any permissionless identity) could take the per-repo
|
||||
/// write lock on demand and be refused the write afterwards, while the owner's
|
||||
/// push burned its retry budget against a lock held by someone with no write
|
||||
/// authorization.
|
||||
///
|
||||
/// The observable: hold the lock from an independent session, then call the
|
||||
/// handler as a stranger. If it authorizes first it refuses immediately; if it
|
||||
/// acquires first it sits in the 60-attempt retry loop and the deadline fires.
|
||||
#[sqlx::test]
|
||||
async fn stranger_is_refused_without_waiting_on_the_write_lock(pool: PgPool) {
|
||||
use sqlx::Connection;
|
||||
let opts = (*pool.connect_options()).clone();
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
|
||||
let owner = "did:key:z6MkU7Owner";
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6MkU7Owner", "u7repo", "/tmp/u7repo", None, true)
|
||||
.await
|
||||
.expect("seed repo");
|
||||
let record = state
|
||||
.db
|
||||
.get_repo("z6MkU7Owner", "u7repo")
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("repo exists");
|
||||
|
||||
// An independent session holds the repo's write lock for the whole call.
|
||||
let key = crate::git::repo_store::advisory_lock_key_for_test(
|
||||
&record.owner_did.replace([':', '/'], "_"),
|
||||
&record.name,
|
||||
);
|
||||
let mut holder = sqlx::PgConnection::connect_with(&opts).await.unwrap();
|
||||
let held: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(key)
|
||||
.fetch_one(&mut holder)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
held.0,
|
||||
"the test must hold the lock for this to mean anything"
|
||||
);
|
||||
let _ = owner;
|
||||
|
||||
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkU7Stranger".to_string());
|
||||
let outcome = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(stranger),
|
||||
axum::extract::Path((
|
||||
"z6MkU7Owner".to_string(),
|
||||
"u7repo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.64:5000".parse().unwrap())),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
let refused = outcome.expect(
|
||||
"a caller with no write authorization must be refused WITHOUT waiting on the \
|
||||
write lock; hitting this deadline means the handler tried to acquire first, \
|
||||
which is the wedge primitive",
|
||||
);
|
||||
assert!(
|
||||
matches!(refused, Err(AppError::Forbidden(_))),
|
||||
"expected 403 Forbidden for a stranger, got {:?}",
|
||||
refused.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
|
||||
/// The read-gate's blind spot, pinned rather than left to be rediscovered.
|
||||
///
|
||||
/// A repo row synced from a peer is stored public and carries none of the
|
||||
/// owner's visibility rules, so the gate's own inputs can only produce allow
|
||||
/// for it. The gate above therefore does not carry this class of row, and the
|
||||
/// test that does cover it (`non_reader_is_refused_before_the_snapshot`) seeds
|
||||
/// a locally created repo, which cannot observe this: a passing test there is
|
||||
/// not coverage here.
|
||||
///
|
||||
/// Two things are asserted, and the second is why the first is acceptable.
|
||||
/// The gate's verdict for such a row is allow for an arbitrary caller, and the
|
||||
/// handler still refuses that caller afterwards, because the decision that
|
||||
/// matters is the owner-or-author check rather than this one. If a later change
|
||||
/// makes the gate the load-bearing decision for this route, the first assertion
|
||||
/// breaks and this comment is where to start.
|
||||
#[sqlx::test]
|
||||
async fn a_synced_row_is_not_gated_by_its_own_visibility(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
|
||||
// Only a synced row exists for this repo, with no locally created twin.
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6MkSyncOwner", "syncrepo", "/tmp/syncrepo", None, false)
|
||||
.await
|
||||
.expect("seed synced repo"); // false = not quarantined, the ordinary case
|
||||
let record = state
|
||||
.db
|
||||
.get_repo("z6MkSyncOwner", "syncrepo")
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("repo exists")
|
||||
.clone();
|
||||
assert!(
|
||||
record.id.contains('/'),
|
||||
"this test is only meaningful against a synced row; got id {}",
|
||||
record.id
|
||||
);
|
||||
|
||||
// The gate's two inputs, and what they force.
|
||||
let rules = state
|
||||
.db
|
||||
.list_visibility_rules(&record.id)
|
||||
.await
|
||||
.expect("list rules");
|
||||
assert!(rules.is_empty(), "a synced row carries no rules of its own");
|
||||
assert!(record.is_public, "a synced row is stored public");
|
||||
assert_eq!(
|
||||
crate::visibility::visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
Some("did:key:z6MkSyncStranger"),
|
||||
"/",
|
||||
),
|
||||
crate::visibility::Decision::Allow,
|
||||
"the gate can only allow for a synced row, which is the property the \
|
||||
handler's mirror-rows-handled note records",
|
||||
);
|
||||
|
||||
// So the refusal has to come from the decision that is actually load-bearing.
|
||||
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkSyncStranger".to_string());
|
||||
let outcome = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(stranger),
|
||||
axum::extract::Path((
|
||||
"z6MkSyncOwner".to_string(),
|
||||
"syncrepo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.99:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"a stranger must still be refused on a synced row, gate or no gate",
|
||||
);
|
||||
let body = format!("{:?}", outcome.err().unwrap());
|
||||
assert!(
|
||||
!body.contains("syncrepo/") && !body.to_lowercase().contains("issue body"),
|
||||
"the refusal must not leak repo contents: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The read-gate added for the pre-lock snapshot: a caller who cannot READ
|
||||
/// the repo (private repo, no rule granting them access) must be refused with
|
||||
/// a not-found BEFORE any snapshot download or extraction happens. The
|
||||
/// observable is the refusal itself; the cheaper part (no Tigris work) is
|
||||
/// structural (the gate precedes the snapshot call in the handler).
|
||||
#[sqlx::test]
|
||||
async fn non_reader_is_refused_before_the_snapshot(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let now = chrono::Utc::now();
|
||||
state
|
||||
.db
|
||||
.create_repo(&crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "priv-close".to_string(),
|
||||
owner_did: "z6MkT3Owner".to_string(),
|
||||
description: None,
|
||||
is_public: false,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/priv-close".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
|
||||
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkT3Stranger".to_string());
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(stranger),
|
||||
axum::extract::Path((
|
||||
"z6MkT3Owner".to_string(),
|
||||
"priv-close".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.67:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(res, Err(AppError::RepoNotFound(_))),
|
||||
"a non-reader must be refused as not-found, got {:?}",
|
||||
res.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_repo_with_issue(
|
||||
state: &crate::state::AppState,
|
||||
owner_slug: &str,
|
||||
owner_did: &str,
|
||||
repo: &str,
|
||||
issue_id: &str,
|
||||
author_did: &str,
|
||||
) -> std::path::PathBuf {
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner_slug, repo, "/unused", None, true)
|
||||
.await
|
||||
.expect("seed repo row");
|
||||
// Seed at the path the HANDLER will resolve. upsert_mirror_repo stores the
|
||||
// bare slug in owner_did, and close_issue resolves from record.owner_did, so
|
||||
// seeding from the full did:key would create the repo in a different
|
||||
// directory and the handler would find nothing.
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(owner_slug, repo)
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("seeded repo exists");
|
||||
let _ = owner_did;
|
||||
let path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.expect("resolve disk path");
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
crate::git::store::init_bare(&path).expect("init bare repo");
|
||||
// Must deserialize as a real IssueRecord: `created_at` and `status` are
|
||||
// required, and a parse failure would silently drop the author (the
|
||||
// `.ok()` on from_str), which reads as a 403 rather than as a broken fixture.
|
||||
let json = serde_json::to_string(&IssueRecord {
|
||||
id: issue_id.to_string(),
|
||||
title: "seeded".to_string(),
|
||||
body: Some(String::new()),
|
||||
author: Some(author_did.to_string()),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
status: "open".to_string(),
|
||||
signed_payload: None,
|
||||
})
|
||||
.expect("serialize seeded issue");
|
||||
crate::git::issues::create_issue(&path, issue_id, &json).expect("seed issue blob");
|
||||
path
|
||||
}
|
||||
|
||||
/// INV-21(c) positive twin 1: the OWNER can still close. The reorder moved the
|
||||
/// owner check above the lock, so this is the arm most likely to have broken,
|
||||
/// and the deny test alone could not see it.
|
||||
///
|
||||
/// The issue is seeded with a THIRD party as its author, deliberately. Seeding
|
||||
/// the owner as their own author made this test unable to fail: with the owner
|
||||
/// check disabled, the author fallback granted the close anyway and the test
|
||||
/// stayed green. Only the owner arm can grant here now.
|
||||
#[sqlx::test]
|
||||
async fn owner_can_still_close_after_the_reorder(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner_did = "did:key:z6MkT1Owner";
|
||||
seed_repo_with_issue(
|
||||
&state,
|
||||
"z6MkT1Owner",
|
||||
owner_did,
|
||||
"t1repo",
|
||||
"1",
|
||||
"did:key:z6MkT1Stranger",
|
||||
)
|
||||
.await;
|
||||
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
|
||||
axum::extract::Path((
|
||||
"z6MkT1Owner".to_string(),
|
||||
"t1repo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.65:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"the owner must still be able to close: {:?}",
|
||||
res.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-21(c) positive twin 2: the non-owner AUTHOR can still close, through both
|
||||
/// the pre-lock check and the re-assertion under the guard.
|
||||
///
|
||||
/// It does NOT cover the acquire-vs-acquire_fresh distinction, despite that being
|
||||
/// the reason the call changed. `RepoStore::for_testing` hardcodes `tigris: None`,
|
||||
/// which makes `acquire` and `acquire_fresh` identical in every test here, so
|
||||
/// reverting that line leaves this green. Separating them needs an object-storage
|
||||
/// seam, which is out of scope for this change and tracked separately. Claiming
|
||||
/// the coverage here would be worse than admitting the gap.
|
||||
#[sqlx::test]
|
||||
async fn issue_author_who_is_not_the_owner_can_still_close(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner_did = "did:key:z6MkT2Owner";
|
||||
let author_did = "did:key:z6MkT2Author";
|
||||
seed_repo_with_issue(&state, "z6MkT2Owner", owner_did, "t2repo", "1", author_did).await;
|
||||
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(crate::auth::AuthenticatedDid(author_did.to_string())),
|
||||
axum::extract::Path((
|
||||
"z6MkT2Owner".to_string(),
|
||||
"t2repo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.66:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"the issue author, who is NOT the repo owner, must still be able to close: {:?}",
|
||||
res.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: issue write paths reach `acquire_write` with no admission
|
||||
/// permit, so an exhausted write-lock pool must shed 503 + Retry-After.
|
||||
/// #173 F1 follow-up: the two issue write paths reach `acquire_write` holding NO
|
||||
/// admission permit (unlike the push handler, which is capped by the git-push
|
||||
/// semaphore), so they are the callers most likely to meet an exhausted write-lock
|
||||
/// POOL under load. An exhausted pool is a capacity signal, so both must shed
|
||||
/// 503 + Retry-After (`AppError::Overloaded`) the way the push handler does, not
|
||||
/// report the generic 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
@@ -830,17 +314,22 @@ mod lock_pool_shed_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// State whose repo store draws write locks from a ONE-connection pool with a
|
||||
/// short checkout timeout, so a single held guard exhausts it promptly rather
|
||||
/// than at the pool default.
|
||||
async fn one_connection_lock_pool_state(pool: &PgPool) -> AppState {
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-issues-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(pool, 1, std::time::Duration::from_secs(1)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The shed must be a real 503 carrying Retry-After, not just an internal enum
|
||||
/// variant: assert on the rendered response so a remapping of `Overloaded` is
|
||||
/// caught here too.
|
||||
fn assert_sheds_503_with_retry_after(err: AppError, what: &str) {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
@@ -857,6 +346,10 @@ mod lock_pool_shed_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `create_issue`. Both directions: the shed while the
|
||||
/// only lock-pool connection is held by a guard on a DIFFERENT repo (so this is
|
||||
/// pool capacity, not advisory-lock contention on this repo), and the must-not
|
||||
/// case once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECREATELOCKPOOLAAAAAAAAAAAAAAAAAAAA";
|
||||
@@ -887,7 +380,9 @@ 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, "create_issue");
|
||||
|
||||
let _ = held.release(false).await;
|
||||
// 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;
|
||||
let admitted = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
@@ -906,38 +401,7 @@ mod lock_pool_shed_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn close_issue_read_pool_exhaustion_sheds_before_snapshot(pool: PgPool) {
|
||||
use std::sync::Arc;
|
||||
let owner = "did:key:zISSUECLOSEREADPOOLBBBBBBBBBBBBBBBBBBBBB";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, "read-cap"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let shed = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(
|
||||
"did:key:zISSUECLOSEREADSTRANGER".to_string(),
|
||||
)),
|
||||
Path((
|
||||
owner.to_string(),
|
||||
"read-cap".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(shed, Err(AppError::Overloaded(_))),
|
||||
"an exhausted read pool must shed before snapshot work; got {shed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `close_issue`, same two directions.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
|
||||
@@ -962,14 +426,12 @@ mod lock_pool_shed_tests {
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "close_issue");
|
||||
|
||||
let _ = held.release(false).await;
|
||||
held.release(false).await;
|
||||
let admitted = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
@@ -978,8 +440,6 @@ mod lock_pool_shed_tests {
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -988,244 +448,4 @@ mod lock_pool_shed_tests {
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_retryable_repo_acquire(err: AppError, what: &str, code: &str) {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"{what}: a transient acquire refusal must shed 503, not a 500 git error"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
|
||||
.await
|
||||
.expect("body");
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body.contains(code),
|
||||
"{what}: the 503 must carry the {code} code, got {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Advisory-lock contention on a non-push mutation must map through the shared
|
||||
/// `acquire_write_app_error` classifier to `repo_busy`, not a 500 git_error.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_contention_sheds_repo_busy_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECREATEBUSYAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo_name = "busy-create";
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, repo_name))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, repo_name)
|
||||
.await
|
||||
.expect("the first writer takes the advisory lock");
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), repo_name.to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("contention must refuse the second writer");
|
||||
assert_retryable_repo_acquire(err, "create_issue", "repo_busy").await;
|
||||
|
||||
let _ = held.release(false).await;
|
||||
}
|
||||
|
||||
/// A refused under-lock refresh on a non-push mutation must map to
|
||||
/// `repo_unavailable`, not expose the storage error as a 500 git_error.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_unavailable_sheds_repo_unavailable_not_500(pool: PgPool) {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/{*key}",
|
||||
axum::routing::any(|method: axum::http::Method| async move {
|
||||
if method == axum::http::Method::HEAD {
|
||||
let mut resp = axum::http::StatusCode::OK.into_response();
|
||||
resp.headers_mut()
|
||||
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
|
||||
resp
|
||||
} else {
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let owner = "did:key:zISSUEUNAVAILAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo_name = "unavail-create";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-issue-unavail"),
|
||||
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
|
||||
);
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, repo_name))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), repo_name.to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("a failed archive download must refuse the write");
|
||||
assert_retryable_repo_acquire(err, "create_issue", "repo_unavailable").await;
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// A denied reader on a private repo must see 404 even when the caller's rate
|
||||
/// bucket is exhausted. Rate limiting runs after the read gate.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_rate_limit_runs_after_the_read_gate(pool: PgPool) {
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
let owner = "did:key:zCLOSERATEOWNERAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zCLOSERATESTRANGERBBBBBBBBBBBBBBBBBB";
|
||||
let mut state = crate::test_support::test_state(pool).await;
|
||||
state.close_issue_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
|
||||
let mut repo = seed_repo(owner, "priv-close");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let peer: SocketAddr = "203.0.113.88:7000".parse().unwrap();
|
||||
assert!(
|
||||
state
|
||||
.close_issue_rate_limiter
|
||||
.check(&peer.ip().to_string())
|
||||
.await,
|
||||
"exhaust the close_issue bucket before close_issue"
|
||||
);
|
||||
|
||||
let res = close_issue(
|
||||
State(state),
|
||||
Extension(AuthenticatedDid(stranger.to_string())),
|
||||
Path((owner.to_string(), "priv-close".to_string(), "1".to_string())),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(res, Err(AppError::RepoNotFound(_))),
|
||||
"a non-reader must see 404, not 429, even when rate limited: {:?}",
|
||||
res
|
||||
);
|
||||
}
|
||||
|
||||
/// close_issue and receive-pack must not share one per-IP bucket.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_rate_limit_does_not_drain_push_bucket(pool: PgPool) {
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
let mut state = crate::test_support::test_state(pool).await;
|
||||
state.close_issue_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
|
||||
let peer: SocketAddr = "203.0.113.99:7000".parse().unwrap();
|
||||
let key = peer.ip().to_string();
|
||||
assert!(
|
||||
state.close_issue_rate_limiter.check(&key).await,
|
||||
"exhaust only the close_issue bucket"
|
||||
);
|
||||
assert!(
|
||||
state.push_rate_limiter.check(&key).await,
|
||||
"push traffic must keep its own bucket after close_issue is exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused publish must roll back the local issue ref so a retry does not
|
||||
/// mint a second id for the same logical filing attempt.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_rolls_back_local_ref_when_publish_refuses(pool: PgPool) {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/{*key}",
|
||||
axum::routing::any(|method: axum::http::Method| async move {
|
||||
if method == axum::http::Method::HEAD {
|
||||
let mut resp = axum::http::StatusCode::OK.into_response();
|
||||
resp.headers_mut()
|
||||
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
|
||||
resp
|
||||
} else {
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let owner = "did:key:zISSUEROLLBACKAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo_name = "rollback-create";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
let disk = tempfile::TempDir::new().unwrap();
|
||||
crate::git::store::init_bare(&disk.path().join("repo.git")).expect("bare repo");
|
||||
let mut seeded = seed_repo(owner, repo_name);
|
||||
seeded.disk_path = disk.path().join("repo.git").to_string_lossy().to_string();
|
||||
state.db.create_repo(&seeded).await.expect("seed repo");
|
||||
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
|
||||
disk.path().to_path_buf(),
|
||||
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
|
||||
);
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), repo_name.to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
shed.is_err(),
|
||||
"a failed archive upload must refuse the write"
|
||||
);
|
||||
|
||||
let issues = git_issues::list_issues(&disk.path().join("repo.git")).expect("list issues");
|
||||
assert!(
|
||||
issues.is_empty(),
|
||||
"the local issue ref must be rolled back after a refused publish, got {issues:?}"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -209,6 +209,9 @@ pub async fn merge_pr(
|
||||
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
|
||||
}
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
@@ -225,10 +228,7 @@ pub async fn merge_pr(
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
// Short-circuit on a refused publish before the PR is marked merged and
|
||||
// before the webhook fires. Both are irreversible announcements of a merge
|
||||
// commit that only exists on this node's disk.
|
||||
guard.release(merge_result.is_ok()).await.into_result()?;
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
@@ -428,8 +428,12 @@ pub async fn list_comments(
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
|
||||
/// pool must shed 503 + Retry-After.
|
||||
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
|
||||
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
|
||||
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
|
||||
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
|
||||
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
|
||||
/// 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
@@ -453,15 +457,20 @@ mod lock_pool_shed_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
|
||||
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
|
||||
/// capacity, not advisory-lock contention on this repo), and the must-not case
|
||||
/// once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
// One lock-pool connection with a short checkout timeout, so a single held
|
||||
// guard exhausts it promptly rather than at the pool default.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
|
||||
let repo = seed_repo(owner, "lp-merge");
|
||||
@@ -515,7 +524,9 @@ mod lock_pool_shed_tests {
|
||||
"merge_pr: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
|
||||
let _ = held.release(false).await;
|
||||
// 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;
|
||||
let admitted = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
@@ -517,7 +613,6 @@ mod tests {
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
@@ -638,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,9 +252,11 @@ pub struct Config {
|
||||
|
||||
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
|
||||
/// (connections open lazily). Size against the database server's
|
||||
/// max_connections, remembering admin tooling opens its own pool. Writes pin
|
||||
/// the dedicated lock pool, not this one; this pool still needs enough headroom
|
||||
/// for ordinary request handlers and metadata paths (`DB_POOL_APP_HEADROOM`).
|
||||
/// max_connections, remembering admin tooling opens its own pool. Each
|
||||
/// concurrent write pins one pooled connection for its whole duration (the
|
||||
/// advisory lock in `repo_store::acquire_write` is connection-affine), so this
|
||||
/// must exceed `max_concurrent_git_pushes` by `DB_POOL_APP_HEADROOM` or slow
|
||||
/// pushes starve every other DB path — enforced by `Config::validate`.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_MAX_CONNECTIONS",
|
||||
@@ -263,46 +265,6 @@ pub struct Config {
|
||||
)]
|
||||
pub db_max_connections: u32,
|
||||
|
||||
/// Maximum connections in the dedicated advisory-lock pool, which is separate
|
||||
/// from the main pool above.
|
||||
///
|
||||
/// Size this against the expected peak number of concurrent distinct-repo
|
||||
/// writers, NOT small. Every in-flight repo write pins one connection here for
|
||||
/// its whole duration (the write, its metadata tail, and the bounded archive
|
||||
/// upload), so this value is a hard ceiling on simultaneous writes node-wide.
|
||||
/// Keeping it separate from GITLAWB_DB_MAX_CONNECTIONS is what stops a push
|
||||
/// burst from starving ordinary request handlers; the cost is that
|
||||
/// (main pool + lock pool) must fit inside the database server's
|
||||
/// max_connections, times the number of nodes, plus admin tooling.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS",
|
||||
default_value_t = 40,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub db_lock_pool_max_connections: u32,
|
||||
|
||||
/// Upper bound, in seconds, on any single object-storage transfer that runs
|
||||
/// while the per-repo advisory lock is HELD.
|
||||
///
|
||||
/// Two bounded spans exist, and the bound applies per span. The acquire-side
|
||||
/// refresh in `acquire_write` covers the existence HEAD and the download
|
||||
/// together under ONE budget (it runs after the lock is taken and before the
|
||||
/// guard is constructed), and the archive upload in `release` gets its own.
|
||||
/// Worst-case slot occupancy is therefore about twice this value plus the git
|
||||
/// work between them, not one times this value. Both used to be free, because the lock's
|
||||
/// connection was returned to the pool immediately; now that a write guard
|
||||
/// pins a lock-pool connection for its whole lifetime, an unbounded transfer
|
||||
/// holds that slot, and enough stalled transfers deny every write on the node.
|
||||
/// This is the bound that keeps a stall from becoming an outage.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS",
|
||||
default_value_t = 300,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub lock_held_transfer_timeout_secs: u64,
|
||||
|
||||
/// Maximum time a request waits for a pool connection before failing with
|
||||
/// 503, in seconds. Bounds queueing when the database is slow or down.
|
||||
#[arg(
|
||||
@@ -397,14 +359,15 @@ pub struct Config {
|
||||
/// error rather than a boot-time panic).
|
||||
///
|
||||
/// CONNECTION BUDGET. A push holds a Postgres connection from the node's separate
|
||||
/// advisory-lock pool (`GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS`, default 40) for
|
||||
/// the whole receive-pack. That pool must be at least this value plus
|
||||
/// `DB_LOCK_POOL_NON_PUSH_HEADROOM` so admitted pushes and non-push mutations
|
||||
/// can each pin a lock-pool connection for their whole duration. Size BOTH
|
||||
/// pools against the database server's `max_connections`: the main pool
|
||||
/// (`GITLAWB_DB_MAX_CONNECTIONS`, default 48) serves ordinary handlers, and the
|
||||
/// lock pool serves writes. Raising this knob does not raise the lock pool;
|
||||
/// set `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` explicitly.
|
||||
/// advisory-lock pool for the whole receive-pack, and that pool is sized from this
|
||||
/// knob (this value + 8, clamped to 64 in `main.rs`). The node's total ceiling is
|
||||
/// therefore `db_max_connections` (default 48) + the lock pool (default 40), i.e.
|
||||
/// 88 by default, and at most `db_max_connections` + 64. Size BOTH against the
|
||||
/// database server's `max_connections`: `db_max_connections`' own doc predates the
|
||||
/// lock pool and no longer covers most of the node's connections. The +8 headroom
|
||||
/// is shared with the three non-push `acquire_write` callers (`api/issues.rs` x2,
|
||||
/// `api/pulls.rs`). Raising this knob past the clamp does NOT buy more lock-pool
|
||||
/// connections; pushes beyond it wait briefly and then shed a 503 + Retry-After.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_GIT_PUSHES",
|
||||
@@ -762,37 +725,25 @@ impl Config {
|
||||
/// the pool must clear the concurrent-write cap by at least this margin.
|
||||
pub const DB_POOL_APP_HEADROOM: u32 = 8;
|
||||
|
||||
/// Lock-pool slots reserved for non-push `acquire_write` callers (issue create,
|
||||
/// close, merge) so a saturated push budget cannot turn ordinary mutations into
|
||||
/// lock-pool exhaustion on unrelated repositories.
|
||||
pub const DB_LOCK_POOL_NON_PUSH_HEADROOM: u32 = 8;
|
||||
|
||||
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
|
||||
/// catches combinations that ship a denial-of-service under otherwise-valid
|
||||
/// values. Call once at startup and fail fast on `Err`.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Concurrent git writes pin the dedicated lock pool for their whole
|
||||
// duration (connection-affine advisory lock in acquire_write). The main
|
||||
// pool no longer carries that occupancy.
|
||||
let lock_floor = self
|
||||
.max_concurrent_git_pushes
|
||||
.saturating_add(Self::DB_LOCK_POOL_NON_PUSH_HEADROOM as usize);
|
||||
if (self.db_lock_pool_max_connections as usize) < lock_floor {
|
||||
// A write pins one pooled connection for its whole duration (the
|
||||
// connection-affine advisory lock in repo_store::acquire_write), and
|
||||
// concurrent writes are capped at max_concurrent_git_pushes. If the pool
|
||||
// does not exceed that cap by DB_POOL_APP_HEADROOM, a burst of slow pushes
|
||||
// drains every connection and every other DB path 503s. (#174 F1)
|
||||
let floor = (self.max_concurrent_git_pushes as u64) + (Self::DB_POOL_APP_HEADROOM as u64);
|
||||
if (self.db_max_connections as u64) < floor {
|
||||
return Err(format!(
|
||||
"GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS ({}) must be at least \
|
||||
max_concurrent_git_pushes ({}) plus {} for non-push mutations \
|
||||
that share the lock pool",
|
||||
self.db_lock_pool_max_connections,
|
||||
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least max_concurrent_git_pushes ({}) \
|
||||
+ {} headroom = {}: each concurrent write pins one pooled connection for its whole \
|
||||
duration, so a smaller pool lets a burst of slow pushes starve every other DB path.",
|
||||
self.db_max_connections,
|
||||
self.max_concurrent_git_pushes,
|
||||
Self::DB_LOCK_POOL_NON_PUSH_HEADROOM
|
||||
));
|
||||
}
|
||||
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
|
||||
if (self.db_max_connections as u64) < main_floor {
|
||||
return Err(format!(
|
||||
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least {} for non-git \
|
||||
database paths",
|
||||
self.db_max_connections, main_floor
|
||||
Self::DB_POOL_APP_HEADROOM,
|
||||
floor
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -803,25 +754,6 @@ impl Config {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lock_pool_size_defaults_to_40_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).db_lock_pool_max_connections,
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "8"])
|
||||
.db_lock_pool_max_connections,
|
||||
8
|
||||
);
|
||||
// A zero-sized lock pool would deny every write, so clap must reject it
|
||||
// rather than let a node boot into a state where no repo can be written.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "0"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
@@ -1431,61 +1363,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F1: concurrent git writes pin the dedicated lock pool, not the main
|
||||
/// pool. `validate()` must reject an under-sized lock pool at boot.
|
||||
/// #174 F1: a connection-affine write lock pins a pooled connection per
|
||||
/// concurrent write, so the pool must clear `max_concurrent_git_pushes` by
|
||||
/// `DB_POOL_APP_HEADROOM` or a push burst starves every other DB path.
|
||||
/// `validate()` must reject an under-sized pool at boot.
|
||||
#[test]
|
||||
fn db_pool_must_clear_the_git_push_cap() {
|
||||
// Shipped defaults validate (lock pool 40 >= pushes 32 + non-push headroom 8).
|
||||
// Shipped defaults validate (48 >= 32 + 8).
|
||||
Config::parse_from(["gitlawb-node"])
|
||||
.validate()
|
||||
.expect("default config must validate");
|
||||
|
||||
// An under-sized lock pool relative to the push cap is rejected.
|
||||
let under_lock = Config::parse_from([
|
||||
// An under-sized pool relative to the push cap is rejected (20 < 32 + 8).
|
||||
let under = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"16",
|
||||
"--db-max-connections",
|
||||
"20",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
under_lock.validate().is_err(),
|
||||
"db_lock_pool_max_connections below max_concurrent_git_pushes + headroom must be rejected"
|
||||
under.validate().is_err(),
|
||||
"db_max_connections 20 below max_concurrent_git_pushes 32 + headroom must be rejected"
|
||||
);
|
||||
|
||||
// Exactly at the push cap without non-push headroom is rejected.
|
||||
let push_only = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"32",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
push_only.validate().is_err(),
|
||||
"db_lock_pool_max_connections equal to max_concurrent_git_pushes must be rejected"
|
||||
);
|
||||
|
||||
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
|
||||
let split = Config::parse_from([
|
||||
// Exactly at the floor validates (40 == 32 + 8).
|
||||
let at_floor = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"16",
|
||||
"--db-lock-pool-max-connections",
|
||||
"40",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
split.validate().is_ok(),
|
||||
"a small main pool with a large lock pool must validate"
|
||||
);
|
||||
|
||||
// Main pool still needs the app headroom floor.
|
||||
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
assert!(
|
||||
under_main.validate().is_err(),
|
||||
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
|
||||
at_floor.validate().is_ok(),
|
||||
"db_max_connections at the floor (pushes + headroom) must validate"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,11 @@ pub struct Db {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Access the underlying Postgres connection pool.
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn for_testing(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
@@ -305,46 +310,6 @@ impl Db {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Build the dedicated pool that advisory-lock connections come from.
|
||||
///
|
||||
/// Deliberately **lazy**: connections open on first use rather than at boot.
|
||||
/// The main pool has to connect eagerly because it runs migrations, which is
|
||||
/// why it needs `connect_db_with_retry`'s backoff and degraded-server
|
||||
/// handoff. This pool has no startup work at all, so an eager connect would
|
||||
/// only add a new way for the process to fail to boot, and would need a
|
||||
/// second copy of that retry machinery to be safe. Being lazy removes the
|
||||
/// failure mode instead of handling it: if Postgres is unreachable when the
|
||||
/// first write arrives, that write fails on the pool's own acquire timeout,
|
||||
/// the same way any other database-backed request already does.
|
||||
///
|
||||
/// Kept separate from the main pool so a burst of lock-holding connections
|
||||
/// cannot starve ordinary request handlers. See
|
||||
/// `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` for the sizing tradeoff.
|
||||
pub fn lock_pool(
|
||||
database_url: &str,
|
||||
max_connections: u32,
|
||||
acquire_timeout: Duration,
|
||||
) -> Result<PgPool> {
|
||||
info!(
|
||||
max_connections,
|
||||
acquire_timeout_secs = acquire_timeout.as_secs(),
|
||||
"creating dedicated advisory-lock pool (lazy)"
|
||||
);
|
||||
PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
// Explicit, and load-bearing rather than cosmetic: `RepoWriteGuard::Drop`
|
||||
// has a no-runtime branch that calls `PoolConnection::leak()` and relies
|
||||
// on the husk's own drop doing nothing. With `min_connections > 0` that
|
||||
// drop still spawns a pool-replenish task, and spawning without a runtime
|
||||
// panics inside Drop, which aborts the process during unwind. It is 0 by
|
||||
// default, so this line exists to keep a future tuning change from
|
||||
// silently re-arming that panic.
|
||||
.min_connections(0)
|
||||
.acquire_timeout(acquire_timeout)
|
||||
.connect_lazy(database_url)
|
||||
.context("creating advisory-lock pool")
|
||||
}
|
||||
|
||||
/// Cheap liveness probe against the pool, for readiness checks: one
|
||||
/// `SELECT 1` that fails fast when the database is unreachable.
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
|
||||
@@ -69,15 +69,6 @@ pub enum AppError {
|
||||
#[error("server overloaded: {0}")]
|
||||
Overloaded(String),
|
||||
|
||||
#[error("repository is busy")]
|
||||
RepoBusy,
|
||||
|
||||
#[error("repository is temporarily unavailable")]
|
||||
RepoUnavailable,
|
||||
|
||||
#[error("repository write was fenced by a concurrent publish")]
|
||||
RepoWriteFenced,
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
@@ -119,31 +110,7 @@ impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
match err.downcast::<sqlx::Error>() {
|
||||
Ok(sql) => AppError::Db(sql),
|
||||
// Lock contention is transient and ordinary, so it must not land as a
|
||||
// 500. The internal message names the owner slug and repo, so the
|
||||
// variant carries nothing: the detail stays in the log at the raise
|
||||
// site and the client gets a fixed retryable body.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
|
||||
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
|
||||
Ok(_) => AppError::RepoBusy,
|
||||
// Same reasoning one rung down: a refused under-lock refresh is a
|
||||
// transient storage condition, and its internal message names the
|
||||
// owner slug and repo, so the variant carries nothing.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
|
||||
Ok(_) => AppError::RepoUnavailable,
|
||||
// And one more rung: a publish the store refused twice is
|
||||
// transient in the same way, and the retry is the client's
|
||||
// to make. The variant carries nothing for the same reason
|
||||
// as the two above.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
|
||||
{
|
||||
Ok(_) => AppError::RepoWriteFenced,
|
||||
Err(err) => AppError::Internal(err),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Err(err) => AppError::Internal(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,29 +184,6 @@ impl IntoResponse for AppError {
|
||||
// 504, distinct from the 500 git_error and from the read-gate's 404 /
|
||||
// the auth 401, so the client can tell a deadline from a failure.
|
||||
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
|
||||
// 503 with a FIXED body: the caller should retry, and must not be told
|
||||
// which repo is contended or for how long.
|
||||
AppError::RepoBusy => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_busy",
|
||||
"repository is busy — retry".into(),
|
||||
),
|
||||
// 503 with a FIXED body for the same reason: the caller should retry, and
|
||||
// must not be told which repo could not be refreshed or why.
|
||||
AppError::RepoUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_unavailable",
|
||||
"repository is temporarily unavailable, retry".into(),
|
||||
),
|
||||
// 503 with a FIXED body again, and its own code: the caller should
|
||||
// retry, but the condition is not contention, so a client that
|
||||
// distinguishes them should be able to. The body must not say which
|
||||
// repo lost its publish or to whom.
|
||||
AppError::RepoWriteFenced => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_write_fenced",
|
||||
"repository changed underneath this write, retry".into(),
|
||||
),
|
||||
AppError::Db(e) if db_unavailable(e) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
DB_UNAVAILABLE_CODE,
|
||||
@@ -300,11 +244,7 @@ impl IntoResponse for AppError {
|
||||
// here rather than in bespoke early returns, keeping each variant handled once.
|
||||
if matches!(
|
||||
self,
|
||||
AppError::Overloaded(_)
|
||||
| AppError::SearchIncomplete { .. }
|
||||
| AppError::RepoBusy
|
||||
| AppError::RepoUnavailable
|
||||
| AppError::RepoWriteFenced
|
||||
AppError::Overloaded(_) | AppError::SearchIncomplete { .. }
|
||||
) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
@@ -422,31 +362,4 @@ mod tests {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
|
||||
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
|
||||
for err in [
|
||||
AppError::RepoBusy,
|
||||
AppError::RepoUnavailable,
|
||||
AppError::RepoWriteFenced,
|
||||
] {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,27 +61,6 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a single issue ref while the write guard still holds the advisory lock,
|
||||
/// after a definite publish refusal. Best-effort: a failed delete leaves a
|
||||
/// local-only orphan, which is still better than telling the client to retry into a
|
||||
/// duplicate id. Not used on `UploadUnknowable`, where the PUT may still land.
|
||||
pub fn delete_issue_ref(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
issue_id: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<()> {
|
||||
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
|
||||
crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["update-ref", "-d", &ref_name],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all issue refs and return their JSON content.
|
||||
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
|
||||
// List all refs under refs/gitlawb/issues/
|
||||
@@ -299,27 +278,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().to_string().contains("ambiguous"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_issue_ref_removes_a_created_ref() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "eee88888-0000-0000-0000-000000000000";
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"eee88888-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
delete_issue_ref(
|
||||
"git",
|
||||
dir.path(),
|
||||
full_id,
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolve_issue_id(dir.path(), full_id).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_issue_via_prefix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
+1005
-4298
File diff suppressed because it is too large.
Load diff
@@ -870,7 +870,6 @@ pub fn merge_branch(
|
||||
}
|
||||
|
||||
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
|
||||
#[allow(dead_code)] // exercised from test_support and state tests; production uses validated_repo_disk_path
|
||||
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
|
||||
// Sanitize the DID for use as a directory name
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
|
||||
@@ -5,41 +5,12 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// The precondition an upload is fenced on.
|
||||
///
|
||||
/// Object storage is the only place a fence can hold. Dropping the future of an
|
||||
/// in-flight PUT does not cancel the request the server is already processing,
|
||||
/// so no amount of local locking stops an abandoned writer's bytes from landing
|
||||
/// after a successor has published. A conditional PUT the store itself refuses
|
||||
/// is what actually stops it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum UploadPrecondition {
|
||||
/// Publish only if the stored object is still the generation we observed.
|
||||
IfMatch(String),
|
||||
/// Publish only if nothing is stored under the key yet.
|
||||
IfAbsent,
|
||||
/// No fence. Last writer wins.
|
||||
Unconditional,
|
||||
}
|
||||
|
||||
/// Why an upload failed, split so a caller can tell "someone else already
|
||||
/// published this key" (expected, and dropping our bytes is the correct
|
||||
/// outcome) from a real storage failure.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UploadError {
|
||||
#[error("upload precondition lost (HTTP {status})")]
|
||||
PreconditionLost { status: u16 },
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// Wrapper around the S3 client with the configured bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct TigrisClient {
|
||||
@@ -60,29 +31,21 @@ impl TigrisClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a client pointed at an arbitrary endpoint, for tests.
|
||||
///
|
||||
/// The production constructor reads the endpoint and credentials from the
|
||||
/// environment, which a test cannot steer without mutating process-global
|
||||
/// state. This takes both explicitly so a test can aim the client at a
|
||||
/// closed port and get a prompt transport error out of `exists()`.
|
||||
///
|
||||
/// `RetryConfig::disabled()` is load-bearing, not tidiness: the SDK's default
|
||||
/// policy retries a connection refusal with backoff, which turns each failing
|
||||
/// call into seconds of waiting.
|
||||
/// 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 fn for_testing_with_endpoint(bucket: &str, endpoint: &str) -> Self {
|
||||
use aws_sdk_s3::config::{retry::RetryConfig, Credentials, Region};
|
||||
|
||||
let config = aws_sdk_s3::config::Config::builder()
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(Credentials::new("test", "test", None, None, "test"))
|
||||
.region(Region::new("auto"))
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
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::from_conf(config),
|
||||
s3: S3Client::new(&config),
|
||||
bucket: bucket.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -114,47 +77,8 @@ impl TigrisClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the ETag of a repo archive, or `None` when nothing is stored under
|
||||
/// the key. The ETag identifies the generation a later conditional upload
|
||||
/// can fence itself on.
|
||||
///
|
||||
/// Separate from `exists` rather than folded into it: `exists` has callers
|
||||
/// that only want the boolean, and widening its return type would churn
|
||||
/// every one of them for no benefit.
|
||||
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
match self
|
||||
.s3
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => Ok(Some(
|
||||
out.e_tag()
|
||||
.context(format!("tigris HEAD {key}: hit carried no ETag"))?
|
||||
.to_string(),
|
||||
)),
|
||||
Err(e) => {
|
||||
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive,
|
||||
/// fenced by `precondition`.
|
||||
pub async fn upload(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
precondition: UploadPrecondition,
|
||||
) -> std::result::Result<(), UploadError> {
|
||||
/// 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");
|
||||
|
||||
@@ -169,57 +93,15 @@ impl TigrisClient {
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
|
||||
|
||||
let mut req = self
|
||||
.s3
|
||||
self.s3
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.body(body)
|
||||
.content_type("application/zstd");
|
||||
match &precondition {
|
||||
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
|
||||
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
|
||||
UploadPrecondition::Unconditional => {}
|
||||
}
|
||||
|
||||
if let Err(e) = req.send().await {
|
||||
// `PutObjectError` models no PreconditionFailed variant (its arms are
|
||||
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
|
||||
// TooManyParts, Unhandled), so a refused precondition arrives as
|
||||
// `Unhandled` and matching the enum would classify it as a generic
|
||||
// failure. The raw HTTP status off the response is the only place the
|
||||
// answer actually lives.
|
||||
//
|
||||
// Read it via `raw_response()`, not a `ServiceError`-only match: the
|
||||
// SDK exposes the raw response for BOTH `ServiceError` and
|
||||
// `ResponseError`, and a refused conditional PUT whose error body the
|
||||
// SDK cannot parse (malformed XML, premature close) surfaces as
|
||||
// `ResponseError`. Matching only `ServiceError` would classify that
|
||||
// unparsable 409/412 as a generic failure, and `RepoWriteGuard::release`
|
||||
// would log-and-succeed instead of taking the supersede retry,
|
||||
// acknowledging a write that was definitively not published.
|
||||
let status = e.raw_response().map(|raw| raw.status().as_u16());
|
||||
// 412 is always a lost precondition. 409 is one only when we asked
|
||||
// for create-only, which is how S3-compatible stores report "the key
|
||||
// already exists". Everything else, 404 included, is a real failure:
|
||||
// archive keys are never deleted (`delete` has no callers), so a 404
|
||||
// here means something permanent like a missing bucket or a
|
||||
// misrouted endpoint, and reporting that as a lost precondition
|
||||
// would tell a caller to expect a successor that does not exist.
|
||||
let lost = match status {
|
||||
Some(412) => true,
|
||||
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
|
||||
_ => false,
|
||||
};
|
||||
if lost {
|
||||
return Err(UploadError::PreconditionLost {
|
||||
status: status.expect("a lost precondition came from a status"),
|
||||
});
|
||||
}
|
||||
return Err(UploadError::Other(
|
||||
anyhow::Error::new(e).context(format!("tigris PUT {key}")),
|
||||
));
|
||||
}
|
||||
.content_type("application/zstd")
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris PUT {key}"))?;
|
||||
|
||||
info!(key = %key, "uploaded repo to tigris");
|
||||
Ok(())
|
||||
@@ -230,32 +112,10 @@ impl TigrisClient {
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
self.download_to(owner_slug, repo_name, local_path, true, swap_authority)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract it, returning the
|
||||
/// directory that was populated.
|
||||
///
|
||||
/// `publish` controls whether the extract is swapped into `target` in place
|
||||
/// (the live-path mutation used by writes; returns `target`) or unpacked
|
||||
/// into a fresh temp directory under `target`'s parent (a non-mutating
|
||||
/// snapshot read; returns the temp dir, which the caller owns and cleans
|
||||
/// up). The snapshot form never touches the live repo path.
|
||||
pub async fn download_to(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
target: &super::repo_store::ValidatedRepoDiskPath,
|
||||
publish: bool,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
) -> Result<DownloadExtract> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
|
||||
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
|
||||
|
||||
let resp = self
|
||||
.s3
|
||||
@@ -273,62 +133,21 @@ impl TigrisClient {
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
|
||||
// The snapshot temp dir is decided HERE, in the async layer, before the
|
||||
// extraction runs. Cleanup ownership moves into the blocking task so a
|
||||
// cancelled async future cannot drop it while extraction is still running.
|
||||
let snapshot_tmp = if publish {
|
||||
None
|
||||
} else {
|
||||
let parent = target.parent().context("snapshot path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.context("snapshot path has no file name")?
|
||||
.to_string_lossy();
|
||||
Some(parent.join(format!(
|
||||
".{file_name}.tmp-snapshot.{}",
|
||||
uuid::Uuid::new_v4()
|
||||
)))
|
||||
};
|
||||
|
||||
// Extract tar.zst to a directory.
|
||||
let extracted = tokio::task::spawn_blocking({
|
||||
let target = target.clone();
|
||||
let snapshot_tmp = snapshot_tmp.clone();
|
||||
move || -> Result<DownloadExtract> {
|
||||
let result = (|| -> Result<DownloadExtract> {
|
||||
if publish {
|
||||
decompress_repo(&data, &target, swap_authority.as_ref())?;
|
||||
return Ok(DownloadExtract::Published(()));
|
||||
}
|
||||
// Non-mutating snapshot: unpack into the temp dir decided above.
|
||||
// The live repo path is never touched.
|
||||
let tmp_dir = snapshot_tmp.expect("snapshot path was decided above");
|
||||
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);
|
||||
}
|
||||
Ok(DownloadExtract::Snapshot(TempSnapshotDir { path: tmp_dir }))
|
||||
})();
|
||||
result
|
||||
}
|
||||
// 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 = %target.as_path().display(), "downloaded repo from tigris");
|
||||
Ok(extracted)
|
||||
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
|
||||
@@ -361,7 +180,7 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
/// `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.
|
||||
pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
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
|
||||
@@ -374,35 +193,6 @@ pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
|
||||
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
|
||||
/// before the outer future resumes still removes the directory.
|
||||
pub(crate) struct TempSnapshotDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempSnapshotDir {
|
||||
pub(crate) fn into_repo_snapshot(self) -> super::repo_store::RepoSnapshot {
|
||||
let path = self.path.clone();
|
||||
std::mem::forget(self);
|
||||
super::repo_store::RepoSnapshot::from_owned_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempSnapshotDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TigrisClient::download_to`] produced.
|
||||
pub enum DownloadExtract {
|
||||
/// Published into the validated live repo path.
|
||||
Published(()),
|
||||
/// Unpacked into a throwaway temp dir that cleans up on drop until adopted.
|
||||
Snapshot(TempSnapshotDir),
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
@@ -410,16 +200,11 @@ pub enum DownloadExtract {
|
||||
/// 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: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<&Arc<AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
let live = local_path.as_path();
|
||||
let parent = live.parent().context("repo path has no parent")?;
|
||||
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 = live
|
||||
let file_name = local_path
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
@@ -444,218 +229,17 @@ fn decompress_repo(
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Swap through the validated-path helper so CodeQL sees the barrier before the
|
||||
// remove/rename sink (`rust/path-injection`).
|
||||
super::repo_store::swap_extracted_into_validated_repo(local_path, &tmp_dir, swap_authority)?;
|
||||
// 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use futures::FutureExt;
|
||||
|
||||
/// The envs the probe needs, all of them, or it does not run.
|
||||
///
|
||||
/// `AWS_ENDPOINT_URL_S3` is included on purpose: without it the SDK resolves
|
||||
/// to real AWS S3, and a probe that passed there would say nothing about
|
||||
/// Tigris.
|
||||
fn probe_env() -> Option<String> {
|
||||
if std::env::var("GITLAWB_TIGRIS_PROBE").ok().as_deref() != Some("1") {
|
||||
return None;
|
||||
}
|
||||
for name in [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_ENDPOINT_URL_S3",
|
||||
] {
|
||||
if std::env::var(name).is_err() {
|
||||
eprintln!("tigris conditional-write probe: {name} is unset, skipping");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
match std::env::var("GITLAWB_TIGRIS_BUCKET") {
|
||||
Ok(b) if !b.is_empty() => Some(b),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"tigris conditional-write probe: GITLAWB_TIGRIS_BUCKET is unset, skipping"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One conditional PUT, reported as the status that REFUSED it, or `None`
|
||||
/// when the store accepted the write.
|
||||
///
|
||||
/// Accepted is the interesting answer here, not an error: it means the
|
||||
/// endpoint ignored the header we fenced on.
|
||||
async fn conditional_put(
|
||||
s3: &S3Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
body: &'static [u8],
|
||||
if_match: Option<&str>,
|
||||
if_none_match: Option<&str>,
|
||||
) -> Result<Option<u16>, String> {
|
||||
let mut req = s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(body));
|
||||
if let Some(v) = if_match {
|
||||
req = req.if_match(v);
|
||||
}
|
||||
if let Some(v) = if_none_match {
|
||||
req = req.if_none_match(v);
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => match e.raw_response() {
|
||||
// Same raw-response rule as `upload`: a refused conditional PUT
|
||||
// whose body the SDK cannot parse surfaces as `ResponseError`, and
|
||||
// the status has to come off the raw response for both variants.
|
||||
// Without it, an unparsable 409/412 would report "no HTTP
|
||||
// response" here and skip the supersede retry.
|
||||
Some(raw) => Ok(Some(raw.status().as_u16())),
|
||||
None => Err(format!("conditional PUT {key}: no HTTP response: {e}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe body, written to RETURN its failures rather than panic on
|
||||
/// them, so the caller's cleanup is reached on every arm.
|
||||
async fn conditional_write_probe(s3: &S3Client, bucket: &str, key: &str) -> Result<(), String> {
|
||||
// 1. A plain PUT under a throwaway key.
|
||||
let seeded = s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"probe-one"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("seeding PUT {key}: {e}"))?;
|
||||
|
||||
// 2. Its ETag, which is the generation the next arm fences against.
|
||||
let etag = seeded
|
||||
.e_tag()
|
||||
.ok_or_else(|| format!("seeding PUT {key} returned no ETag"))?
|
||||
.to_string();
|
||||
|
||||
// 3. A deliberately wrong If-Match. A store honoring it answers 412.
|
||||
let wrong = format!("\"{}\"", "0".repeat(32));
|
||||
if etag.trim_matches('"') == wrong.trim_matches('"') {
|
||||
return Err(format!(
|
||||
"the seeded ETag {etag} collides with the deliberately wrong one, \
|
||||
so this arm would prove nothing"
|
||||
));
|
||||
}
|
||||
match conditional_put(s3, bucket, key, b"probe-two", Some(&wrong), None).await? {
|
||||
Some(412) => {}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"a stale If-Match must be refused with 412, the endpoint answered {status}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"a stale If-Match was ACCEPTED: this endpoint does not honor If-Match, so \
|
||||
the release fence cannot hold here"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If-None-Match `*` over the object that now exists. This arm matters
|
||||
// MORE than the one above. An ignored If-Match eventually surfaces as
|
||||
// odd behavior, because a stale writer overwrites and someone notices
|
||||
// the lost tree. An ignored If-None-Match just returns 200, so a publish
|
||||
// that should have been fenced lands with no error anywhere: the silent
|
||||
// no-op the bucket-type caveat on this test describes.
|
||||
match conditional_put(s3, bucket, key, b"probe-three", None, Some("*")).await? {
|
||||
// Either status is a pass, and the asymmetry with the If-Match arm
|
||||
// above mirrors `upload`'s classifier exactly: 412 is always a lost
|
||||
// precondition, and 409 is one too when we asked for create-only.
|
||||
// AWS documents 409 for a create-only conflict racing a delete, so a
|
||||
// store answering it is enforcing the precondition and we already
|
||||
// handle it. Pinning 412 alone here would fail the probe against a
|
||||
// backend that is behaving correctly, which sends whoever runs it
|
||||
// chasing a fault that is not there.
|
||||
Some(412) | Some(409) => {}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"create-only over an existing object must be refused with 412 or 409, \
|
||||
the endpoint answered {status}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"If-None-Match * was ACCEPTED over an existing object: this endpoint does \
|
||||
not honor create-only, so a fenced publish lands silently"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Probe the REAL Tigris endpoint for the conditional-write semantics the
|
||||
/// release fence depends on.
|
||||
///
|
||||
/// UNTIL THIS IS RUN AGAINST REAL CREDENTIALS, the fence is verified against
|
||||
/// vendor documentation and an in-process mock, not against the backend it
|
||||
/// runs on. The mock implements the semantics we believe Tigris has; it
|
||||
/// cannot tell us whether Tigris actually has them.
|
||||
///
|
||||
/// The bucket matters, not just the endpoint. Tigris documents conditional
|
||||
/// operations as supported on Single-region and Multi-region buckets only.
|
||||
/// Global and Dual-region buckets are eventually consistent, and a
|
||||
/// conditional PUT evaluated against a stale replica would make the fence a
|
||||
/// silent no-op rather than an error. So point `GITLAWB_TIGRIS_BUCKET` at a
|
||||
/// throwaway bucket of the SAME type production uses.
|
||||
///
|
||||
/// Ignored by default and additionally gated on `GITLAWB_TIGRIS_PROBE=1`,
|
||||
/// because it writes to a real bucket and costs real requests. Run with:
|
||||
/// `GITLAWB_TIGRIS_PROBE=1 cargo test -p gitlawb-node --bin gitlawb-node
|
||||
/// tigris_honors_conditional_writes -- --ignored --nocapture`
|
||||
#[tokio::test]
|
||||
#[ignore = "writes to a real Tigris bucket; needs GITLAWB_TIGRIS_PROBE=1 plus credentials"]
|
||||
async fn tigris_honors_conditional_writes() {
|
||||
let Some(bucket) = probe_env() else {
|
||||
eprintln!(
|
||||
"tigris conditional-write probe: skipped. Set GITLAWB_TIGRIS_PROBE=1, \
|
||||
GITLAWB_TIGRIS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and \
|
||||
AWS_ENDPOINT_URL_S3 to run it."
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
||||
let s3 = S3Client::new(&config);
|
||||
// A fresh key per run, so a probe that somehow orphaned an object on an
|
||||
// earlier run cannot change what this one observes.
|
||||
let key = format!("probe/conditional-write-{}.bin", uuid::Uuid::new_v4());
|
||||
|
||||
// CLEANUP MUST RUN ON EVERY ARM, and a failing assertion is precisely
|
||||
// the case this probe exists to catch, so the delete cannot sit after
|
||||
// the checks. The body returns its failures rather than panicking, and
|
||||
// `catch_unwind` covers the panic an SDK call could still raise; either
|
||||
// way the delete below is reached before the verdict is re-raised.
|
||||
let outcome = std::panic::AssertUnwindSafe(conditional_write_probe(&s3, &bucket, &key))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
if let Err(e) = s3.delete_object().bucket(&bucket).key(&key).send().await {
|
||||
eprintln!("tigris conditional-write probe: cleanup of {key} failed: {e}");
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(msg)) => panic!("tigris conditional-write probe: {msg}"),
|
||||
Err(payload) => std::panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,7 +676,7 @@ async fn warm_candidates(
|
||||
&repo.owner_did,
|
||||
&repo.name,
|
||||
) {
|
||||
Ok(p) if p.is_dir() => out.push((repo, created_at_key, p.into_path_buf())),
|
||||
Ok(p) if p.is_dir() => out.push((repo, created_at_key, p)),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_id = %repo.id, err = %e, "sweep discovery: rejected unsafe repo path");
|
||||
|
||||
+231
-54
@@ -59,6 +59,32 @@ struct DbStartupStatus {
|
||||
next_retry_secs: AtomicU64,
|
||||
}
|
||||
|
||||
/// Hard ceiling on the advisory-lock pool's `max_connections`.
|
||||
///
|
||||
/// `max_concurrent_git_pushes` is validated all the way up to 1_048_576, and the lock
|
||||
/// pool used to derive its size straight from that knob, so raising the push cap
|
||||
/// silently raised the node's Postgres connection ceiling with no CLI error and no
|
||||
/// relation to the server's own `max_connections` (#173 F4). The node's total budget is
|
||||
/// now bounded: `db_max_connections` (default 48) + at most this.
|
||||
const LOCK_POOL_MAX_CONNECTIONS: u32 = 64;
|
||||
|
||||
/// Connections the lock pool keeps above the push cap. Covers the three non-push
|
||||
/// `acquire_write` callers (`api/issues.rs` x2, `api/pulls.rs`), which hold no
|
||||
/// concurrency permit, so a push never queues here for a connection where it did not
|
||||
/// before.
|
||||
const LOCK_POOL_PUSH_HEADROOM: u8 = 8;
|
||||
|
||||
/// Size the advisory-lock pool for a given push cap: the cap plus
|
||||
/// [`LOCK_POOL_PUSH_HEADROOM`], clamped to [`LOCK_POOL_MAX_CONNECTIONS`]. Past the
|
||||
/// clamp a push may wait for a lock-pool connection, which is a bounded wait that sheds
|
||||
/// a clean 503 (see `LockPoolBusy`), not an unbounded hang.
|
||||
fn lock_pool_size(max_concurrent_git_pushes: usize) -> u32 {
|
||||
u32::try_from(max_concurrent_git_pushes)
|
||||
.unwrap_or(u32::MAX)
|
||||
.saturating_add(u32::from(LOCK_POOL_PUSH_HEADROOM))
|
||||
.min(LOCK_POOL_MAX_CONNECTIONS)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -90,6 +116,8 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Load or generate the node's identity keypair
|
||||
let keypair = load_or_create_keypair(&config)?;
|
||||
// Sealing key for the legacy-scan continuation tokens, DERIVED from the identity
|
||||
// just loaded so it is the same key after a restart (see `derive_scan_token_key`).
|
||||
let scan_token_key = AppState::derive_scan_token_key(&keypair);
|
||||
let node_did = keypair.did();
|
||||
|
||||
@@ -288,20 +316,20 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
// Advisory-lock connections come from their own pool: a write guard pins one
|
||||
// for its whole lifetime, and sharing the application pool would let a push
|
||||
// burst starve ordinary request handlers.
|
||||
let lock_pool = db::Db::lock_pool(
|
||||
&config.database_url,
|
||||
config.db_lock_pool_max_connections,
|
||||
// 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
|
||||
// pushes drawing from the main pool would park that many connections for the
|
||||
// duration of their receive-packs and starve every other query. That holds
|
||||
// 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).
|
||||
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),
|
||||
)?;
|
||||
let repo_store = git::repo_store::RepoStore::new(
|
||||
config.repos_dir.clone(),
|
||||
tigris,
|
||||
lock_pool,
|
||||
std::time::Duration::from_secs(config.lock_held_transfer_timeout_secs),
|
||||
);
|
||||
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.
|
||||
@@ -346,23 +374,6 @@ async fn main() -> Result<()> {
|
||||
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
|
||||
}
|
||||
|
||||
// close_issue drives a full archive download for non-owner author pre-checks.
|
||||
// Keep it on its own bucket so it cannot drain the receive-pack limit.
|
||||
let close_issue_limit = std::env::var("GITLAWB_CLOSE_ISSUE_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(120);
|
||||
let close_issue_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
close_issue_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if close_issue_limit == 0 {
|
||||
tracing::warn!(
|
||||
"GITLAWB_CLOSE_ISSUE_RATE_LIMIT=0 — per-IP close_issue rate limiting disabled"
|
||||
);
|
||||
}
|
||||
|
||||
// Which forwarded header the edge is trusted to set. Default None (trust
|
||||
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
|
||||
// a node behind Caddy/NGINX sets it to x-forwarded-for.
|
||||
@@ -412,10 +423,18 @@ async fn main() -> Result<()> {
|
||||
rate_limiter,
|
||||
create_ip_rate_limiter,
|
||||
push_rate_limiter,
|
||||
close_issue_rate_limiter,
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
// The legacy-probe budget is operator-tunable via GITLAWB_IPFS_MAX_LEGACY_PROBES
|
||||
// (R5), which is what `ipfs_legacy_probe_budget` reads. NOT
|
||||
// GITLAWB_IPFS_MAX_REPOS_WALKED, which this comment used to name: that is the
|
||||
// separate cap on expensive visibility walks, so an operator following the old
|
||||
// text tuned the walk cap and left this fan-out unchanged. The history-walk
|
||||
// ceiling above stays constant (a smaller value false-503s a provenanced
|
||||
// request). Default 256 preserves the shipped behaviour.
|
||||
ipfs_max_legacy_probes: AppState::ipfs_legacy_probe_budget(&config),
|
||||
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
|
||||
// Operator-tunable via GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS, read through the same
|
||||
// helper shape as the probe budget so the knob cannot be a silent no-op.
|
||||
ipfs_max_legacy_scan_rows: AppState::ipfs_legacy_scan_row_budget(&config),
|
||||
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
|
||||
ipfs_scan_token_key: Arc::new(scan_token_key),
|
||||
@@ -494,6 +513,10 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
// Separate WORK-budget bucket for the resolver's per-probe/per-walk charges (R6).
|
||||
// Its capacity is DERIVED from the route limit (no new knob) and floored at the
|
||||
// legacy-probe budget, so one full default-config legacy scan never self-throttles
|
||||
// mid-request while the route brake above stays the pure once-per-request cap.
|
||||
ipfs_work_rate_limiter: rate_limit::RateLimiter::new_bounded(
|
||||
AppState::ipfs_work_budget(&config),
|
||||
std::time::Duration::from_secs(3600),
|
||||
@@ -533,14 +556,16 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let sweep_state = state.clone();
|
||||
let cleanup_state = state.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
sweep_state.sweep_rate_limiters().await;
|
||||
// Sweep every per-IP/DID limiter (incl. the ipfs walk brake)
|
||||
// so bounded maps shed stale keys instead of sitting at cap.
|
||||
cleanup_state.sweep_rate_limiters().await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
@@ -683,6 +708,44 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// U4 (#173): spawn the periodic legacy provider-CID repair sweep. Releases before this
|
||||
/// version stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`,
|
||||
/// and this version's `/ipfs/{cid}` resolver withholds any row whose stored key is not
|
||||
/// the raw-content CID. The opportunistic repair on the pin path only fires when a push
|
||||
/// re-carries the object, which normal git negotiation makes it not do, so those rows
|
||||
/// need a walk. DETACHED, never on the boot path: the caller keeps serving while this
|
||||
/// runs, and the sweep's own batch bound plus inter-batch delay keep it off the DB's
|
||||
/// critical path. Its cursor is durable, so a restart mid-walk resumes instead of
|
||||
/// rewinding. It re-arms after every run, including a failed one, so it never returns
|
||||
/// and there is no awaited value to log here; the shutdown watcher below is what ends
|
||||
/// it.
|
||||
///
|
||||
/// A named function rather than an inline block in `main` so the WIRING has a seam a
|
||||
/// test can call: that the task is spawned at all, that it reads its batch and delay
|
||||
/// from the config knobs rather than some other field, that the caller is not blocked
|
||||
/// on it, and that the shutdown watcher actually ends it mid-walk. The sweep's own
|
||||
/// behavior is covered elsewhere; this is the boot-path half.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
// Shutdown mid-walk simply drops the run; the persisted cursor means the
|
||||
// next boot picks up where this one stopped.
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
@@ -1124,20 +1187,20 @@ mod rate_limiter_sweep_tests {
|
||||
state.rate_limiter = RateLimiter::new(10, window);
|
||||
state.create_ip_rate_limiter = RateLimiter::new(10, window);
|
||||
state.push_rate_limiter = RateLimiter::new(10, window);
|
||||
state.close_issue_rate_limiter = RateLimiter::new(10, window);
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
|
||||
state.peer_write_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_work_rate_limiter = RateLimiter::new(10, window);
|
||||
|
||||
let limiters = |s: &crate::state::AppState| {
|
||||
[
|
||||
s.rate_limiter.clone(),
|
||||
s.create_ip_rate_limiter.clone(),
|
||||
s.push_rate_limiter.clone(),
|
||||
s.close_issue_rate_limiter.clone(),
|
||||
s.sync_trigger_rate_limiter.clone(),
|
||||
s.peer_write_rate_limiter.clone(),
|
||||
s.ipfs_rate_limiter.clone(),
|
||||
s.ipfs_work_rate_limiter.clone(),
|
||||
]
|
||||
};
|
||||
for l in limiters(&state) {
|
||||
@@ -1154,26 +1217,6 @@ mod rate_limiter_sweep_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// it.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn gossip_ping_round(
|
||||
db: &Db,
|
||||
client: &reqwest::Client,
|
||||
@@ -1350,6 +1393,140 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod legacy_cid_sweep_wiring_tests {
|
||||
use super::spawn_legacy_cid_sweep;
|
||||
use sqlx::PgPool;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Seed `count` `pinned_cids` rows whose keys are already canonical raw CIDv1, in a
|
||||
/// known `sha256_hex` order. The sweep's own cost gate skips a raw-CIDv1 row without
|
||||
/// reading bytes or resolving a repo, so each row is SCANNED (it advances the cursor)
|
||||
/// and nothing else. That is what makes the cursor a clean readout of how far the
|
||||
/// walk got, with no dependency on repos on disk.
|
||||
async fn seed_scannable_rows(pool: &PgPool, count: usize) -> Vec<String> {
|
||||
let mut shas = Vec::new();
|
||||
for i in 1..=count {
|
||||
let sha = format!("wire{i:02}");
|
||||
let cid = gitlawb_core::cid::Cid::from_git_object_bytes(sha.as_bytes()).to_string();
|
||||
assert!(
|
||||
gitlawb_core::cid::is_raw_cidv1(&cid),
|
||||
"the seeded key must hit the sweep's raw-CIDv1 skip, not a repair attempt"
|
||||
);
|
||||
sqlx::query("INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) VALUES ($1, $2, $3)")
|
||||
.bind(&sha)
|
||||
.bind(&cid)
|
||||
.bind("2020-01-01T00:00:00Z")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
shas.push(sha);
|
||||
}
|
||||
shas
|
||||
}
|
||||
|
||||
/// Poll the persisted sweep cursor until it reaches `want`, or give up.
|
||||
async fn cursor_reaches(db: &crate::db::Db, want: &str, within: Duration) -> String {
|
||||
let deadline = std::time::Instant::now() + within;
|
||||
loop {
|
||||
let c = db.pin_repair_cursor().await.unwrap();
|
||||
if c == want || std::time::Instant::now() >= deadline {
|
||||
return c;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// #173 U4, the BOOT-PATH half. The sweep's own logic (batching, cursor resumption,
|
||||
/// terminal vs retryable skips) is covered in `test_support`; what this covers is the
|
||||
/// wiring `main` performs, which nothing else executes: the task is spawned at all,
|
||||
/// it takes its batch and delay from the two `pin_repair_sweep_*` knobs rather than
|
||||
/// some other config field, the caller is not blocked on the walk, and the shutdown
|
||||
/// watcher ends the run mid-walk.
|
||||
///
|
||||
/// Six scannable rows, batch 2, delay 30s. One pass must land the cursor on exactly
|
||||
/// the second row and the task must then still be alive in its inter-batch sleep,
|
||||
/// which pins both knobs at once: a different batch stops at a different row, and a
|
||||
/// delay that did not come from the knob either finishes the table or leaves the task
|
||||
/// gone. Shutdown must then end it while four rows are still unwalked.
|
||||
#[sqlx::test]
|
||||
async fn the_boot_path_spawns_the_sweep_detached_with_its_configured_knobs(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let shas = seed_scannable_rows(&pool, 6).await;
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let mut config = (*state.config).clone();
|
||||
config.repos_dir = repos_dir.path().to_path_buf();
|
||||
config.pin_repair_sweep_batch = 2;
|
||||
// Far longer than this test runs, so a task still alive after the first pass can
|
||||
// only be one that is honoring the configured inter-batch delay.
|
||||
config.pin_repair_sweep_delay_secs = 30;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let handle = spawn_legacy_cid_sweep(&state, &config);
|
||||
let spawn_cost = started.elapsed();
|
||||
|
||||
let cursor = cursor_reaches(&state.db, &shas[1], Duration::from_secs(10)).await;
|
||||
assert_eq!(
|
||||
cursor, shas[1],
|
||||
"the spawned sweep must run and stop its first pass at the CONFIGURED batch \
|
||||
bound (2), leaving the cursor on the second row"
|
||||
);
|
||||
assert!(
|
||||
spawn_cost < Duration::from_secs(1),
|
||||
"the sweep must be detached, not awaited on the boot path; the spawn took \
|
||||
{spawn_cost:?}"
|
||||
);
|
||||
assert!(
|
||||
!handle.is_finished(),
|
||||
"with a 30s inter-batch delay the task must still be sleeping between passes, \
|
||||
not finished: a finished task means the delay was not the configured one"
|
||||
);
|
||||
|
||||
state.shutdown();
|
||||
tokio::time::timeout(Duration::from_secs(10), handle)
|
||||
.await
|
||||
.expect("the shutdown watcher must end the sweep, and not after its 30s delay")
|
||||
.expect("the sweep task must not panic");
|
||||
|
||||
assert_eq!(
|
||||
state.db.pin_repair_cursor().await.unwrap(),
|
||||
shas[1],
|
||||
"shutdown must have ended the run MID-walk, with the remaining rows unwalked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_pool_sizing_tests {
|
||||
use super::{lock_pool_size, LOCK_POOL_MAX_CONNECTIONS, LOCK_POOL_PUSH_HEADROOM};
|
||||
|
||||
/// The default push cap gets its cap plus headroom, so no push ever queues for a
|
||||
/// lock-pool connection where it did not before.
|
||||
#[test]
|
||||
fn default_push_cap_gets_headroom_over_the_cap() {
|
||||
assert_eq!(lock_pool_size(32), 32 + u32::from(LOCK_POOL_PUSH_HEADROOM));
|
||||
assert_eq!(lock_pool_size(1), 1 + u32::from(LOCK_POOL_PUSH_HEADROOM));
|
||||
}
|
||||
|
||||
/// #173 F4: `max_concurrent_git_pushes` is validated all the way to 1_048_576, so an
|
||||
/// operator raising it used to raise the node's Postgres connection ceiling with it,
|
||||
/// silently and without bound. The lock pool is CLAMPED instead.
|
||||
#[test]
|
||||
fn an_oversized_push_cap_is_clamped_not_propagated() {
|
||||
assert_eq!(lock_pool_size(1_048_576), LOCK_POOL_MAX_CONNECTIONS);
|
||||
assert_eq!(lock_pool_size(usize::MAX), LOCK_POOL_MAX_CONNECTIONS);
|
||||
// The largest cap that still fits under the clamp keeps its full headroom.
|
||||
let widest = (LOCK_POOL_MAX_CONNECTIONS - u32::from(LOCK_POOL_PUSH_HEADROOM)) as usize;
|
||||
assert_eq!(lock_pool_size(widest), LOCK_POOL_MAX_CONNECTIONS);
|
||||
assert_eq!(
|
||||
lock_pool_size(widest - 1),
|
||||
LOCK_POOL_MAX_CONNECTIONS - 1,
|
||||
"values below the clamp must not be rounded up to it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::{
|
||||
|
||||
@@ -79,10 +79,6 @@ pub struct AppState {
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
pub push_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for `close_issue`'s pre-lock snapshot path.
|
||||
/// Distinct from `push_rate_limiter` so a flood of close attempts cannot
|
||||
/// drain the receive-pack budget for the same source IP.
|
||||
pub close_issue_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP ROUTE brake for `GET /ipfs/{cid}`: charged ONCE per request by the
|
||||
/// `rate_limit_by_ip` middleware (server.rs), never inside the handler. It bounds
|
||||
/// request RATE (the "requests per hour" contract of `GITLAWB_IPFS_RATE_LIMIT`) on
|
||||
@@ -346,7 +342,6 @@ impl AppState {
|
||||
self.rate_limiter.cleanup().await;
|
||||
self.create_ip_rate_limiter.cleanup().await;
|
||||
self.push_rate_limiter.cleanup().await;
|
||||
self.close_issue_rate_limiter.cleanup().await;
|
||||
self.ipfs_rate_limiter.cleanup().await;
|
||||
self.ipfs_work_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
|
||||
@@ -103,7 +103,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
@@ -558,35 +557,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fork disk paths must go through `validated_repo_disk_path` so a user-supplied
|
||||
/// name cannot reach `remove_dir_all` on an escaped path (CodeQL path-injection).
|
||||
#[sqlx::test]
|
||||
async fn fork_rejects_name_that_fails_validated_disk_path(pool: PgPool) {
|
||||
let owner = "did:key:zFORKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = test_state(pool).await;
|
||||
let repo = seed_repo(owner, "fork-src");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/fork",
|
||||
axum::routing::post(crate::api::repos::fork_repo),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
let too_long = "a".repeat(101);
|
||||
let uri = format!("/api/v1/repos/{owner}/fork-src/fork");
|
||||
let body = Body::from(format!(r#"{{"name":"{too_long}"}}"#));
|
||||
let resp = router
|
||||
.oneshot(signed_request_as(owner, Method::POST, &uri, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"fork names that fail validated_repo_disk_path must be rejected before clone"
|
||||
);
|
||||
}
|
||||
|
||||
/// N13: the task handlers bind the acting DID to the signer. A caller signed
|
||||
/// as B claiming delegator_did A is rejected before any DB write (DB-free).
|
||||
#[sqlx::test]
|
||||
@@ -2420,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
|
||||
@@ -15966,6 +16386,65 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::api::repos::drain_faults;
|
||||
|
||||
/// Process-wide tracing capture so a test can assert the give-up is logged at
|
||||
/// ERROR. A global default subscriber can only be installed once per process,
|
||||
/// so it is shared by every test here and assertions filter on the repo id,
|
||||
/// which is a fresh uuid per test.
|
||||
mod logcap {
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
type Lines = Arc<Mutex<Vec<(Level, String)>>>;
|
||||
|
||||
fn lines() -> &'static Lines {
|
||||
static LINES: OnceLock<Lines> = OnceLock::new();
|
||||
LINES.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
|
||||
}
|
||||
|
||||
struct Capture;
|
||||
impl<S: Subscriber> Layer<S> for Capture {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
struct V(String);
|
||||
impl tracing::field::Visit for V {
|
||||
fn record_debug(
|
||||
&mut self,
|
||||
field: &tracing::field::Field,
|
||||
value: &dyn std::fmt::Debug,
|
||||
) {
|
||||
self.0.push_str(&format!(" {}={:?}", field.name(), value));
|
||||
}
|
||||
}
|
||||
let mut v = V(String::new());
|
||||
event.record(&mut v);
|
||||
lines()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((*event.metadata().level(), v.0));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install() {
|
||||
static ONCE: OnceLock<()> = OnceLock::new();
|
||||
ONCE.get_or_init(|| {
|
||||
let _ = tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(Capture),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn errors_containing(needle: &str) -> Vec<String> {
|
||||
lines()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(lvl, msg)| *lvl == Level::ERROR && msg.contains(needle))
|
||||
.map(|(_, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// SCENARIO 1. The repo re-read fails once, then succeeds: the drain lap
|
||||
/// must still RUN, under the refreshed state, and pin the coalesced push's
|
||||
/// object. RED before the fix (the single `Err` returned `None`, the lap
|
||||
@@ -16031,11 +16510,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// SCENARIO 2. Every re-read attempt fails: the loop must give up on a BOUND
|
||||
/// (asserted as a literal, so raising or removing the bound goes RED) and hit
|
||||
/// the give-up path that logs at ERROR in production (asserted here via the
|
||||
/// drain_faults seam, which fires on the same branch as that log).
|
||||
/// (asserted as a literal, so raising or removing the bound goes RED) and log
|
||||
/// the give-up at ERROR so the residual loss is observable rather than silent.
|
||||
#[sqlx::test]
|
||||
async fn u2_sustained_repo_reread_failure_is_bounded_and_logged(pool: PgPool) {
|
||||
logcap::install();
|
||||
let state = test_state(pool).await;
|
||||
let owner = new_did();
|
||||
let repo = seed_repo(&owner, "u2-bounded");
|
||||
@@ -16086,10 +16565,11 @@ mod tests {
|
||||
!state.db.is_pinned(&obj2).await.unwrap(),
|
||||
"with the read never succeeding there is nothing fresh to act on"
|
||||
);
|
||||
let errs = logcap::errors_containing(&repo.id);
|
||||
assert!(
|
||||
drain_faults::reread_exhausted(&repo.id),
|
||||
"the exhausted drain re-read must hit the give-up path that logs at \
|
||||
ERROR in production"
|
||||
!errs.is_empty(),
|
||||
"the exhausted drain re-read is logged at ERROR with the repo id, so \
|
||||
the residual work loss is observable; captured: {errs:?}"
|
||||
);
|
||||
assert!(
|
||||
state.encrypt_inflight.is_empty(),
|
||||
|
||||
@@ -225,8 +225,7 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
let repo_store = src("git/repo_store.rs");
|
||||
|
||||
let rel_start = repo_store
|
||||
.find("pub async fn release(self")
|
||||
.or_else(|| repo_store.find("pub async fn release(mut self"))
|
||||
.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 RepoWriteGuard")
|
||||
|
||||
@@ -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