Compare commits

..
Author SHA1 Message Date
beardthelion 5da280b6ff fix(node): propagate for-each-ref failures in resolve_issue_id 2026-09-10 23:08:58 -05:00
beardthelion f2b6cebdc1 fix(node): propagate for-each-ref failures in list_issues 2026-09-10 22:56:25 -05:00
beardthelion b5048f749f fix(node): read issue refs via cat-file --batch on stdin, resolve absence by enumeration 2026-09-10 22:00:08 -05:00
beardthelion 326f5e55ac fix(node): bound issue ref names and propagate rev-parse probe failures 2026-09-10 21:30:04 -05:00
beardthelion 4fa108160a fix(node): split absent ref from unreadable object in issue reads
get_issue folded every cat-file failure into Ok(None), so a corrupt or
unreadable object store looked identical to a missing issue. close_issue
then hit that None right after resolve_issue_id had proven the ref
exists and panicked on the expect.

A shared read_issue_blob helper now re-resolves the ref with rev-parse
(which does not read the object) when cat-file fails: unresolved means
genuinely absent (Ok(None)), resolved means a real read failure
(propagated as Err). list_issues applies the same split so a degraded
store errors the listing instead of silently under-reporting, and
close_issue treats a ref deleted between resolve and read as not-found
instead of panicking.

Closes #426.
2026-09-10 19:59:12 -05:00
29 changed files with 698 additions and 5568 deletions

No files matched your search

+4 -8
View File
@@ -3,10 +3,7 @@
# All variables are optional unless marked REQUIRED.
# ── Node identity ─────────────────────────────────────────────────────────
# 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.
# Path to the node's Ed25519 keypair PEM file.
# Generate with: gl identity new
GITLAWB_KEY=/data/keys/identity.pem
@@ -101,10 +98,9 @@ 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. 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.
# 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.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Comma-separated libp2p multiaddrs.
Generated
-3
View File
@@ -3377,13 +3377,10 @@ name = "git-remote-gitlawb"
version = "0.7.1"
dependencies = [
"anyhow",
"chrono",
"dirs",
"gitlawb-core",
"libc",
"mockito",
"reqwest",
"serde_json",
"tempfile",
"tracing",
"tracing-subscriber",
+4 -4
View File
@@ -66,8 +66,8 @@ Good today:
Known limitations:
- 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.
- 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.
- 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 | 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). |
| UCAN | Delegation token for future capability-based workflows. |
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
| Gossipsub | libp2p topic for ref-update events. |
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
@@ -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 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_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_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. |
-9
View File
@@ -14,17 +14,8 @@ 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"
+11 -852
View File
@@ -124,9 +124,7 @@ 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, absolute\n\
\x20 (default: ~/.gitlawb/identity.pem). Its parent also\n\
\x20 holds the delegations `gl ucan import` writes.\n\
\x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\
\x20 GITLAWB_LOG Log filter (default: warn)\n\
\n\
FLAGS:\n\
@@ -410,205 +408,6 @@ 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,
@@ -627,15 +426,6 @@ 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)");
}
@@ -1002,7 +792,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;
@@ -1025,38 +815,16 @@ fn load_keypair() -> Option<Keypair> {
}
}
/// 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()
}
fn resolve_key_path() -> std::path::PathBuf {
let path_str =
std::env::var("GITLAWB_KEY").unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
/// 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()
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)
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -2848,612 +2616,3 @@ 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()
);
}
}
-299
View File
@@ -1,299 +0,0 @@
//! 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}"
);
}
}
-1
View File
@@ -5,7 +5,6 @@ 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
-20
View File
@@ -30,30 +30,10 @@ 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
+25 -737
View File
@@ -16,19 +16,6 @@ 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 {
@@ -62,32 +49,11 @@ 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;
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
resource_ok && action_ok
}
}
@@ -105,138 +71,6 @@ 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 {
@@ -312,37 +146,6 @@ 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 {
@@ -445,33 +248,8 @@ impl Ucan {
/// 3. Check the proof is not expired
/// 4. Recursively verify the proof's own chain
///
/// 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"
)));
}
/// A UCAN with no proofs (root capability) passes trivially.
pub fn verify_chain(&self) -> Result<()> {
// First verify our own signature
self.verify_signature()?;
@@ -483,44 +261,34 @@ impl Ucan {
return Err(Error::Ucan("token is not yet valid".to_string()));
}
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(),
));
}
for proof_token in &self.payload.prf {
let proof = Self::decode(proof_token)
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
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 {
// The proof's audience must be this UCAN's issuer
if proof.payload.aud != self.payload.iss {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
"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()?;
}
// Recurse; the root of the proof's chain is the root of ours.
proof.verify_chain_at(depth + 1)
Ok(())
}
}
@@ -896,484 +664,4 @@ 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());
}
}
+195
View File
@@ -449,3 +449,198 @@ mod lock_pool_shed_tests {
);
}
}
/// #426: end-to-end at the handler seam. A corrupt object store behind a
/// resolving issue ref used to look identical to an absent issue: get returned
/// a fake 404, close returned a fake 404 after releasing the write guard, and
/// list silently under-reported. All three must now surface a git error.
#[cfg(test)]
mod corrupt_store_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
use tempfile::TempDir;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = Utc::now();
crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
/// A state whose repo store roots in `repos_dir`, so the test can lay down
/// a real repo and then corrupt it without touching /tmp.
async fn state_with_repos_dir(pool: &PgPool, repos_dir: &std::path::Path) -> AppState {
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store =
crate::git::repo_store::RepoStore::for_testing(repos_dir.to_path_buf(), pool.clone());
state
}
/// Bare repo at the store's expected path, holding one issue ref.
fn bare_repo_with_issue(
repos_dir: &std::path::Path,
owner: &str,
name: &str,
) -> (std::path::PathBuf, String) {
let repo_path = repos_dir
.join(owner.replace([':', '/'], "_"))
.join(format!("{name}.git"));
std::fs::create_dir_all(&repo_path).unwrap();
let out = std::process::Command::new("git")
.args(["init", "--bare"])
.arg(&repo_path)
.output()
.unwrap();
assert!(out.status.success());
let issue_id = "aaaabbbb-0000-0000-0000-000000000000".to_string();
git_issues::create_issue(
&repo_path,
&issue_id,
&serde_json::json!({
"id": issue_id,
"title": "t",
"status": "open",
})
.to_string(),
)
.unwrap();
(repo_path, issue_id)
}
/// Delete the blob object behind the issue ref and put garbage in its
/// place: `cat-file` fails while `rev-parse` still resolves the ref.
fn corrupt_issue_object(repo_path: &std::path::Path, issue_id: &str) {
let out = std::process::Command::new("git")
.args(["rev-parse", &format!("refs/gitlawb/issues/{issue_id}")])
.current_dir(repo_path)
.output()
.unwrap();
let oid = String::from_utf8(out.stdout).unwrap().trim().to_string();
let obj = repo_path.join("objects").join(&oid[..2]).join(&oid[2..]);
std::fs::remove_file(&obj).unwrap();
std::fs::write(&obj, b"garbage").unwrap();
}
/// The regression: closing over a corrupt store used to return a fake 404
/// (or panic deeper in). It must reach the caller as a git 500, and the
/// handler must return rather than unwind.
#[sqlx::test]
async fn close_issue_on_corrupt_store_returns_git_error_not_panic(pool: PgPool) {
let owner = "did:key:zISSUECORRUPTCCCCCCCCCCCCCCCCCCCCCCCCC";
let dir = TempDir::new().unwrap();
let state = state_with_repos_dir(&pool, dir.path()).await;
state
.db
.create_repo(&seed_repo(owner, "corrupt-close"))
.await
.expect("seed repo");
let (repo_path, issue_id) = bare_repo_with_issue(dir.path(), owner, "corrupt-close");
corrupt_issue_object(&repo_path, &issue_id);
let err = close_issue(
State(state),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "corrupt-close".to_string(), issue_id)),
)
.await
.expect_err("a corrupt store must error, not panic or 404");
assert_eq!(
err.into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a read failure is a git 500, not a fake not-found"
);
}
/// get on a corrupt store: 500, not the 404 the fold used to produce.
#[sqlx::test]
async fn get_issue_on_corrupt_store_returns_500_not_404(pool: PgPool) {
let owner = "did:key:zISSUECORRUPTGETDDDDDDDDDDDDDDDDDDDDDDD";
let dir = TempDir::new().unwrap();
let state = state_with_repos_dir(&pool, dir.path()).await;
state
.db
.create_repo(&seed_repo(owner, "corrupt-get"))
.await
.expect("seed repo");
let (repo_path, issue_id) = bare_repo_with_issue(dir.path(), owner, "corrupt-get");
corrupt_issue_object(&repo_path, &issue_id);
let err = get_issue(
State(state),
Path((owner.to_string(), "corrupt-get".to_string(), issue_id)),
None,
)
.await
.expect_err("a corrupt store must error, not 404");
assert_eq!(
err.into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
/// list on a corrupt store must fail outright; a 200 that silently drops
/// the unreadable issue is the other half of the fold.
#[sqlx::test]
async fn list_issues_on_corrupt_store_returns_git_error(pool: PgPool) {
let owner = "did:key:zISSUECORRUPTLISTEEEEEEEEEEEEEEEEEEEEEE";
let dir = TempDir::new().unwrap();
let state = state_with_repos_dir(&pool, dir.path()).await;
state
.db
.create_repo(&seed_repo(owner, "corrupt-list"))
.await
.expect("seed repo");
let (repo_path, issue_id) = bare_repo_with_issue(dir.path(), owner, "corrupt-list");
corrupt_issue_object(&repo_path, &issue_id);
let err = list_issues(
State(state),
Path((owner.to_string(), "corrupt-list".to_string())),
None,
)
.await
.expect_err("a corrupt store must error, not under-report");
assert_eq!(
err.into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
/// Must-not direction: a genuinely absent issue still gets the 404.
#[sqlx::test]
async fn get_issue_on_absent_ref_still_returns_404(pool: PgPool) {
let owner = "did:key:zISSUEABSENTFFFFFFFFFFFFFFFFFFFFFFFFFFF";
let dir = TempDir::new().unwrap();
let state = state_with_repos_dir(&pool, dir.path()).await;
state
.db
.create_repo(&seed_repo(owner, "absent-get"))
.await
.expect("seed repo");
bare_repo_with_issue(dir.path(), owner, "absent-get");
let err = get_issue(
State(state),
Path((
owner.to_string(),
"absent-get".to_string(),
"ffffffff-0000-0000-0000-000000000000".to_string(),
)),
None,
)
.await
.expect_err("a missing issue is still not-found");
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
}
}
+10 -3
View File
@@ -68,9 +68,16 @@ 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 {
// 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)
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
+14 -115
View File
@@ -1861,21 +1861,14 @@ fn owner_push_rejection(
enforce: bool,
record: &crate::db::RepoRecord,
caller: Option<&str>,
verified: Option<&crate::auth::VerifiedUcan>,
) -> Option<AppError> {
if !enforce {
return None;
}
match caller {
Some(did) if caller_authorized_to_push(record, did, verified) => None,
// One message for every refusal. It must not say whether a delegation was
// presented, was expired, or named another repository: varying it would turn
// the denial into an oracle for which capabilities exist. It only has to be
// TRUE in all of those cases, which the previous owner-only wording no
// longer was once a delegation could authorize a push.
Some(did) if caller_authorized_to_push(record, did) => None,
_ => Some(AppError::Forbidden(
"push rejected — you must be the repo owner, or hold a valid \
owner-issued git/push delegation for this repository \
"push rejected — only the repo owner may push to this repository \
(GITLAWB_ENFORCE_OWNER_PUSH is enabled)"
.into(),
)),
@@ -2015,10 +2008,6 @@ pub async fn git_receive_pack(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Extension(auth): Extension<AuthenticatedDid>,
// `X-Ucan` is optional, so the extension may be absent: axum extracts that as
// `None` rather than rejecting the request. Present only when the middleware
// validated a chain.
verified: Option<Extension<crate::auth::VerifiedUcan>>,
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
headers: axum::http::HeaderMap,
body: Bytes,
@@ -2066,7 +2055,6 @@ pub async fn git_receive_pack(
state.config.enforce_owner_push,
&record,
Some(auth.0.as_str()),
verified.as_ref().map(|Extension(v)| v),
) {
tracing::warn!(
repo = %name,
@@ -2078,17 +2066,9 @@ pub async fn git_receive_pack(
}
// ── Branch protection check ──────────────────────────────────────────
// Uses the same verified identity as the owner-push gate above, but a STRICTER
// predicate: owner-only, deliberately not `caller_authorized_to_push`.
//
// A delegate can therefore clear the gate above and still be refused here. That
// is the intended policy, not an oversight: a protected branch is the owner's
// explicit marker that even routine writes should stop, so a `git/push`
// delegation must not silently override it. Widening this to accept a
// delegation would make every existing protection weaker the moment the owner
// issues any capability.
//
// `delegated_push_is_still_refused_on_a_protected_branch` pins this.
// Uses the same verified identity as the owner-push gate above. (When that
// gate is enabled a non-owner never reaches here; this still applies when it
// is off, gating only the branches an owner has explicitly protected.)
for update in &ref_updates {
// Strip refs/heads/ prefix to get plain branch name
let branch = update
@@ -3643,7 +3623,7 @@ mod tests {
#[test]
fn enforced_allows_owner_full_did() {
let repo = repo_owned_by(OWNER_DID);
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID), None).is_none());
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID)).is_none());
}
#[test]
@@ -3651,92 +3631,36 @@ mod tests {
// Owners are accepted in bare-multibase form, matching the rest of the
// codebase's owner comparisons.
let repo = repo_owned_by(OWNER_DID);
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT), None).is_none());
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT)).is_none());
}
#[test]
fn enforced_rejects_non_owner_with_forbidden() {
let repo = repo_owned_by(OWNER_DID);
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID)));
}
#[test]
fn enforced_rejects_missing_did_with_forbidden() {
// Fail closed: an absent authenticated identity is rejected, not allowed.
let repo = repo_owned_by(OWNER_DID);
assert_forbidden(owner_push_rejection(true, &repo, None, None));
assert_forbidden(owner_push_rejection(true, &repo, None));
}
#[test]
fn disabled_allows_non_owner_and_missing_did() {
// Flag off → legacy behavior: authentication-only, no owner gate.
let repo = repo_owned_by(OWNER_DID);
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID), None).is_none());
assert!(owner_push_rejection(false, &repo, None, None).is_none());
}
/// Build a VerifiedUcan whose chain roots at `root_did` and which carries
/// `git/push` for `repo`. The token's own issuer and audience do not matter
/// here: the middleware has already bound them before this gate is reached.
fn push_delegation(root_did: &str, repo: &crate::db::RepoRecord) -> crate::auth::VerifiedUcan {
let agent = gitlawb_core::identity::Keypair::generate();
let node = gitlawb_core::identity::Keypair::generate();
let ucan = gitlawb_core::ucan::Ucan::issue(
&agent,
node.did(),
vec![gitlawb_core::ucan::Capability::new(
format!("gitlawb://repos/{}/{}", repo.owner_did, repo.name),
gitlawb_core::ucan::caps::GIT_PUSH,
)],
// Finite: a write capability that never lapses is refused, since there
// is no revocation path to withdraw a leaked one.
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
)
.expect("issue delegation");
crate::auth::VerifiedUcan {
ucan,
root: root_did.parse().expect("root DID must parse"),
}
}
#[test]
fn enforced_allows_a_non_owner_holding_an_owner_rooted_push_capability() {
// The regression owner-only push introduced: a CI or delegated key with a
// valid capability was refused exactly like a stranger.
let repo = repo_owned_by(OWNER_DID);
let verified = push_delegation(OWNER_DID, &repo);
assert!(
owner_push_rejection(true, &repo, Some(STRANGER_DID), Some(&verified)).is_none(),
"a delegation rooted at the owner must let a non-owner push"
);
}
#[test]
fn enforced_rejects_a_delegation_rooted_at_a_stranger() {
// Anchoring is the whole point: a chain nobody the repo trusts started
// grants nothing, even carrying a perfectly formed push capability.
let repo = repo_owned_by(OWNER_DID);
let verified = push_delegation(STRANGER_DID, &repo);
assert_forbidden(owner_push_rejection(
true,
&repo,
Some(STRANGER_DID),
Some(&verified),
));
}
#[test]
fn enforced_still_rejects_a_non_owner_with_no_capability() {
let repo = repo_owned_by(OWNER_DID);
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID)).is_none());
assert!(owner_push_rejection(false, &repo, None).is_none());
}
#[test]
fn caller_authorized_to_push_is_owner_only_in_phase_1() {
let repo = repo_owned_by(OWNER_DID);
assert!(caller_authorized_to_push(&repo, OWNER_DID, None));
assert!(caller_authorized_to_push(&repo, OWNER_SHORT, None));
assert!(!caller_authorized_to_push(&repo, STRANGER_DID, None));
assert!(caller_authorized_to_push(&repo, OWNER_DID));
assert!(caller_authorized_to_push(&repo, OWNER_SHORT));
assert!(!caller_authorized_to_push(&repo, STRANGER_DID));
}
// ── fork_withheld_blocks (#98 path-scoped fork gate) ──
@@ -6016,7 +5940,6 @@ mod tests {
State(state.clone()),
Path(("z6rp4wr".to_string(), "rp4".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(capped)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6035,7 +5958,6 @@ mod tests {
State(state.clone()),
Path(("z6rp4wr".to_string(), "rp4".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(other)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6142,7 +6064,6 @@ mod tests {
State(state_for_task),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6230,7 +6151,6 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6388,7 +6308,6 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6MkDisconnectWriteLockProofDidAAAAAAAA".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.81:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -6543,7 +6462,6 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6MkPushSuccessReleaseProofDidAAAAAAAA".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.83:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -6634,7 +6552,6 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6653,7 +6570,6 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.72:5000".parse().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -7302,7 +7218,6 @@ mod tests {
State(state),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
None,
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
ref_update_body(new_sha),
@@ -7397,7 +7312,6 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6f4fast".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -7458,7 +7372,6 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6f4park".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
ref_update_body("2222222222222222222222222222222222222222"),
@@ -8684,7 +8597,6 @@ mod tests {
State(state.clone()),
Path(("z6f3repo".to_string(), "r1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -8713,7 +8625,6 @@ mod tests {
State(state_b),
Path(("z6f3repo".to_string(), "r1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.82:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -8810,7 +8721,6 @@ mod tests {
State(st),
Path(("z6f3clean".to_string(), "c1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -8904,7 +8814,6 @@ mod tests {
State(state.clone()),
Path(("z6f3dos".to_string(), "d1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -8932,7 +8841,6 @@ mod tests {
State(state_b),
Path(("z6f3dos".to_string(), "d1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.72:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -9117,7 +9025,6 @@ mod tests {
State(st),
Path(("z6u2key".to_string(), "k1".to_string())),
Extension(crate::auth::AuthenticatedDid(did)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9191,7 +9098,6 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6ovflow".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
ref_update_body("1111111111111111111111111111111111111111"),
@@ -9246,7 +9152,6 @@ mod tests {
State(st),
Path(("z6u1cap".to_string(), "c1".to_string())),
Extension(crate::auth::AuthenticatedDid(did)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9352,7 +9257,6 @@ mod tests {
State(st),
Path(("z6u1two".to_string(), repo.to_string())),
Extension(crate::auth::AuthenticatedDid(did)),
None,
crate::rate_limit::PeerAddr(Some(src)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9443,7 +9347,6 @@ mod tests {
State(st),
Path((owner.to_string(), repo.to_string())),
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
None,
crate::rate_limit::PeerAddr(Some(edge)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9537,7 +9440,6 @@ mod tests {
State(st),
Path(("z6f1key".to_string(), repo.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9598,7 +9500,6 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6f1none".to_string(),
)),
None,
crate::rate_limit::PeerAddr(None),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9643,7 +9544,6 @@ mod tests {
State(state.clone()),
Path(("z6f1seq".to_string(), "s1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(src)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -10154,7 +10054,6 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
+14 -514
View File
@@ -17,74 +17,16 @@ 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`.
///
/// 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 {
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
/// intentionally a distinct, intent-named gate rather than a bare owner check so
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
/// call sites.
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
crate::api::did_matches(caller, &record.owner_did)
|| 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::{
@@ -328,7 +270,7 @@ fn validate_ucan_chain(
token: &str,
expected_aud: &Did,
signer_did: &Did,
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let ucan = Ucan::decode(token).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
@@ -356,14 +298,14 @@ fn validate_ucan_chain(
)
})?;
let root = ucan.verify_chain().map_err(|e| {
ucan.verify_chain().map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
Ok(VerifiedUcan { ucan, root })
Ok(())
}
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
@@ -416,18 +358,11 @@ pub async fn require_ucan_chain(
}
};
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
Ok(v) => v,
Err((status, body)) => return (status, body).into_response(),
};
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
return (status, body).into_response();
}
tracing::debug!(did = %signer_did, 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);
tracing::debug!(did = %signer_did, "UCAN chain validated");
next.run(request).await
}
@@ -463,37 +398,6 @@ 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,
@@ -733,407 +637,3 @@ 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");
}
}
}
+240 -27
View File
@@ -61,6 +61,119 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
Ok(())
}
/// Issue ids are UUIDs; refs under `refs/gitlawb/issues/` only ever carry
/// `[0-9a-zA-Z_-]` names. Anything else is not a value we pass to git.
fn issue_ref_name_is_safe(ref_name: &str) -> bool {
let Some(id) = ref_name.strip_prefix("refs/gitlawb/issues/") else {
return false;
};
!id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// Resolve a ref name without putting it on a command line: enumerate refs
/// (constant argv) and match in memory. A nonzero exit propagates, so "cannot
/// determine" is an error rather than a quiet `false`.
fn ref_name_resolves(repo_path: &Path, ref_name: &str) -> Result<bool> {
// allow-unbounded-git: failure-path existence recheck, module convention.
let out = Command::new("git")
.args(["for-each-ref", "--format=%(refname)"])
.current_dir(repo_path)
.output()
.context("failed to run git for-each-ref")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("git for-each-ref failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(stdout.lines().any(|line| line.trim() == ref_name))
}
/// Read the blob behind an issue ref.
///
/// `Ok(None)` means the ref itself is absent. A ref that resolves but whose
/// object cannot be read (corrupt object, unreadable store, a mid-read gc) is
/// an error, not an absence (#426).
///
/// The ref name reaches git on stdin, never argv: `cat-file --batch` answers
/// `<oid> <type> <size>\n<body>\n` for a readable object and
/// `<spec> missing\n` for an absent ref or an unreadable object alike, so the
/// `missing` case is re-resolved by name enumeration to tell them apart.
fn read_issue_blob(repo_path: &Path, ref_name: &str) -> Result<Option<String>> {
if !issue_ref_name_is_safe(ref_name) {
anyhow::bail!("refusing issue ref outside the issues namespace: {ref_name}");
}
// allow-unbounded-git: this module's spawns predate the bounded runner and
// its callers run inside issue handlers that already hold the repo guard.
let mut child = Command::new("git")
.args(["cat-file", "--batch"])
.current_dir(repo_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn git cat-file --batch")?;
use std::io::Write;
// Reap the child even when the write fails so a stdin error cannot drop an
// unwaited Child and leak a zombie (#53).
let write_result = match child.stdin.take() {
Some(mut stdin) => stdin
.write_all(ref_name.as_bytes())
.and_then(|()| stdin.write_all(b"\n")),
None => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"git cat-file --batch stdin unavailable",
)),
};
let output = child
.wait_with_output()
.context("git cat-file --batch failed")?;
write_result.context("failed to write ref name to git cat-file stdin")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git cat-file --batch failed: {}", stderr.trim());
}
// "<spec> missing\n" covers an absent ref and an unreadable object; only a
// ref that still resolves is the error case.
let missing = format!("{ref_name} missing\n");
if output.stdout.as_slice() == missing.as_bytes() {
return match ref_name_resolves(repo_path, ref_name)? {
true => anyhow::bail!("issue ref resolves but its object is unreadable: {ref_name}"),
false => Ok(None),
};
}
// Header: "<oid> <type> <size>\n" then exactly <size> bytes plus a newline.
let header_end = output
.stdout
.iter()
.position(|b| *b == b'\n')
.context("unexpected git cat-file --batch output")?;
let header = String::from_utf8_lossy(&output.stdout[..header_end]);
let mut parts = header.split(' ');
parts.next();
let obj_type = parts.next().unwrap_or("");
if obj_type != "blob" {
anyhow::bail!("issue ref {ref_name} resolves to a non-blob object");
}
let size: usize = parts
.next()
.and_then(|s| s.parse().ok())
.context("unexpected git cat-file --batch header")?;
let body = &output.stdout[header_end + 1..];
anyhow::ensure!(
body.len() >= size,
"truncated git cat-file --batch body for {ref_name}"
);
Ok(Some(String::from_utf8_lossy(&body[..size]).to_string()))
}
/// 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/
@@ -74,9 +187,11 @@ pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
.output()
.context("failed to run git for-each-ref")?;
// for-each-ref exits 0 with empty output when no refs match, so a nonzero
// exit is a real enumeration failure, not "no issues yet".
if !list_output.status.success() {
// No issues yet
return Ok(vec![]);
let stderr = String::from_utf8_lossy(&list_output.stderr);
anyhow::bail!("git for-each-ref failed: {}", stderr.trim());
}
let refs_str = String::from_utf8_lossy(&list_output.stdout);
@@ -88,16 +203,12 @@ pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
continue;
}
// Read the blob content
let cat_output = Command::new("git")
.args(["cat-file", "blob", ref_name])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file")?;
if cat_output.status.success() {
let content = String::from_utf8_lossy(&cat_output.stdout).to_string();
issues.push(content);
// Read the blob content. A ref that vanished between for-each-ref and
// here is skipped; a ref that resolves but fails to read propagates so
// a degraded object store errors the listing instead of under-reporting.
match read_issue_blob(repo_path, ref_name)? {
Some(content) => issues.push(content),
None => continue,
}
}
@@ -127,8 +238,12 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
.output()
.context("failed to run git for-each-ref")?;
// for-each-ref exits 0 with empty output when nothing matches, so a
// nonzero exit is a real enumeration failure (e.g. not a repo), not
// "issue not found".
if !list.status.success() {
return Ok(None);
let stderr = String::from_utf8_lossy(&list.stderr);
anyhow::bail!("git for-each-ref failed: {}", stderr.trim());
}
let output = String::from_utf8_lossy(&list.stdout);
@@ -161,8 +276,12 @@ pub fn close_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
None => return Ok(None),
};
let raw = get_issue(repo_path, &full_id)?
.expect("ref existed in resolve but not in get — should be impossible");
// A ref that resolved and is now absent was deleted between the two calls;
// any other read failure propagates as an error rather than panic (#426).
let raw = match get_issue(repo_path, &full_id)? {
Some(raw) => raw,
None => return Ok(None),
};
let mut issue: serde_json::Value =
serde_json::from_str(&raw).context("invalid issue JSON in git ref")?;
@@ -181,18 +300,7 @@ pub fn get_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
};
let ref_name = format!("refs/gitlawb/issues/{full_id}");
let cat_output = Command::new("git")
.args(["cat-file", "blob", &ref_name])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file")?;
if !cat_output.status.success() {
return Ok(None);
}
let content = String::from_utf8_lossy(&cat_output.stdout).to_string();
Ok(Some(content))
read_issue_blob(repo_path, &ref_name)
}
#[cfg(test)]
@@ -294,4 +402,109 @@ mod tests {
let v: serde_json::Value = serde_json::from_str(&updated).unwrap();
assert_eq!(v["status"], "closed");
}
/// Overwrite the loose object behind an issue ref with garbage, so
/// `cat-file` fails while `rev-parse` still resolves the ref (#426).
fn corrupt_issue_object(dir: &TempDir, full_id: &str) {
let oid_out = Command::new("git")
.args(["rev-parse", &format!("refs/gitlawb/issues/{full_id}")])
.current_dir(dir.path())
.output()
.unwrap();
let oid = String::from_utf8(oid_out.stdout)
.unwrap()
.trim()
.to_string();
let obj = dir
.path()
.join(".git/objects")
.join(&oid[..2])
.join(&oid[2..]);
std::fs::remove_file(&obj).unwrap();
std::fs::write(&obj, b"garbage").unwrap();
}
#[test]
fn test_get_and_close_missing_issue_return_none() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
assert_eq!(get_issue(dir.path(), "nosuch00").unwrap(), None);
assert_eq!(close_issue(dir.path(), "nosuch00").unwrap(), None);
}
// #426: a directory that is not a repo makes `cat-file --batch` exit
// nonzero; that failure is an error, not a quiet None.
#[test]
fn test_issue_read_on_non_repo_errors_instead_of_none() {
let dir = TempDir::new().unwrap();
let not_a_repo = dir.path().join("not-a-repo");
std::fs::create_dir_all(&not_a_repo).unwrap();
assert!(read_issue_blob(&not_a_repo, "refs/gitlawb/issues/deadbeef").is_err());
}
// #426: the ref name is constrained to the issues namespace and a safe
// charset before it is fed to git, even on stdin (a newline would smuggle
// a second `--batch` spec).
#[test]
fn test_issue_read_rejects_ref_outside_issues_namespace() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
assert!(read_issue_blob(dir.path(), "refs/heads/main").is_err());
assert!(read_issue_blob(dir.path(), "refs/gitlawb/issues/../x").is_err());
}
// #426: a failed for-each-ref enumeration is an error, not an empty list.
#[test]
fn test_list_issues_on_non_repo_errors_instead_of_empty() {
let dir = TempDir::new().unwrap();
let not_a_repo = dir.path().join("not-a-repo");
std::fs::create_dir_all(&not_a_repo).unwrap();
assert!(list_issues(&not_a_repo).is_err());
}
// #426: the same enumeration failure inside resolve_issue_id must reach
// get_issue/close_issue as an error, not a "not found" None.
#[test]
fn test_get_and_close_on_non_repo_error_instead_of_none() {
let dir = TempDir::new().unwrap();
let not_a_repo = dir.path().join("not-a-repo");
std::fs::create_dir_all(&not_a_repo).unwrap();
assert!(get_issue(&not_a_repo, "nosuch00").is_err());
assert!(close_issue(&not_a_repo, "nosuch00").is_err());
}
// #426: a ref that resolves but whose object is corrupt is a read error,
// not an absent issue. `close_issue` used to `expect()` on that None and
// panic inside a request handler holding the write guard.
#[test]
fn test_corrupt_issue_object_errors_instead_of_panicking() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "bad00000-0000-0000-0000-000000000000";
create_issue(dir.path(), full_id, r#"{"status":"open"}"#).unwrap();
corrupt_issue_object(&dir, full_id);
assert!(get_issue(dir.path(), full_id).is_err());
assert!(close_issue(dir.path(), full_id).is_err());
assert!(list_issues(dir.path()).is_err());
}
#[test]
fn test_list_issues_returns_all() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(
dir.path(),
"aaa00000-0000-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
create_issue(
dir.path(),
"bbb00000-0000-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
assert_eq!(list_issues(dir.path()).unwrap().len(), 2);
}
}
-450
View File
@@ -2390,456 +2390,6 @@ 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
+7 -8
View File
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -73,18 +73,17 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
println!("gl doctor — checking your gitlawb setup");
println!();
// 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 dir = args.dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let mut checks = Vec::new();
let mut all_ok = true;
// ── 1. Identity ───────────────────────────────────────────────────────
let pem_path = crate::identity::key_path_for(args_dir.as_deref())?;
let pem_path = dir.join("identity.pem");
if pem_path.exists() {
match std::fs::read_to_string(&pem_path)
.ok()
+2 -2
View File
@@ -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 {
gitlawb_core::sanitize::strip_terminal_controls(s)
.chars()
s.chars()
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
.take(200)
.collect()
}
+20 -239
View File
@@ -9,8 +9,7 @@ use std::path::{Path, PathBuf};
pub enum IdentityCmd {
/// Generate a new Ed25519 keypair and DID
New {
/// Output directory for key files
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Output directory for key files (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Overwrite existing keys if present
@@ -64,51 +63,16 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
}
}
/// 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> {
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
if let Some(d) = override_dir {
return Ok(d);
}
// `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}"))
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".gitlawb"))
}
/// 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 key_path(dir: &Path) -> PathBuf {
dir.join("identity.pem")
}
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
@@ -117,14 +81,15 @@ 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 path = key_path_for(dir)?;
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 pem = fs::read_to_string(&path).with_context(|| {
format!(
"no identity found at {}\nRun `gl identity new` to create one",
@@ -143,11 +108,8 @@ async fn cmd_new_with_reader(
force: bool,
reader: &mut impl std::io::BufRead,
) -> Result<()> {
let path = key_path_for(dir.as_deref())?;
let dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let dir = gitlawb_dir(dir)?;
let path = key_path(&dir);
if path.exists() {
if force {
@@ -221,7 +183,8 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
}
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
let src = key_path_for(dir.as_deref())?;
let base = gitlawb_dir(dir)?;
let src = key_path(&base);
let pem = fs::read_to_string(&src).with_context(|| {
format!(
@@ -276,11 +239,8 @@ 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 dest = key_path_for(dir.as_deref())?;
let base = dest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let base = gitlawb_dir(dir)?;
let dest = key_path(&base);
if dest.exists() {
if force {
@@ -549,182 +509,3 @@ 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")
);
}
}
+13 -7
View File
@@ -22,7 +22,7 @@ pub struct InitArgs {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -101,7 +101,10 @@ 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 = crate::identity::gitlawb_dir(args.dir.clone())?;
let ucan_dir = args
.dir
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
std::fs::create_dir_all(&ucan_dir)?;
let record = json!({
"ucan": ucan,
@@ -218,15 +221,18 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
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("."));
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 keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
let path = base.join("identity.pem");
#[cfg(unix)]
{
+1 -1
View File
@@ -24,7 +24,7 @@ pub enum IpfsCmd {
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
+33 -415
View File
@@ -283,7 +283,7 @@ fn tool_definitions() -> Value {
},
{
"name": "ucan_show",
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
"description": "Show the saved bootstrap UCAN token for this agent.",
"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. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
"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", "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." }
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
}
}
},
{
"name": "ucan_verify",
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
"inputSchema": {
"type": "object",
"required": ["token"],
@@ -583,17 +583,6 @@ 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 ─────────────────────────────────────────────────────
{
@@ -770,45 +759,15 @@ async fn call_tool(
]))?),
"ucan_show" => {
// 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 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())
}
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" => {
@@ -1166,35 +1125,14 @@ 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}"))?;
// 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 exp = args
.get("expiry_hours")
.and_then(|v| v.as_i64())
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
let ucan = gitlawb_core::ucan::Ucan::issue(
&kp,
@@ -1217,26 +1155,23 @@ 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")?;
// 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())?)
}
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();
"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!({
"owner": imported.owner,
"repo": imported.repo,
"root_issuer": imported.root,
"expires": imported.expires,
"path": imported.path.display().to_string(),
"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,
}))?)
}
@@ -1629,7 +1564,6 @@ mod tests {
.collect();
assert!(names.contains(&"ucan_delegate"));
assert!(names.contains(&"ucan_verify"));
assert!(names.contains(&"ucan_import"));
}
#[test]
@@ -1796,38 +1730,6 @@ 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();
@@ -1856,204 +1758,6 @@ 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]
@@ -2323,95 +2027,9 @@ mod tests {
}
#[test]
fn test_tool_count_matches_the_shipped_list() {
fn test_tool_count_is_42() {
let tools = tool_definitions();
let count = tools.as_array().unwrap().len();
// 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}");
assert_eq!(count, 40, "expected 40 tools, got {count}");
}
}
+10 -2
View File
@@ -168,8 +168,16 @@ 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 = crate::identity::key_path_for(dir.as_deref())?;
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
@@ -182,7 +190,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
}
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
let path = crate::identity::key_path_for(dir.as_deref())?;
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+1 -1
View File
@@ -22,7 +22,7 @@ pub enum NodeCmd {
Status {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
+6 -4
View File
@@ -328,10 +328,12 @@ pub async fn cmd_unstake(
// ── Helpers ─────────────────────────────────────────────────────────────────
fn load_did(dir: Option<PathBuf>) -> Result<String> {
// 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 base = dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let path = base.join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+20 -46
View File
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -39,26 +39,19 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!("and create your first repository.");
println!();
// 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())?;
let dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
// ── Step 1: Identity ──────────────────────────────────────────────────
println!("── Step 1: Identity ─────────────────────────────────────────────────");
println!();
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
let pem_path = dir.join("identity.pem");
let keypair = if pem_path.exists() {
// `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()) {
match load_keypair_from_dir(Some(&dir)) {
Ok(kp) => {
let did = kp.did();
println!(" ✓ Identity already exists");
@@ -66,15 +59,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!();
kp
}
Err(e) => {
println!(" Identity at {} is unreadable: {e}", pem_path.display());
println!(" Regenerating — the previous key cannot be recovered.");
generate_identity(&dir, &pem_path)?
Err(_) => {
println!(" Identity file exists but is unreadable. Regenerating...");
generate_identity(&dir)?
}
}
} else {
println!(" No identity found. Generating a new Ed25519 keypair...");
generate_identity(&dir, &pem_path)?
generate_identity(&dir)?
};
let did = keypair.did().to_string();
@@ -107,14 +99,7 @@ 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("");
// 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 {
if !ucan.is_empty() {
std::fs::create_dir_all(&dir)?;
let record = json!({
"ucan": ucan,
@@ -123,20 +108,11 @@ 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}");
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!(" UCAN saved to {}", ucan_path.display());
println!();
}
Ok(resp) => {
@@ -250,24 +226,22 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────
fn generate_identity(
dir: &PathBuf,
path: &std::path::Path,
) -> Result<gitlawb_core::identity::Keypair> {
fn generate_identity(dir: &PathBuf) -> 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();
+17 -77
View File
@@ -1,8 +1,7 @@
//! `gl register` — register this agent identity with a gitlawb node.
//!
//! Sends a signed POST /api/register request and saves the returned bootstrap
//! UCAN token as `ucan.json` beside the identity key, where the other commands
//! look for it.
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
use anyhow::{Context, Result};
use clap::Args;
@@ -30,7 +29,7 @@ pub struct RegisterArgs {
#[arg(long)]
pub model: Option<String>,
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -70,20 +69,17 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
// Save bootstrap UCAN
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
let saved_to = if ucan.is_empty() {
None
} else {
let path = ucan_path(args.dir.as_deref())?;
if !ucan.is_empty() {
let ucan_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(&path, serde_json::to_string_pretty(&record)?)?;
tracing::debug!("saved UCAN to {}", path.display());
Some(path)
};
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
tracing::debug!("saved UCAN to {}", ucan_path.display());
}
let trust = payload
.get("trust_score")
@@ -103,35 +99,21 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
println!(" Trust score: {trust:.2}");
println!(" UCAN expires: {expires}");
println!();
// 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.");
}
}
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
println!(" You are now a verified agent on the gitlawb network.");
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 = 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()))?;
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)?;
Ok(base.join("ucan.json"))
}
@@ -178,48 +160,6 @@ 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();
+39 -1644
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct WhoamiArgs {
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
/// Identity directory (default: ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Node URL to query for registration info
+11 -79
View File
@@ -143,11 +143,9 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
## Owner-only push
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
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
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
This is on by default, and the default is the point. The node authenticates every
@@ -157,20 +155,6 @@ 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
@@ -184,68 +168,16 @@ 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.
- **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.
### Caution: delegated and CI keys are non-owners
### Delegating push to a CI agent
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.
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.
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.
---