Files
buzz/crates/buzz-relay/src/api/git/transport.rs
Will Pfleger 8af2d91f37 refactor(relay): bind HTTP tenants through one shadow-aware helper (#8062)
Adds `bind_tenant()`, a single helper that resolves the community from
the request `Host` and records the NIP-FI shadow verdict when binding
fails. Ten protected HTTP handlers previously duplicated the Host lookup
and called `observe_unbound` in their own failure branches, so each one
had to remember the observer call. Moving those ten handlers onto the
helper makes recording the verdict part of binding itself, and
`observe_unbound` becomes private to the shadow module. Each caller
keeps its existing rejection response, so Off/Enforce behavior is
unchanged.

`bind_community()` stays directly callable. Routes exempt from NIP-FI
(invite claim, webhooks, NIP-05 and NIP-11 metadata) keep using it, and
the WebSocket and audio upgrades record their own verdicts before they
bind. A new protected HTTP handler should bind through `bind_tenant()`.

🤖 Authored by Duncan (agent) on behalf of Will.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-02 19:16:28 -04:00

5477 lines
232 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Smart HTTP git transport for Buzz.
//!
//! Three endpoints implement the git Smart HTTP protocol:
//! - `GET /git/{owner}/{repo}/info/refs?service={svc}` — ref advertisement
//! - `POST /git/{owner}/{repo}/git-upload-pack` — clone/fetch
//! - `POST /git/{owner}/{repo}/git-receive-pack` — push
//!
//! Auth: NIP-98 on all routes (clone + push). No public repos for v1.
//! Transport: shells out to `git --stateless-rpc` with `env_clear()`.
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
body::Body,
extract::{Path as AxumPath, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use base64::Engine;
use hex;
use serde::Deserialize;
use tokio::process::Command;
use tower_http::limit::RequestBodyLimitLayer;
use tracing::{error, info, warn};
use super::binding::{resolve_repo_binding, RepoBinding};
use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits};
use super::hook::install_hook;
use super::hydrate::{
hydrate_for_read, hydrate_for_write, load_manifest_for_read, HydrateError, HydratedRepo,
HydrationOptions,
};
use super::manifest_event::{build_ref_state_event, RefStateInputs};
use crate::state::AppState;
use buzz_core::TenantContext;
/// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`).
const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// Timeout for pack operations (upload-pack, receive-pack) — large repos need time.
const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// Maximum buffered response bytes for receive-pack status output.
///
/// A receive-pack response is protocol status, not repository contents. One
/// MiB is generous and prevents a malformed subprocess path from turning a
/// push into an arbitrary in-memory response buffer.
const RECEIVE_PACK_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
/// Maximum ref advertisement output after manifest ref-count validation.
const INFO_REFS_MAX_OUTPUT_BYTES: u64 = 4 * 1024 * 1024;
/// Maximum *decoded* upload-pack request body (want/have negotiation).
///
/// Bounds the output of [`decode_git_request_body`] so a small gzip bomb
/// cannot bypass the compressed-body `RequestBodyLimitLayer`. Negotiation
/// bodies are pkt-lines of wants/haves (~50 bytes per ref); even a repo
/// with a million refs stays far under this.
const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024;
/// NIP-98 auth extractor for git routes.
///
/// Validates the `Authorization: Nostr <base64>` header before the request body
/// is read. Same pattern as `UploadContext` in media.rs: auth is deferred out of
/// the extractor so NIP-FI admission can map failures to canonical denial bytes.
///
/// Authorization model: reads (ref advertisement, upload-pack) require the
/// caller's *current* active membership in the repo's bound channel — see
/// [`authorize_git_read`] (SEC-005). Push authorization is additionally
/// handled by the pre-receive hook (calls back to the internal policy endpoint
/// which checks channel role + protection rules from kind:30617).
pub struct GitAuth {
/// The authenticated user's public key, extracted from the NIP-98 event.
pub pubkey: nostr::PublicKey,
/// Server-resolved tenant bound from the request Host before auth checks.
pub tenant: TenantContext,
}
impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
type Rejection = Response;
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let method = parts.method.as_str();
// Off mode: parse and validate the Authorization header BEFORE tenant
// lookup. [FI-INV-15] — Off mode preserves pre-NIP-FI error precedence:
// missing credentials → 401 + WWW-Authenticate challenge;
// malformed credentials (bad base64, bad UTF-8) → 401 without challenge;
// regardless of whether the Host resolves to a known community. No
// database work for syntactically bad requests in Off mode.
//
// Enforce: the header syntax is validated
// inside the NIP-FI admission closure below, where proof failures are
// mapped to NIP-FI denial bytes. (DenyProtected returns 503 at the
// start of admission without running the closure.) Tenant lookup still happens before
// admission (immediately after this block) because the signed `u` tag
// must be verified against the tenant-bound host, not a process-global
// domain.
let mode = state.config.nip_fi.mode;
if !mode.restricts() {
if let Err(rejection) = parse_git_auth_header(&parts.headers, method) {
// Off returns `rejection` as is; shadow also records the
// enforce verdict for the failed proof, still with no DB work.
crate::nip_fi_http::observe_failed_proof(state, &parts.headers);
return Err(rejection);
}
}
// Row zero for Git HTTP: bind the request Host to a server-resolved
// tenant before URL verification. We still do not trust forwarded
// headers; the signed `u` tag is checked against the host that resolved
// through the authoritative communities table, not a deployment-global
// `config.relay_url` and not any client-supplied community value.
let tenant = crate::nip_fi_shadow::bind_tenant(state, &parts.headers)
.await
.ok_or_else(|| (StatusCode::NOT_FOUND, "repository not found").into_response())?;
let expected_url = git_expected_url(
&state.config.relay_url,
&tenant,
parts
.uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or(parts.uri.path()),
)
.ok_or_else(|| (StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response())?;
// NIP-FI admission: the NIP-98 extraction closure runs inside
// `admit_nip_fi_http_on_state` so all proof failures (missing header,
// invalid base64, bad signature) are mapped to NIP-FI denial bytes in
// Enforce mode, and cardinality is enforced uniformly. Off mode
// preserves legacy Git 401 responses per [FI-INV-15]; Off-mode header
// syntax was already validated above so the closure cannot fail on the
// syntax cases.
// [FI-TRACE-AUTHORITY-UNIFORM, FI-TRACE-DENIAL-ORACLE]
//
// Skip HTTP method check for git routes.
//
// Git's credential helper signs with `method=GET` (the initial /info/refs request)
// then reuses the token for POST (pack data). Method binding can't work here.
//
// Security is provided by: service-binding in the URL (clone vs push scoped),
// ±60s timestamp, and the pre-receive hook for push authorization.
// We pass the method from the event itself so verify_nip98_event always accepts.
//
// body=None: can't buffer streaming pack data to verify payload hash.
// Token is time-bounded (±60s) and URL-locked — acceptable trade-off.
let headers_clone = parts.headers.clone();
let method_str = method.to_owned();
let admission = crate::nip_fi_http::admit_nip_fi_http_on_state(
state,
&parts.headers,
move || -> Result<crate::nip_fi_http::Nip98Proof<(nostr::Event, u64)>, Response> {
// In Off mode `parse_git_auth_header` already ran above and
// succeeded, so re-parsing here is purely for the return value.
// In Enforce mode it runs for the first time inside this closure
// (on the auth-rejection path the NIP-FI layer maps the error).
let (event_json, method_for_verify) =
parse_git_auth_header_full(&headers_clone, &method_str)?;
// SECURITY: method intentionally not verified for git routes. The tautological
// check (event.method == event.method) is deliberate — see comment block above.
// Git's credential protocol signs once with GET and reuses for POST. The URL tag
// provides the real security boundary (±60s timestamp + URL lock + HTTPS).
let pubkey = buzz_auth::nip98::verify_nip98_event(
&event_json,
&expected_url,
&method_for_verify,
None,
)
.map_err(|e| {
warn!(error = %e, "git NIP-98 auth failed");
(StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response()
})?;
// NOTE: NIP-98 event-ID dedup intentionally NOT implemented here.
// Git's credential protocol reuses one signed token across multiple requests
// in a session (info_refs GET -> upload-pack/receive-pack POST). Rejecting
// replayed event IDs would break normal clone/push operations.
// The +-60s timestamp window + URL scoping + HTTPS transport provide sufficient
// replay protection for v1. Per-request signing requires protocol changes.
let event: nostr::Event = serde_json::from_str(&event_json).map_err(|_| {
(StatusCode::UNAUTHORIZED, "invalid auth event").into_response()
})?;
let signed_auth_created_at = event.created_at.as_secs();
Ok(crate::nip_fi_http::Nip98Proof::new(
pubkey,
(event, signed_auth_created_at),
))
},
)?;
let pubkey = *admission.proven_pubkey();
let (event, signed_auth_created_at) = admission.into_extra();
// Relay membership gate (NIP-43). Git cannot carry a standalone
// x-auth-tag header through the credential-helper protocol, so agents
// attach their NIP-OA attestation to the signed NIP-98 event, matching
// the WebSocket NIP-42 flow.
let event_auth_tag = crate::handlers::auth::extract_auth_tag_json(&event);
let header_auth_tag = crate::api::relay_members::extract_auth_tag_header(&parts.headers);
let auth_tag = event_auth_tag.as_deref().or(header_auth_tag);
// A failed policy lookup is 503 (the canonical NIP-FI body when the
// mode restricts); only a real refusal is 403.
let unavailable = |legacy: Response| {
if mode.restricts() {
crate::nip_fi_core::http_denial(buzz_auth::DenialClass::AuthorizationUnavailable)
} else {
legacy
}
};
#[cfg(test)]
crate::nip_fi_test_hooks::before_git_membership(tenant.community()).await;
match crate::api::relay_members::check_relay_membership(
state,
tenant.community(),
pubkey.as_bytes(),
auth_tag,
Some(signed_auth_created_at),
)
.await
{
Ok(crate::api::relay_members::MembershipDecision::Denied) => {
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
return Err(
(StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()
);
}
Ok(_) => {}
Err(e) => {
warn!(pubkey = %pubkey.to_hex(), error = %e, "git: relay membership lookup failed");
return Err(unavailable(
(
StatusCode::SERVICE_UNAVAILABLE,
"error: internal error checking relay membership",
)
.into_response(),
));
}
}
deny_banned_git_principal(
&state.db,
tenant.community(),
&pubkey,
auth_tag,
Some(signed_auth_created_at),
)
.await
.map_err(|denial| {
if denial.status() == StatusCode::SERVICE_UNAVAILABLE {
unavailable(denial)
} else {
denial
}
})?;
Ok(GitAuth { pubkey, tenant })
}
}
/// Deny banned principals on every Git HTTP request.
///
/// Git runs outside the WebSocket authentication path, so a valid NIP-98
/// credential and channel membership are not enough — neither reflects a
/// moderation ban. Git credentials are also deliberately reused across a
/// session (see the replay notes above), so no session expiry would close the
/// gap on its own. Re-read the durable ban per request instead.
///
/// Cascades to the proven NIP-OA owner, matching the NIP-42 gate in
/// `handlers::auth`: banning a human must also revoke their agents, or the ban
/// is bypassable by cloning and pushing through an agent key.
pub(super) async fn deny_banned_git_principal(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
pubkey: &nostr::PublicKey,
auth_tag: Option<&str>,
signed_auth_created_at: Option<u64>,
) -> Result<(), Response> {
let agent = git_restriction_state(db, community, pubkey).await?;
// Skip the owner read when the agent is already banned: the denial is
// identical either way. Mirrors the WebSocket cascade's short-circuit.
let owner = if agent.banned {
None
} else {
crate::api::relay_members::extract_nip_oa_owner(
pubkey.as_bytes(),
auth_tag,
signed_auth_created_at,
)
};
let owner_state = match owner {
Some(owner) => Some(git_restriction_state(db, community, &owner).await?),
None => None,
};
enforce_git_ban_cascade(&agent, owner_state.as_ref()).map_err(|status| {
warn!(
pubkey = %pubkey.to_hex(),
owner = ?owner.map(|owner| owner.to_hex()),
"git: community ban denied request"
);
(status, "blocked: banned from this community").into_response()
})
}
/// One restriction read, failing closed with 503.
///
/// A restriction-store outage must not be reported to the client as a
/// permission decision — 503 says "retry", 403 would claim a ban that was
/// never read.
async fn git_restriction_state(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
pubkey: &nostr::PublicKey,
) -> Result<buzz_db::moderation::RestrictionState, Response> {
db.moderation_restriction_state(community, pubkey.as_bytes())
.await
.map_err(|error| {
warn!(pubkey = %pubkey.to_hex(), error = %error, "git: ban lookup failed closed");
(StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response()
})
}
fn enforce_git_ban(restriction: &buzz_db::moderation::RestrictionState) -> Result<(), StatusCode> {
if restriction.banned {
Err(StatusCode::FORBIDDEN)
} else {
Ok(())
}
}
/// Either principal's ban denies the request; `None` owner means no attested
/// owner to inherit from.
///
/// Split from the DB reads so agent→owner precedence stays unit-testable
/// without Postgres.
fn enforce_git_ban_cascade(
agent: &buzz_db::moderation::RestrictionState,
owner: Option<&buzz_db::moderation::RestrictionState>,
) -> Result<(), StatusCode> {
enforce_git_ban(agent)?;
match owner {
Some(owner) => enforce_git_ban(owner),
None => Ok(()),
}
}
/// Parse and syntax-validate the `Authorization: Nostr <base64>` header for
/// Git HTTP requests. Returns `Ok(())` on success (the caller only needs to
/// know whether the syntax is valid); on failure returns a `Response` that
/// already carries the correct 401 + `WWW-Authenticate` challenge.
///
/// Shared by the Off-mode early-exit path and the full extraction below.
/// Keeps the response bytes identical between the two call sites.
///
/// [FI-INV-15] — Off mode must return 401 + challenge for missing/malformed
/// credentials before any tenant lookup.
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
fn parse_git_auth_header(headers: &axum::http::HeaderMap, method: &str) -> Result<(), Response> {
parse_git_auth_header_full(headers, method).map(|_| ())
}
/// Full extraction: parse the Authorization header and return
/// `(event_json, method_for_verify)`. `method_for_verify` is taken from the
/// NIP-98 event's `method` tag when present, falling back to the HTTP method;
/// this is the "tautological" bypass that lets git clients reuse a GET token
/// for the subsequent POST.
///
/// Returns `Err(Response)` for missing/malformed-scheme → 401 + `WWW-Authenticate`;
/// bad-base64/bad-utf-8 → 401 without `WWW-Authenticate` (use `into_response()`
/// which does not add the challenge header).
#[allow(clippy::result_large_err)]
fn parse_git_auth_header_full(
headers: &axum::http::HeaderMap,
method: &str,
) -> Result<(String, String), Response> {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(
"WWW-Authenticate",
format!("Nostr realm=\"buzz\", method=\"{method}\""),
)
.body(Body::from("missing Authorization header"))
.unwrap()
})?;
let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(
"WWW-Authenticate",
format!("Nostr realm=\"buzz\", method=\"{method}\""),
)
.body(Body::from("expected Authorization: Nostr <base64>"))
.unwrap()
})?;
let event_bytes = base64::engine::general_purpose::STANDARD
.decode(token)
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token))
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?;
let event_json = String::from_utf8(event_bytes)
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?;
// Extract `method` from the NIP-98 event tag; fall back to HTTP method.
// SECURITY: method intentionally not verified for git routes (see GitAuth
// comment block). Git's credential helper signs once with GET and reuses
// for POST; the tautological self-comparison is deliberate.
let method_for_verify = serde_json::from_str::<serde_json::Value>(&event_json)
.ok()
.and_then(|v| {
v["tags"]
.as_array()?
.iter()
.find(|t| t[0].as_str() == Some("method"))?[1]
.as_str()
.map(str::to_owned)
})
.unwrap_or_else(|| method.to_owned());
Ok((event_json, method_for_verify))
}
/// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request.
///
/// The host is always the server-resolved tenant host. `config_relay_url` only
/// contributes the deployment scheme (`wss://` => `https://`, otherwise
/// `http://`) so a request to community B cannot authenticate with a token
/// signed for community A's URL just because the deployment has one global
/// `relay_url`.
fn git_expected_url(
config_relay_url: &str,
tenant: &TenantContext,
path_and_query: &str,
) -> Option<String> {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"https"
} else {
"http"
};
let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
prefix
} else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
prefix
} else {
path_and_query.strip_suffix("/git-receive-pack")?
};
Some(format!("{scheme}://{}{repo_path}", tenant.host()))
}
/// Validate URL `(owner, repo)` parameters and return the canonical repo
/// id (= `repo` with any `.git` suffix stripped).
///
/// Security: allowlist characters on both owner (64 lower-hex pubkey) and
/// repo name (`[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`). The
/// filesystem-path canonicalization that the old persistent-repo
/// implementation needed is no longer relevant — git workspaces are
/// ephemeral tempdirs from `hydrate_for_{read,write}`, not paths under a
/// repo root — but the *name* validation stays because owner/repo are
/// still used as object-store key components via `manifest::pointer_key`.
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
pub(super) fn validate_repo_id<'a>(owner: &str, repo: &'a str) -> Result<&'a str, Response> {
// Owner must be exactly 64 lowercase hex chars.
if owner.len() != 64
|| !owner
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return Err((StatusCode::BAD_REQUEST, "invalid owner").into_response());
}
// Strip trailing .git if present.
let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
// Repo name: [a-zA-Z0-9._-]{1,64}, no leading dots, no "..".
if repo_name.is_empty()
|| repo_name.len() > 64
|| repo_name.starts_with('.')
|| repo_name.contains("..")
|| !repo_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err((StatusCode::BAD_REQUEST, "invalid repo name").into_response());
}
Ok(repo_name)
}
/// Apply hardened environment to a git subprocess command.
///
/// Clears all inherited env vars, then sets only the minimum required:
/// - `PATH` — so git can find its own helpers
/// - `GIT_HTTP_EXPORT_ALL` — required for Smart HTTP
/// - `GIT_CONFIG_NOSYSTEM=1` — ignore system-wide gitconfig
/// - `GIT_CONFIG_GLOBAL=/dev/null` — prevent reading global gitconfig
/// - `HOME=/dev/null` — prevent reading ~/.gitconfig
pub(crate) fn harden_git_env(cmd: &mut Command) {
cmd.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null");
}
/// Acquire the global git-subprocess semaphore permit, or respond 503.
///
/// Bounds total in-flight git subprocesses across all routes. Returned
/// `OwnedSemaphorePermit` releases automatically on drop. Streaming callers
/// must move it into the response body so it covers the subprocess lifetime,
/// not just response construction.
#[allow(clippy::result_large_err)]
fn acquire_git_permit(
state: &Arc<AppState>,
operation: &'static str,
) -> Result<tokio::sync::OwnedSemaphorePermit, Response> {
Arc::clone(&state.git_semaphore)
.try_acquire_owned()
.map_err(|_| {
metrics::counter!(
"buzz_git_semaphore_rejections_total",
"operation" => operation
)
.increment(1);
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Retry-After", "5")
.body(Body::from("git service busy"))
.unwrap()
})
}
/// Convert a [`HydrateError`] to the HTTP response shape the read+write
/// paths share. Below-pointer failure ⇒ 5xx; pointer-absent is signalled
/// via `Ok(None)` from [`hydrate_for_read`] and never reaches this fn.
fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Response {
error!(error = %err, owner = %owner, repo = %repo, "hydrate failed");
if matches!(err, HydrateError::ResourceLimit(_)) {
return (
StatusCode::PAYLOAD_TOO_LARGE,
"repository exceeds relay resource limits",
)
.into_response();
}
(
StatusCode::INTERNAL_SERVER_ERROR,
"git backend hydration failed",
)
.into_response()
}
/// SEC-005: authorize a repository *read* (ref advertisement, upload-pack).
///
/// The authorization invariant is the authenticated git caller's **current
/// active membership in the repo's bound channel**. NIP-98 alone only proves
/// key possession — without this gate any authenticated pubkey (including a
/// member removed from the channel) can clone channel-bound repositories.
///
/// Resolution follows the current authoritative announcement, the same
/// mapping the push policy endpoint uses:
/// 1. current live kind:30617 by `(community, owner pubkey from the URL,
/// d = canonical repo name)` — soft-deleted/replaced announcements do not
/// resolve;
/// 2. its `buzz-channel` tag → channel UUID;
/// 3. [`buzz_db::Db::get_member_role`] for the caller — a read is allowed
/// only on `Ok(Some(role))` with a role the relay recognizes.
///
/// Fail-closed: missing/deleted announcement, invalid owner, missing or
/// malformed `buzz-channel` binding, non-member, unknown role, and every DB
/// error all deny. There is deliberately **no repo-owner bypass**: an owner
/// removed from the bound channel loses read access, which is the exact
/// exploit shape this gate closes. Every denial is the same generic 404 as a
/// nonexistent repo so membership cannot be probed through the git endpoints
/// — with exactly one carve-out: a **never-bound** repo read by its own
/// **announcement author** returns a 404 whose body tells the author how to
/// bind it (issue #3527: a vanilla NIP-34 client can announce without a
/// `buzz-channel` tag, and the repo then 404s forever with no explanation
/// for anyone). The author already knows the repo exists — they announced it
/// — so the remediation body leaks nothing, and only the author can rebind
/// (kind:30617 is keyed by `(author, d)`). A *broken* binding stays generic
/// even for the author: ambiguity fails closed.
pub(super) async fn authorize_git_read(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
caller: &nostr::PublicKey,
owner_hex: &str,
repo_name: &str,
) -> Result<nostr::Event, Response> {
fn denied() -> Response {
(StatusCode::NOT_FOUND, "repository not found").into_response()
}
let Ok(owner_bytes) = hex::decode(owner_hex) else {
return Err(denied());
};
if owner_bytes.len() != 32 {
return Err(denied());
}
let query = buzz_db::EventQuery {
kinds: Some(vec![30617]),
pubkey: Some(owner_bytes),
d_tag: Some(repo_name.to_string()),
global_only: true,
limit: Some(1),
..buzz_db::EventQuery::for_community(community)
};
let repo_event = match db.query_events(&query).await {
Ok(mut events) => match events.pop() {
Some(event) => event,
None => return Err(denied()),
},
Err(e) => {
error!(repo = %repo_name, error = %e, "git read gate: 30617 lookup failed (deny)");
return Err(denied());
}
};
let channel_id = match resolve_repo_binding(&repo_event.event) {
RepoBinding::Bound(id) => id,
RepoBinding::NotBound => {
// Remediation carve-out: author of a never-bound announcement.
// Status stays 404 — byte-identical to every other denial at the
// status level — so denial *class* is still unprobeable; only
// the body differs, and only for the one identity that already
// knows the repo exists. The body is a single verb-first line:
// Desktop error paths that keep one line keep the instruction.
if repo_event.event.pubkey == *caller {
warn!(repo = %repo_name, "git read gate: unbound repo read by its author (deny with remediation)");
return Err((
StatusCode::NOT_FOUND,
format!(
"run: buzz repos bind --id {repo_name} --channel <channel-uuid> — repository {repo_name:?} has no channel binding, so the relay cannot authorize access"
),
)
.into_response());
}
warn!(repo = %repo_name, "git read gate: missing buzz-channel binding (deny)");
return Err(denied());
}
RepoBinding::Broken => {
warn!(repo = %repo_name, "git read gate: malformed buzz-channel binding (deny)");
return Err(denied());
}
};
match db
.get_member_role(community, channel_id, &caller.to_bytes())
.await
{
Ok(role) if read_role_allows(role.as_deref()) => Ok(repo_event.event),
Ok(_) => Err(denied()),
Err(e) => {
error!(repo = %repo_name, error = %e, "git read gate: role lookup failed (deny)");
Err(denied())
}
}
}
/// Pure decision for [`authorize_git_read`]: a read requires a current
/// active membership row whose role the relay recognizes.
///
/// `None` = not an active member (removed, left, never joined) ⇒ deny.
/// An unrecognized role string ⇒ deny (fail-closed, same as the push
/// policy endpoint).
fn read_role_allows(role: Option<&str>) -> bool {
match role {
Some(r) => r.parse::<buzz_core::channel::MemberRole>().is_ok(),
None => false,
}
}
#[derive(Deserialize)]
/// Query parameters for the `info/refs` endpoint.
pub struct InfoRefsQuery {
service: String,
}
#[derive(Deserialize)]
/// Path parameters for git repo routes: `{owner}/{repo}`.
pub struct GitRepoParams {
owner: String,
repo: String,
}
/// Longest refname the fast path will emit. `is_safe_refname` enforces an
/// alphabet but no length bound; `pkt_line` encodes its payload length in a
/// 4-hex prefix that overflows past `0xffff`. Git's own refname limits sit far
/// below this, so any refname this long is pathological — degrade to the
/// subprocess path rather than risk a malformed length prefix. Generous bound:
/// `<oid> <refname>\n` plus the 4-byte pkt header must stay under `0xffff`, and
/// 4096 leaves vast headroom (git's de-facto practical ceiling is a few hundred
/// bytes total).
const MAX_FAST_PATH_REFNAME_LEN: usize = 4096;
/// Whether the `info/refs` fast path can serve this manifest without shelling
/// out. The manifest carries only `refname → oid`, so it cannot reproduce the
/// `^{}` peel line an **annotated** tag advertises (the peeled commit oid is
/// not stored — see RESEARCH/GIT_REF_ADVERTISEMENT_FORMAT.md). We cannot tell
/// an annotated tag from a lightweight one at the manifest level, so **any**
/// `refs/tags/*` forces the subprocess fallback for byte-correctness. We also
/// require the symbolic `head` to resolve to a ref we actually advertise —
/// otherwise the `symref=HEAD:<ref>` capability would point at a ref the
/// client never sees. The dominant clone case (branches only, HEAD→a branch)
/// takes the fast path; everything else stays exactly as it is today.
///
/// Eligibility is also a **safety gate**: the fast path emits manifest
/// refnames/oids straight into pkt-line bytes, so this predicate re-runs the
/// same `is_safe_refname`/`is_hex_oid` checks the hydrate path applies
/// (hydrate.rs) and the write path applies (`Manifest::validate`). The manifest
/// is already digest-verified against the pointer when loaded, so on every
/// normally-reachable path these re-checks are redundant — but keeping the
/// emit path symmetric with hydrate means an out-of-band-written manifest
/// (migration, manual S3 put, a future writer that skips `validate`) degrades
/// to the subprocess path instead of advertising unchecked bytes. Any failure
/// here → `false` → subprocess fallback, which surfaces the error correctly.
fn fast_path_eligible(manifest: &super::manifest::Manifest) -> bool {
use super::manifest::{is_hex_oid, is_safe_refname};
if manifest.refs.keys().any(|r| r.starts_with("refs/tags/")) {
return false;
}
// HEAD must resolve to an advertised ref. (Detached HEAD — head not in
// refs — can't be expressed as a symref; fall back.)
if !manifest.refs.contains_key(&manifest.head) {
return false;
}
// Safety re-check: every refname/oid we'd emit must be well-formed and
// bounded. HEAD is a key in `refs` (checked above), so the loop covers it.
manifest.refs.iter().all(|(refname, oid)| {
is_safe_refname(refname) && refname.len() <= MAX_FAST_PATH_REFNAME_LEN && is_hex_oid(oid)
})
}
/// Largest payload a single pkt-line can carry: the 4-hex length prefix counts
/// itself, so the total frame is bounded to `0xffff` and the payload to
/// `0xffff - 4`.
const PKT_LINE_MAX_PAYLOAD: usize = 0xffff - 4;
/// Encode one pkt-line: 4-char lowercase-hex length prefix (counting itself)
/// followed by `payload`. Appends to `out`.
///
/// The 4-hex prefix can only express a frame length up to `0xffff`. A payload
/// past [`PKT_LINE_MAX_PAYLOAD`] would make `format!("{len:04x}")` emit *five*
/// hex digits — not a truncation but a silent stream corruption (the next
/// reader takes the first four as the length). Callers on the manifest fast
/// path are already gated by [`fast_path_eligible`]'s length cap, but this is
/// the construction boundary: rather than trust every present and future
/// caller, an overlong payload here is dropped (emitting an empty `0004`
/// pkt-line) and logged at `error`, instead of writing a malformed length.
/// Non-panicking in every build profile — the worst case is a ref-short
/// advertisement that fails cleanly client-side, never a corrupted stream.
fn pkt_line(out: &mut Vec<u8>, payload: &[u8]) {
if payload.len() > PKT_LINE_MAX_PAYLOAD {
tracing::error!(
payload_len = payload.len(),
limit = PKT_LINE_MAX_PAYLOAD,
"pkt-line payload exceeds 0xffff-4 frame limit; dropping (bug: caller \
bypassed the fast-path length gate)"
);
out.extend_from_slice(b"0004"); // empty pkt-line; never a 5-hex length
return;
}
let len = payload.len() + 4;
out.extend_from_slice(format!("{len:04x}").as_bytes());
out.extend_from_slice(payload);
}
/// Build the **complete** `info/refs` HTTP body for `git-upload-pack` directly
/// from the published manifest — no hydrate, no subprocess. Byte-compatible
/// with `git upload-pack --advertise-refs` for the branches-only case that
/// [`fast_path_eligible`] gates on.
///
/// Layout (matches the subprocess oracle, git 2.51):
/// ```text
/// <pkt># service=git-upload-pack\n
/// 0000
/// <pkt><head-oid> HEAD\0<caps> symref=HEAD:<head-ref> object-format=<fmt> agent=buzz-git\n
/// <pkt><oid> <refname>\n # each ref, sorted ascending (BTreeMap order)
/// 0000
/// ```
/// The advertised capabilities are a fixed conservative **offer**; the client
/// re-negotiates against the real `upload-pack` subprocess in its follow-up
/// POST, so any subset the real upload-pack supports is safe. `object-format`
/// is derived from the oid width (40 hex = sha1, 64 = sha256) rather than
/// hardcoded. Caller guarantees [`fast_path_eligible`] returned true.
fn build_upload_pack_advertisement(manifest: &super::manifest::Manifest) -> Vec<u8> {
// head ref is guaranteed present by `fast_path_eligible`.
let head_oid = &manifest.refs[&manifest.head];
let object_format = if head_oid.len() == 64 {
"sha256"
} else {
"sha1"
};
// Capability offer. Conservative, version-agnostic. The symref tells the
// client which branch HEAD tracks (so `git clone` checks it out).
let caps = format!(
"multi_ack thin-pack side-band side-band-64k ofs-delta shallow \
deepen-since deepen-not deepen-relative no-progress include-tag \
multi_ack_detailed no-done symref=HEAD:{head} object-format={fmt} \
agent=buzz-git",
head = manifest.head,
fmt = object_format,
);
let mut out = Vec::new();
// 1. service header + flush.
let svc_line = b"# service=git-upload-pack\n";
pkt_line(&mut out, svc_line);
out.extend_from_slice(b"0000");
// 2. First line: HEAD with NUL-joined caps.
let mut first = Vec::new();
first.extend_from_slice(head_oid.as_bytes());
first.extend_from_slice(b" HEAD\0");
first.extend_from_slice(caps.as_bytes());
first.push(b'\n');
pkt_line(&mut out, &first);
// 3. Each ref, sorted (BTreeMap iterates ascending — matches git).
for (refname, oid) in &manifest.refs {
let mut line = Vec::new();
line.extend_from_slice(oid.as_bytes());
line.push(b' ');
line.extend_from_slice(refname.as_bytes());
line.push(b'\n');
pkt_line(&mut out, &line);
}
// 4. Trailing flush.
out.extend_from_slice(b"0000");
out
}
/// `GET /git/{owner}/{repo}/info/refs?service={service}`
///
/// Advertises refs for clone (git-upload-pack) or push (git-receive-pack).
///
/// **Track C fast path:** for `git-upload-pack` on a branches-only repo
/// ([`fast_path_eligible`]), the advertisement is built directly from the
/// published manifest — **no hydrate, no subprocess, no git semaphore
/// permit**. This is the dominant clone case, and it's exactly what the
/// W=20 permit used to serialize. Repos with any `refs/tags/*`, or the
/// `git-receive-pack` advertisement (different cap set), fall back to the
/// subprocess path — which still acquires a permit and hydrates, preserving
/// today's behavior byte-for-byte.
///
/// **Read fail-closed (Max's blocker):** pointer-absent → 404 (repo
/// never existed). *Any* below-pointer failure (manifest 404 under a
/// non-empty pointer, digest mismatch, pack 404, `index-pack` failure)
/// → 5xx via `HydrateError`. The legacy "leave disk as-is on hydrate
/// error" behavior is gone — A1 detectability holds on the read side too.
pub async fn info_refs(
State(state): State<Arc<AppState>>,
auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
Query(query): Query<InfoRefsQuery>,
) -> Result<Response, Response> {
// Validate service parameter: exact allowlist.
let service = match query.service.as_str() {
"git-upload-pack" | "git-receive-pack" => &query.service,
_ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()),
};
let repo_name = validate_repo_id(&params.owner, &params.repo)?;
// SEC-005: channel-membership gate before any manifest load, hydration,
// or subprocess work. Both services — the receive-pack advertisement
// leaks the ref list just like the upload-pack one.
authorize_git_read(
&state.db,
auth.tenant.community(),
&auth.pubkey,
&params.owner,
repo_name,
)
.await?;
// Track C fast path: only for clone advertisement. The receive-pack
// advertisement carries a different capability set (report-status,
// delete-refs, atomic, …) that we don't reproduce, so it always takes
// the subprocess path below.
if service == "git-upload-pack" {
// Load just the verified manifest — no object materialization, no
// permit. `Ok(None)` = pointer absent = repo never existed → 404.
match load_manifest_for_read(&state.git_store, &auth.tenant, &params.owner, &params.repo)
.await
{
Ok(Some(manifest)) if fast_path_eligible(&manifest) => {
let body = build_upload_pack_advertisement(&manifest);
return Ok(Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/x-git-upload-pack-advertisement",
)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(body))
.unwrap());
}
// Eligible repo but has tags, or below-pointer failure handling:
// a present-but-ineligible manifest falls through to the
// subprocess path. Pointer-absent is a definitive 404 here —
// no point hydrating a repo that doesn't exist.
Ok(Some(_)) => { /* ineligible (has tags) → subprocess fallback */ }
Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
Err(e) => return Err(hydrate_error_to_response(&params.owner, &params.repo, e)),
}
}
// Subprocess path: receive-pack advertisement, or upload-pack for a
// tagged repo. Acquires a permit and hydrates — today's behavior.
info_refs_subprocess(&state, &auth.tenant, service, &params).await
}
/// Subprocess-backed `info/refs` advertisement: hydrate the published state
/// into an ephemeral bare repo and shell out to `git <svc> --advertise-refs`.
///
/// This is the fallback from the Track C fast path (tagged repos, and the
/// `git-receive-pack` advertisement). The advertisement is O(refs), not
/// O(pack), so it stays buffered — streaming would buy nothing and would
/// lose the clean timeout/error mapping that buffering gives us.
async fn info_refs_subprocess(
state: &Arc<AppState>,
tenant: &TenantContext,
service: &str,
params: &GitRepoParams,
) -> Result<Response, Response> {
let _permit = acquire_git_permit(state, "info_refs")?;
let repo = match hydrate_for_read(
&state.git_store,
tenant,
&params.owner,
&params.repo,
HydrationOptions {
pack_cache: &state.git_pack_cache,
scratch_dir: &state.config.git_repo_path,
max_pack_bytes: state.config.git_max_pack_bytes,
max_repo_bytes: state.config.git_max_repo_bytes,
},
)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
Err(e) => return Err(hydrate_error_to_response(&params.owner, &params.repo, e)),
};
// Git's smart HTTP protocol uses service names like "git-upload-pack" and
// "git-receive-pack", but the actual git subcommands are "upload-pack" and
// "receive-pack" (without the "git-" prefix).
let git_subcmd = service.strip_prefix("git-").unwrap_or(service);
let stdout_tmp = tempfile::NamedTempFile::new_in(&state.config.git_repo_path).map_err(|e| {
error!(error = %e, "git info_refs stdout tempfile failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let stderr_tmp = tempfile::NamedTempFile::new_in(&state.config.git_repo_path).map_err(|e| {
error!(error = %e, "git info_refs stderr tempfile failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let stdout_file = stdout_tmp.reopen().map_err(|e| {
error!(error = %e, "git info_refs stdout tempfile reopen failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let stderr_file = stderr_tmp.reopen().map_err(|e| {
error!(error = %e, "git info_refs stderr tempfile reopen failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let mut cmd = Command::new("git");
cmd.arg(git_subcmd)
.arg("--stateless-rpc")
.arg("--advertise-refs")
.arg(repo.path())
.stdout(std::process::Stdio::from(stdout_file))
.stderr(std::process::Stdio::from(stderr_file))
.kill_on_drop(true);
harden_git_env(&mut cmd);
let mut child = cmd.spawn().map_err(|e| {
error!(error = %e, "git subprocess failed to spawn");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let status = tokio::time::timeout(INFO_REFS_TIMEOUT, child.wait())
.await
.map_err(|_| {
warn!(
"git info_refs subprocess timed out ({}s)",
INFO_REFS_TIMEOUT.as_secs()
);
(StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response()
})?
.map_err(|e| {
error!(error = %e, "git subprocess failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
if !status.success() {
let stderr = read_log_prefix(stderr_tmp.path(), 64 * 1024).await;
error!(stderr = %stderr, "git --advertise-refs failed");
return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response());
}
let stdout_len = tokio::fs::metadata(stdout_tmp.path())
.await
.map_err(|e| {
error!(error = %e, "git info_refs stdout metadata failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?
.len();
if stdout_len > INFO_REFS_MAX_OUTPUT_BYTES {
warn!(
bytes = stdout_len,
max = INFO_REFS_MAX_OUTPUT_BYTES,
"git info_refs output exceeded limit"
);
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
"git ref advertisement exceeds relay limits",
)
.into_response());
}
let stdout = tokio::fs::read(stdout_tmp.path()).await.map_err(|e| {
error!(error = %e, "git info_refs stdout read failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
// `repo` (the tempdir) must live until *after* the subprocess has read
// its objects. Holding it until here is the structural lifetime that
// guarantees that.
drop(repo);
// Build pkt-line response: service header + flush + git output.
let svc_line = format!("# service={service}\n");
let svc_pkt = format!("{:04x}{svc_line}", svc_line.len() + 4);
let mut body = Vec::with_capacity(svc_pkt.len() + 4 + stdout.len());
body.extend_from_slice(svc_pkt.as_bytes());
body.extend_from_slice(b"0000"); // flush packet
body.extend_from_slice(&stdout);
let content_type = format!("application/x-{service}-advertisement");
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(body))
.unwrap())
}
/// Decode a smart-HTTP git request body according to its `Content-Encoding`.
///
/// Git's smart-HTTP client gzip-compresses the `git-upload-pack` /
/// `git-receive-pack` request body once it exceeds an internal size
/// threshold (`http.postBuffer`-independent; triggered by the number of
/// want/have lines, so it fires reliably on many-ref clones). The relay
/// pipes the request body straight into the git subprocess's stdin, so a
/// still-compressed body reaches `git upload-pack` as raw gzip and fails
/// with `fatal: protocol error: bad line length character`. Transparently
/// inflate here so the subprocess always sees plain pkt-lines.
///
/// Only `gzip` is decoded (the sole encoding git emits). An unknown
/// non-identity encoding is passed through unchanged rather than rejected;
/// the subprocess surfaces any real mismatch as an in-band protocol error.
///
/// `max_decoded_bytes` bounds the *inflated* size: the router's
/// `RequestBodyLimitLayer` only caps compressed bytes, so without this a
/// small gzip bomb (ratios up to ~1000:1) could feed an effectively
/// unbounded stream to the subprocess — for receive-pack that means
/// unbounded scratch-disk writes. Exceeding the cap errors the stream,
/// which the stdin pumps surface as a logged early-EOF to git.
fn decode_git_request_body(
headers: &axum::http::HeaderMap,
body: Body,
max_decoded_bytes: u64,
) -> Body {
let is_gzip = headers
.get(header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("gzip") || v.eq_ignore_ascii_case("x-gzip"))
.unwrap_or(false);
if !is_gzip {
return body;
}
use futures_util::StreamExt;
use tokio_util::io::{ReaderStream, StreamReader};
let byte_stream = body
.into_data_stream()
.map(|res| res.map_err(std::io::Error::other))
.boxed();
let decoder =
async_compression::tokio::bufread::GzipDecoder::new(StreamReader::new(byte_stream));
let mut decoded: u64 = 0;
let capped = ReaderStream::new(decoder).map(move |chunk| {
let chunk = chunk?;
decoded = decoded.saturating_add(chunk.len() as u64);
if decoded > max_decoded_bytes {
return Err(std::io::Error::other(format!(
"gzip git request body exceeded {max_decoded_bytes} decoded bytes"
)));
}
Ok(chunk)
});
Body::from_stream(capped)
}
/// `POST /git/{owner}/{repo}/git-upload-pack`
///
/// Handles clone/fetch — client sends wants/haves, server sends pack data.
///
/// Reads from a tempdir bare repo hydrated from the published manifest;
/// the tempdir lives only for the duration of this request.
pub async fn upload_pack(
State(state): State<Arc<AppState>>,
auth: GitAuth,
headers: axum::http::HeaderMap,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
let repo_name = validate_repo_id(&params.owner, &params.repo)?;
// SEC-005: the reused NIP-98 token means the GET advertisement's
// authorization cannot stand in for POST-time membership — gate this
// door independently, before body decode work is driven or hydration
// starts.
authorize_git_read(
&state.db,
auth.tenant.community(),
&auth.pubkey,
&params.owner,
repo_name,
)
.await?;
let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES);
let permit = acquire_git_permit(&state, "upload_pack")?;
let repo = match hydrate_for_read(
&state.git_store,
&auth.tenant,
&params.owner,
&params.repo,
HydrationOptions {
pack_cache: &state.git_pack_cache,
scratch_dir: &state.config.git_repo_path,
max_pack_bytes: state.config.git_max_pack_bytes,
max_repo_bytes: state.config.git_max_repo_bytes,
},
)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
Err(e) => return Err(hydrate_error_to_response(&params.owner, &params.repo, e)),
};
// Track A: stream the subprocess stdout straight into the response body
// instead of buffering the whole pack into RAM. `repo` (the hydrated
// tempdir) is moved into the stream and stays alive until the last byte
// is drained — the streaming analogue of the old `drop(repo)`.
stream_git_read(
repo,
permit,
"upload-pack",
&[],
body,
Vec::new(),
"application/x-git-upload-pack-result".to_string(),
)
}
/// `POST /git/{owner}/{repo}/git-receive-pack`
///
/// Handles push — client sends ref updates + pack data.
///
/// Authorization: NIP-98 authenticates the pusher. The pre-receive hook
/// installed into the hydrated tempdir calls back to the internal policy
/// endpoint for ref-level authorization (channel role + protection rules
/// from kind:30617).
///
/// **Push flow (spec §Push steps 1–8):**
/// 1. Validate ids; acquire global git permit (bounds concurrent
/// subprocesses; **no per-repo lock** — writer serialization is the
/// pointer CAS, per spec).
/// 2. `hydrate_for_write` → `(HydratedRepo, ParentState)`. The
/// `ParentState` is the *same* observed pointer state the workspace
/// was hydrated from; it's the CAS predicate at step 7 below, which
/// is what makes `Inv_RefDerivedFromParent` structural rather than a
/// code-review property.
/// 3. `install_hook(repo.path())` — drop the pre-receive script + chmod.
/// Same script, same env contract, same policy callback as today;
/// only the on-disk path is ephemeral.
/// 4. Run `receive-pack --stateless-rpc` against the tempdir. The hook
/// enforces fast-forward + branch protection in-process.
/// 5. `finalize_push(PushContext { pack, parent_state, repo, ... })` is
/// the only path that builds a push `Response`. It calls
/// `cas_publish` (§Push steps 2–7) and emits kind:30618 on `Won`,
/// *only then* builds the 2xx.
pub async fn receive_pack(
State(state): State<Arc<AppState>>,
auth: GitAuth,
headers: axum::http::HeaderMap,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
let repo_name = validate_repo_id(&params.owner, &params.repo)?;
let body = decode_git_request_body(&headers, body, state.config.git_max_pack_bytes);
let pusher_hex = hex::encode(auth.pubkey.to_bytes());
let _permit = acquire_git_permit(&state, "receive_pack")?;
// **No per-repo advisory lock — by design.** Writer serialization is
// the pointer CAS at `cas_publish` step 7 (`Inv_NoFork` proves this
// sufficient). The previous code held a per-repo `tokio::Mutex`, but
// that only spanned one process; once we run >1 relay instance
// (which is the point of "no local stateful disk"), it spans nothing
// and CAS is the only serialization that holds. The named tradeoff:
// two concurrent same-repo pushes each hydrate + run receive-pack,
// and the loser's CPU/IO is thrown away on `Conflict`. **Accepted
// for v1** — same-ref contention is rare, and a cross-instance lock
// would be a distributed-lock service we explicitly don't want.
// If contention shows up in metrics, the fix is a short local
// best-effort lock as a *latency optimization*, never a correctness
// dependency. (Eva's call, on record in #proj-git-on-s3 with the
// ParentState seam review.)
// Hydrate parent state + workspace in one round-trip. ParentState
// travels with the workspace into finalize_push so the CAS predicates
// on the same pointer ETag the workspace was hydrated from.
let (repo, parent_state) = hydrate_for_write(
&state.git_store,
&auth.tenant,
&params.owner,
&params.repo,
HydrationOptions {
pack_cache: &state.git_pack_cache,
scratch_dir: &state.config.git_repo_path,
max_pack_bytes: state.config.git_max_pack_bytes,
max_repo_bytes: state.config.git_max_repo_bytes,
},
)
.await
.map_err(|e| hydrate_error_to_response(&params.owner, &params.repo, e))?;
// Install the pre-receive hook into the ephemeral workspace. The
// hook script is fixed per-deployment; per-push state (callback URL,
// HMAC secret, pusher pubkey) rides in env at exec time.
install_hook(repo.path()).await.map_err(|e| {
error!(error = %e, "install pre-receive hook into hydrated workspace");
(StatusCode::INTERNAL_SERVER_ERROR, "git hook install failed").into_response()
})?;
// Build hook env vars for the pre-receive hook.
let hook_url = format!(
"http://127.0.0.1:{}/internal/git/policy",
state.config.bind_addr.port()
);
let hooks_dir = repo.path().join("hooks").display().to_string();
let mut hook_env = vec![
("BUZZ_HOOK_URL", hook_url),
(
"BUZZ_HOOK_SECRET",
state.config.git_hook_hmac_secret.clone(),
),
("BUZZ_REPO_ID", repo_name.to_string()),
("BUZZ_REPO_OWNER", params.owner.clone()),
(
"BUZZ_COMMUNITY_ID",
auth.tenant.community().as_uuid().to_string(),
),
("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()),
];
hook_env.extend(receive_pack_git_config(hooks_dir));
// Run receive-pack against the tempdir. Returns the *owned* subprocess
// output (PackOutput) — crucially NOT a Response, so the post-push
// fence in finalize_push can sequence the CAS before any 2xx exists.
let pack = run_git_at(
repo.path(),
"receive-pack",
body,
&hook_env,
&state.config.git_repo_path,
RECEIVE_PACK_MAX_OUTPUT_BYTES,
)
.await?;
let ctx = PushContext {
pack,
parent_state,
owner: params.owner.clone(),
repo: params.repo.clone(),
repo_id: repo_name.to_string(),
pusher: auth.pubkey,
tenant: auth.tenant,
repo_handle: repo,
};
Ok(finalize_push(&state, ctx).await)
}
/// Per-process git configuration for the hydrated receive-pack workspace.
fn receive_pack_git_config(hooks_dir: String) -> Vec<(&'static str, String)> {
vec![
// Override any repo-local core.hooksPath setting; defense in depth
// even though the hydrated workspace has no inherited config.
("GIT_CONFIG_COUNT", "2".to_string()),
("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()),
("GIT_CONFIG_VALUE_0", hooks_dir),
// A bare repository rejects deletion of its symbolic HEAD branch by
// default. Hydrated repositories are ephemeral, and cas_publish
// selects a surviving branch for the next manifest HEAD, so allow
// receive-pack to apply the deletion before that selection runs.
("GIT_CONFIG_KEY_1", "receive.denyDeleteCurrent".to_string()),
("GIT_CONFIG_VALUE_1", "ignore".to_string()),
]
}
/// Buffered output of a `git --stateless-rpc` subprocess.
///
/// The handler holds this as an owned value between subprocess completion
/// and response construction — this is the *structural seam* the post-push
/// fence relies on (see §Implementation Correspondence in
/// `docs/git-on-object-storage.md`): nothing reaches the client until
/// [`finalize_push`] has decided to build a `Response` from these bytes.
pub(crate) struct PackOutput {
pub stdout: Vec<u8>,
/// Whether the push is safe to publish: the `git receive-pack` subprocess
/// exited 0 **and** its report-status reported no rejected (`ng`) ref.
///
/// A pre-receive hook decline (authorization denied) does NOT surface as a
/// non-zero exit — `git receive-pack --stateless-rpc` exits 0 and reports
/// the rejection only in-band as report-status. So `ok` must fold in the
/// report-status scan (`receive_pack_report_rejected`), not the exit code
/// alone. `finalize_push` treats `false` as "push did not happen": skip the
/// CAS publish and the derived kind:30618 so a rejected push leaves **no
/// published state**. The buffered stdout still carries git's in-band
/// report-status so the client prints the rejection.
pub ok: bool,
}
/// Spawn a `git --stateless-rpc <service>` subprocess against the given
/// path, stream the request body to stdin, and return the buffered
/// stdout/stderr/exit status as a [`PackOutput`].
///
/// Critically returns the **owned** subprocess output rather than a
/// `Response`, so callers can sequence post-subprocess work (the push
/// fence) before any byte reaches the client.
async fn run_git_at(
repo_path: &Path,
service: &str,
body: Body,
extra_env: &[(&str, String)],
scratch_dir: &Path,
max_output_bytes: u64,
) -> Result<PackOutput, Response> {
let stdout_tmp = tempfile::NamedTempFile::new_in(scratch_dir).map_err(|e| {
error!(error = %e, service = %service, "git stdout tempfile failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let stderr_tmp = tempfile::NamedTempFile::new_in(scratch_dir).map_err(|e| {
error!(error = %e, service = %service, "git stderr tempfile failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let stdout_file = stdout_tmp.reopen().map_err(|e| {
error!(error = %e, service = %service, "git stdout tempfile reopen failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let stderr_file = stderr_tmp.reopen().map_err(|e| {
error!(error = %e, service = %service, "git stderr tempfile reopen failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let mut cmd = Command::new("git");
cmd.arg(service)
.arg("--stateless-rpc")
.arg(repo_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::from(stdout_file))
.stderr(std::process::Stdio::from(stderr_file))
.kill_on_drop(true);
harden_git_env(&mut cmd);
for (key, value) in extra_env {
cmd.env(key, value);
}
let mut child = cmd.spawn().map_err(|e| {
error!(error = %e, service = %service, "git subprocess failed to spawn");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
// Stream request body to git stdin.
let mut stdin = child.stdin.take().unwrap();
let pump_service = service.to_string();
let body_task = tokio::spawn(async move {
use futures_util::StreamExt;
let mut stream = body.into_data_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
if tokio::io::AsyncWriteExt::write_all(&mut stdin, &bytes)
.await
.is_err()
{
break;
}
}
Err(e) => {
// Body/decode errors (client abort, malformed gzip,
// decoded-size cap) surface to git as early EOF; log so
// there is a server-side signal, not just an opaque
// client-side hangup.
warn!(error = %e, service = %pump_service, "git request body stream failed");
break;
}
}
}
drop(stdin); // close stdin → EOF for git
});
let body_abort = body_task.abort_handle();
let timeout_result = tokio::time::timeout(PACK_OPS_TIMEOUT, async {
let _ = body_task.await;
child.wait().await
})
.await;
let status = match timeout_result {
Err(_elapsed) => {
body_abort.abort();
warn!(service = %service, timeout_secs = PACK_OPS_TIMEOUT.as_secs(), "git subprocess timed out");
return Err((StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response());
}
Ok(Err(e)) => {
error!(error = %e, service = %service, "git subprocess failed");
return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response());
}
Ok(Ok(status)) => status,
};
let stdout_len = tokio::fs::metadata(stdout_tmp.path())
.await
.map_err(|e| {
error!(error = %e, service = %service, "git stdout metadata failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?
.len();
if stdout_len > max_output_bytes {
warn!(
service = %service,
bytes = stdout_len,
max = max_output_bytes,
"git subprocess output exceeded limit"
);
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
"git output exceeds relay limits",
)
.into_response());
}
if !status.success() {
let stderr = read_log_prefix(stderr_tmp.path(), 64 * 1024).await;
warn!(stderr = %stderr, service = %service, "git subprocess exited with error");
// Still return output — git protocol errors are communicated in-band.
// A non-zero exit feeds `PackOutput.ok` below, but it is NOT the signal
// for a pre-receive hook decline: `git receive-pack --stateless-rpc`
// exits 0 on a hook decline, reporting the rejection only in-band as
// report-status (see `receive_pack_report_rejected`). The exit code
// still guards genuine subprocess failures (spawn/IO/abort).
}
// Primary fence for a denied push: scan the report-status for an `ng`
// (rejected) ref update. `git receive-pack` exits 0 on a pre-receive hook
// decline, so the exit code alone is insufficient — the rejection lives in
// the in-band report-status. Fold both signals into `ok` so `finalize_push`
// skips CAS publish + kind:30618 on any rejected ref.
let stdout = tokio::fs::read(stdout_tmp.path()).await.map_err(|e| {
error!(error = %e, service = %service, "git stdout read failed");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
let report_rejected = service == "receive-pack" && receive_pack_report_rejected(&stdout);
if report_rejected {
warn!(
service = %service,
"git receive-pack report-status contains a rejected (ng) ref update"
);
}
Ok(PackOutput {
stdout,
ok: status.success() && !report_rejected,
})
}
async fn read_log_prefix(path: &Path, max_bytes: u64) -> String {
use tokio::io::AsyncReadExt;
let Ok(file) = tokio::fs::File::open(path).await else {
return "<stderr unavailable>".to_string();
};
let mut bytes = Vec::new();
let mut limited = file.take(max_bytes);
if limited.read_to_end(&mut bytes).await.is_err() {
return "<stderr unavailable>".to_string();
}
String::from_utf8_lossy(&bytes).to_string()
}
/// Returns true when a `git receive-pack` report-status stream contains an
/// `ng <ref> <reason>` line — i.e. git rejected at least one ref update.
///
/// Empirically, `git receive-pack --stateless-rpc` exits **0** even when a
/// pre-receive hook rejects a push: the rejection is communicated only in-band
/// to the client as report-status (`unpack ok` followed by `ng refs/...
/// pre-receive hook declined`). The relay must therefore treat an `ng` status
/// as "push did not happen" for the publish fence, not rely on the exit code.
///
/// ## Wire format
///
/// The report-status is a pkt-line stream. When side-band-64k is negotiated
/// (which the stock client does), the status pkt-lines are **nested**: each
/// outer pkt-line's payload begins with a channel byte (`1` = data, `2`/`3` =
/// progress/error text), and the band-1 payload carries its *own* inner
/// pkt-line stream:
///
/// ```text
/// <outer-len>\x01<inner-len>unpack ok\n<inner-len>ng refs/heads/main ...\n0000
/// ```
///
/// A naive "strip the band byte then split on \n" misses this: the rejection
/// line surfaces as `0031ng refs/...` (inner length prefix glued on), which
/// does not start with `ng `. So we de-frame one level deeper — for a band-1
/// payload we parse the inner pkt-lines before matching. Without side-band the
/// status pkt-lines appear directly at the outer level, which we also match.
fn receive_pack_report_rejected(stdout: &[u8]) -> bool {
for payload in PktLineIter::new(stdout) {
match payload.first() {
// Side-band channel 1 (data): carries a nested pkt-line stream.
Some(1) => {
if PktLineIter::new(&payload[1..]).any(|line| line.starts_with(b"ng ")) {
return true;
}
}
// Side-band channel 2/3 (progress/error text): never status lines.
Some(2 | 3) => {}
// No side-band: the status pkt-line payload is the line itself.
_ => {
if payload.starts_with(b"ng ") {
return true;
}
}
}
}
false
}
/// Iterator over the payloads of a pkt-line stream.
///
/// Yields the bytes between the 4-hex length prefix and the end of each
/// pkt-line. Skips flush (`0000`) / delim (`0001`) / response-end (`0002`)
/// control pkts. Stops on the first malformed length or truncated frame
/// (defensive: a corrupt stream simply produces no further matches rather
/// than panicking).
struct PktLineIter<'a> {
buf: &'a [u8],
i: usize,
}
impl<'a> PktLineIter<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, i: 0 }
}
}
impl<'a> Iterator for PktLineIter<'a> {
type Item = &'a [u8];
fn next(&mut self) -> Option<&'a [u8]> {
loop {
if self.i + 4 > self.buf.len() {
return None;
}
let len_hex = std::str::from_utf8(&self.buf[self.i..self.i + 4]).ok()?;
let len = usize::from_str_radix(len_hex, 16).ok()?;
// 0000 (flush), 0001 (delim), 0002 (response-end): no payload.
if len < 4 {
self.i += 4;
continue;
}
if self.i + len > self.buf.len() {
return None;
}
let payload = &self.buf[self.i + 4..self.i + len];
self.i += len;
return Some(payload);
}
}
}
/// Keeps the git subprocess and its hydrated workspace alive for exactly as
/// long as the response body is being streamed.
///
/// The HTTP body is a [`tokio_util::io::ReaderStream`] over the child's
/// stdout. That borrows nothing it can keep alive on its own: the `Child`
/// (whose `kill_on_drop` would otherwise reap the process mid-stream) and the
/// [`HydratedRepo`] tempdir (whose objects the subprocess is still reading)
/// must outlive the last byte. We park both here and drop them only when the
/// stream is exhausted or the client disconnects — the structural analogue of
/// the `drop(repo)` the buffered path did after `wait_with_output`.
///
/// Why streaming is safe here but **not** on the push path: these are
/// read-only operations (`upload-pack`, `info/refs --advertise-refs`). They
/// never mutate published state, so there is no fence to preserve — contrast
/// `receive_pack`, which must buffer into [`PackOutput`] so [`finalize_push`]
/// can sequence the pointer CAS *before* any 2xx byte exists.
struct StreamingGit {
inner: TimedByteStream<tokio_util::io::ReaderStream<tokio::process::ChildStdout>>,
/// Held purely to extend lifetime. `kill_on_drop(true)` means dropping
/// this after the stream completes reaps any lingering process; on the
/// happy path the child has already exited by then.
child: tokio::process::Child,
/// The ephemeral bare repo the subprocess reads objects from. Must not be
/// removed from disk until the subprocess is done — i.e. until the stream
/// ends.
_repo: HydratedRepo,
/// Pumping the request body is detached from response polling. Abort it
/// when the response is dropped or the subprocess times out.
stdin_task: tokio::task::JoinHandle<()>,
}
/// Adds a hard deadline and lifecycle metrics to upload-pack stdout.
///
/// The response status is already committed when this stream is polled, so a
/// timeout is surfaced as an in-band body error. [`StreamingGit`] observes
/// that error and kills the subprocess.
struct TimedByteStream<S> {
inner: std::pin::Pin<Box<S>>,
deadline: std::pin::Pin<Box<tokio::time::Sleep>>,
started_at: Instant,
streamed_bytes: u64,
finished: bool,
}
/// Keeps a git concurrency permit alive for the lifetime of a response-body
/// stream. The permit is released when the stream reaches EOF or the client
/// disconnects and Axum drops the body.
struct GitPermitStream<S> {
inner: std::pin::Pin<Box<S>>,
_permit: tokio::sync::OwnedSemaphorePermit,
}
impl<S> futures_util::Stream for GitPermitStream<S>
where
S: futures_util::Stream,
{
type Item = S::Item;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
impl futures_util::Stream for StreamingGit {
type Item = Result<bytes::Bytes, std::io::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
let poll = std::pin::Pin::new(&mut self.inner).poll_next(cx);
if matches!(
&poll,
std::task::Poll::Ready(Some(Err(error)))
if error.kind() == std::io::ErrorKind::TimedOut
) {
self.stdin_task.abort();
if let Err(error) = self.child.start_kill() {
warn!(error = %error, "timed-out git upload-pack could not be killed");
}
}
poll
}
}
impl<S> TimedByteStream<S> {
fn new(inner: S, timeout: Duration) -> Self {
Self {
inner: Box::pin(inner),
deadline: Box::pin(tokio::time::sleep(timeout)),
started_at: Instant::now(),
streamed_bytes: 0,
finished: false,
}
}
}
impl<S> futures_util::Stream for TimedByteStream<S>
where
S: futures_util::Stream<Item = Result<bytes::Bytes, std::io::Error>>,
{
type Item = Result<bytes::Bytes, std::io::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
if self.finished {
return std::task::Poll::Ready(None);
}
if self.deadline.as_mut().poll(cx).is_ready() {
self.finished = true;
metrics::counter!("buzz_git_upload_pack_timeouts_total").increment(1);
warn!("git upload-pack stream timed out");
return std::task::Poll::Ready(Some(Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"git upload-pack timed out",
))));
}
match self.inner.as_mut().poll_next(cx) {
std::task::Poll::Ready(Some(Ok(bytes))) => {
self.streamed_bytes = self
.streamed_bytes
.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
std::task::Poll::Ready(Some(Ok(bytes)))
}
std::task::Poll::Ready(None) => {
self.finished = true;
std::task::Poll::Ready(None)
}
other => other,
}
}
}
impl<S> Drop for TimedByteStream<S> {
fn drop(&mut self) {
metrics::histogram!("buzz_git_upload_pack_stream_seconds")
.record(self.started_at.elapsed().as_secs_f64());
metrics::histogram!("buzz_git_upload_pack_stream_bytes").record(self.streamed_bytes as f64);
}
}
impl Drop for StreamingGit {
fn drop(&mut self) {
self.stdin_task.abort();
}
}
/// Spawn a read-only `git --stateless-rpc <service>` subprocess and return a
/// streaming [`Response`] whose body is the child's stdout, optionally
/// preceded by `prefix` bytes (used by `info/refs` for the
/// `# service=…\n0000` pkt-line header).
///
/// The request `body` is pumped to the child's stdin concurrently. The
/// returned response owns the child + the hydrated workspace via
/// [`StreamingGit`], so neither is torn down until the body is fully drained.
///
/// **Read-path only.** Errors after the response head is sent cannot change
/// the status code (it's already 200), which is exactly git's smart-HTTP
/// contract: protocol-level failures are reported in-band within the pack
/// stream, not via HTTP status. The buffered [`run_git_at`] stays the push
/// path's runner precisely because the fence needs the bytes in hand before
/// committing to a status.
#[allow(clippy::result_large_err)]
fn stream_git_read(
repo: HydratedRepo,
permit: tokio::sync::OwnedSemaphorePermit,
service: &'static str,
extra_args: &[&str],
body: Body,
prefix: Vec<u8>,
content_type: String,
) -> Result<Response, Response> {
let mut cmd = Command::new("git");
cmd.arg(service).arg("--stateless-rpc");
for a in extra_args {
cmd.arg(a);
}
cmd.arg(repo.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
harden_git_env(&mut cmd);
let mut child = cmd.spawn().map_err(|e| {
error!(error = %e, service = %service, "git subprocess failed to spawn");
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
})?;
// Pump the request body into git's stdin, then close it (EOF). Detached:
// the task ends on its own when the body ends or the write fails.
let mut stdin = child.stdin.take().expect("stdin piped");
let stdin_task = tokio::spawn(async move {
use futures_util::StreamExt;
let mut stream = body.into_data_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
if tokio::io::AsyncWriteExt::write_all(&mut stdin, &bytes)
.await
.is_err()
{
break;
}
}
Err(e) => {
// Body/decode errors (client abort, malformed gzip,
// decoded-size cap) surface to git as early EOF; log so
// there is a server-side signal, not just an opaque
// client-side hangup.
warn!(error = %e, service = %service, "git request body stream failed");
break;
}
}
}
drop(stdin); // close stdin → EOF for git
});
let stdout = child.stdout.take().expect("stdout piped");
let git_stream = StreamingGit {
inner: TimedByteStream::new(tokio_util::io::ReaderStream::new(stdout), PACK_OPS_TIMEOUT),
child,
_repo: repo,
stdin_task,
};
// Prepend any protocol header (info/refs) ahead of git's stdout. The
// prefix is a single ready chunk; the rest streams from the subprocess.
let prefix_stream =
futures_util::stream::once(
async move { Ok::<_, std::io::Error>(bytes::Bytes::from(prefix)) },
);
let body_stream = GitPermitStream {
inner: Box::pin(futures_util::StreamExt::chain(prefix_stream, git_stream)),
_permit: permit,
};
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from_stream(body_stream))
.unwrap())
}
/// Build the canonical `application/x-git-{service}-result` response from
/// a completed subprocess. For the push path this is **only** reached via
/// [`finalize_push`], which is the unique constructor of a push 2xx — the
/// structural fence.
fn build_git_response(service: &str, output: PackOutput) -> Response {
let content_type = format!("application/x-git-{service}-result");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(output.stdout))
.unwrap()
}
/// Per-push state captured between subprocess completion and response
/// construction. Constructing a `PushContext` is the only path from a
/// push subprocess to a 2xx push response (see [`finalize_push`]) — the
/// structural fence (spec Theorem 1).
pub(crate) struct PushContext {
pub pack: PackOutput,
/// Parent pointer state observed at hydrate time. The CAS in
/// `cas_publish` predicates on `parent_state.if_match`, so a
/// concurrent writer that advanced the pointer between hydrate and
/// CAS surfaces as `Conflict`/HTTP 409 — `Inv_RefDerivedFromParent`
/// is structural, not a code-review property.
pub parent_state: ParentState,
pub owner: String,
/// Raw URL repo segment (may include `.git`).
pub repo: String,
/// Stripped repo id (= `repo` with any `.git` suffix removed). Used
/// as the `d` tag on kind:30618 — must match the kind:30617 author's
/// `d` exactly.
pub repo_id: String,
pub pusher: nostr::PublicKey,
/// Server-resolved tenant that selected the pointer namespace and owns
/// any derived kind:30618 event from this push.
pub tenant: TenantContext,
/// The hydrated workspace handle. Held until response construction
/// (which happens *after* `cas_publish` returns) so the tempdir
/// outlives the receive-pack subprocess and the CAS publish.
pub repo_handle: HydratedRepo,
}
#[derive(Default)]
struct FinalizePushHooks {
#[cfg(test)]
post_cas_gate: Option<Arc<PostCasGate>>,
#[cfg(test)]
fail_ref_state_insert: bool,
}
#[cfg(test)]
#[derive(Default)]
struct PostCasGate {
reached: tokio::sync::Notify,
resume: tokio::sync::Notify,
}
/// Finalize a push request: CAS-commit the new state into the object
/// store, derive kind:30618 from the committed manifest, and only then
/// build the success response.
///
/// **The fence (Theorem 1):** the success response is constructed only
/// after `cas_publish` returns `Ok(CasSuccess)`. Lost-race / conflict /
/// backend failure all return *without* a 2xx. This is the unique
/// constructor of a push 2xx, so the seam is structural (not by
/// convention).
async fn finalize_push(state: &Arc<AppState>, ctx: PushContext) -> Response {
finalize_push_inner(state, ctx, &FinalizePushHooks::default()).await
}
async fn finalize_push_inner(
state: &Arc<AppState>,
ctx: PushContext,
hooks: &FinalizePushHooks,
) -> Response {
#[cfg(not(test))]
let _ = hooks;
// The push fence, part 0 — **a rejected push publishes nothing.**
//
// `ctx.pack.ok` is false when git aborted the ref updates: either the
// subprocess exited non-zero (genuine failure) OR — the important case —
// a pre-receive hook declined the push. A hook decline does NOT yield a
// non-zero exit; `git receive-pack --stateless-rpc` exits 0 and reports
// the rejection only in-band as report-status (`ng <ref> <reason>`), which
// `run_git_at` folds into `ok` via `receive_pack_report_rejected`. In
// either case the workspace refs were NOT advanced, so there is no
// committed state to publish: skip the CAS pointer write AND the derived
// kind:30618. Otherwise a denied push would emit a relay-signed, fanned-out
// 30618 falsely attributing the ref state to the *denied* pusher
// (`actor = ctx.pusher`), breaking the invariant "rejected push → no
// published state".
//
// git's in-band report-status (already buffered in `ctx.pack.stdout`)
// still streams back so the client prints `! [remote rejected]` / the
// hook's decline message; only the publish side effects are suppressed.
if !ctx.pack.ok {
warn!(
owner = %ctx.owner,
repo = %ctx.repo_id,
"receive-pack exited non-zero (e.g. pre-receive hook decline); \
skipping CAS publish and kind:30618 — no state published"
);
let response = build_git_response("receive-pack", ctx.pack);
drop(ctx.repo_handle);
return response;
}
// An already-running receive-pack may cross the durable fence after
// request admission. Revalidate immediately before object-store CAS; DB
// trigger fencing alone cannot roll back an S3 pointer mutation.
let serving_write = match buzz_deletion::acquire_serving_write(
&state.db,
ctx.tenant.community(),
"git_publish",
)
.await
{
Ok(guard) => guard,
Err(error) => {
warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push rejected by community deletion fence");
return (
StatusCode::SERVICE_UNAVAILABLE,
"community writes are fenced",
)
.into_response();
}
};
if let Err(error) = serving_write.verify().await {
warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease");
return (
StatusCode::SERVICE_UNAVAILABLE,
"community write lease lost",
)
.into_response();
}
// Step 7 (CAS). The PushContext binds `parent_state` (observed at
// hydrate) to the CAS predicate here — no re-reading of the pointer
// between hydrate and CAS. Observe serving-lease loss throughout the
// potentially long upload/CAS operation, not only at its boundaries.
let publish = cas_publish(
&state.git_store,
&ctx.tenant,
ctx.repo_handle.path(),
&ctx.owner,
&ctx.repo,
&ctx.parent_state,
PublishLimits {
parent_hydrated_bytes: ctx.repo_handle.hydrated_bytes(),
max_pack_bytes: state.config.git_max_pack_bytes,
max_repo_bytes: state.config.git_max_repo_bytes,
},
);
let success = match serving_write.protect(publish).await {
Ok(result) => match result {
Ok(s) => s,
Err(CasError::Conflict {
winner_manifest_key,
..
}) => {
warn!(
owner = %ctx.owner,
repo = %ctx.repo,
winner = %winner_manifest_key,
"push lost CAS race; tempdir dropped, returning 409"
);
return (
StatusCode::CONFLICT,
"push superseded by a concurrent writer; pull and retry",
)
.into_response();
}
Err(CasError::ManifestInvalid(e)) => {
// 4xx-class: the workspace produced refs/HEAD/oids the
// manifest validator rejects (unsafe refname, malformed oid,
// empty head, malformed parent). Pre-CAS — no pointer was
// written.
warn!(
owner = %ctx.owner,
repo = %ctx.repo,
error = %e,
"push rejected: manifest validation failed"
);
return (
StatusCode::BAD_REQUEST,
"push produced invalid manifest state",
)
.into_response();
}
Err(CasError::ResourceLimit(e)) => {
warn!(
owner = %ctx.owner,
repo = %ctx.repo,
error = %e,
"push rejected: repo exceeds relay resource limits"
);
return (
StatusCode::PAYLOAD_TOO_LARGE,
"repository exceeds relay resource limits",
)
.into_response();
}
Err(e) => {
// 5xx-class: ManifestReadFailed (parent corruption),
// Backend, PackCapture. The tempdir drops on scope exit; no
// pointer was written (or, on rare ManifestReadFailed during
// winner-fetch, the winner is already installed and the
// loser's data is unrelated).
error!(
owner = %ctx.owner,
repo = %ctx.repo,
error = %e,
"push failed pre-response"
);
return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response();
}
},
Err(error) => {
warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "push lost community serving lease during CAS publish");
return (
StatusCode::SERVICE_UNAVAILABLE,
"community write lease lost",
)
.into_response();
}
};
#[cfg(test)]
if let Some(gate) = &hooks.post_cas_gate {
gate.reached.notify_one();
gate.resume.notified().await;
}
// Derived after CAS: kind:30618 ref-state event over the *committed*
// manifest's refs/head. Spec §Implementation Correspondence:
// "kind:30618 is derived after CAS, never the commit." We emit only
// when the committed manifest differs from the parent — a true no-op
// push pays no 30618 cost.
//
// **Strict no-op detection.** We emit unless the canonical manifest
// is byte-identical to the parent (Dawn's `canonical_bytes` is
// deterministic, so equal published state ⇒ equal digest by
// construction). The cases this differs from "refs+head equality":
// pack-only changes (rare; internal recompaction, or a push that
// produces a new pack covering existing tips with different deltas)
// would emit a 30618 with identical `(refs, head)`. The relay DB's
// `Ok((_, false))` arm below dedups it for free — one extra DB
// round-trip per pack-only push, which clients don't normally
// generate. Tightening to refs+head equality is a future
// micro-optimization only if that dedup cost becomes visible.
let parent_digest_str: Option<&str> = ctx.parent_state.parent_digest.as_deref();
let after_digest = success.manifest_key.strip_prefix("manifests/");
let manifest_changed = match (parent_digest_str, after_digest) {
(Some(before), Some(after)) => before != after,
_ => true, // first push (parent None) or impossible-shape after key → publish
};
let publication_result: Result<(), String> = if manifest_changed {
let inputs = RefStateInputs {
repo_id: &ctx.repo_id,
head: &success.manifest.head,
refs: &success.manifest.refs,
actor_pubkey_hex: &hex::encode(ctx.pusher.to_bytes()),
};
match build_ref_state_event(&inputs, &state.relay_keypair) {
Ok(event) => {
// Relay-signed kind:30618 belongs to the same server-resolved
// tenant as the git request that committed the pointer.
#[cfg(test)]
let insert_result = if hooks.fail_ref_state_insert {
Err(buzz_db::DbError::InvalidData(
"injected kind:30618 insert failure".to_string(),
))
} else {
state
.db
.insert_event_with_serving_write_guard(serving_write.lease(), &event, None)
.await
};
#[cfg(not(test))]
let insert_result = state
.db
.insert_event_with_serving_write_guard(serving_write.lease(), &event, None)
.await;
match insert_result {
Ok((stored, true)) => {
// Routed through the guarded send path for uniformity;
// the access gate no-ops for this globally-scoped
// (channel_id = None) ref-state event.
crate::handlers::event::fan_out_event_to_local_subscribers(
state,
ctx.tenant.community(),
&stored,
)
.await;
info!(
owner = %ctx.owner,
repo = %ctx.repo_id,
manifest = %success.manifest_key,
"kind:30618 published (derived after CAS)"
);
Ok(())
}
Ok((_, false)) => {
info!(
owner = %ctx.owner,
repo = %ctx.repo_id,
"kind:30618 deduplicated by relay db"
);
Ok(())
}
Err(error) => Err(format!("kind:30618 insert failed: {error}")),
}
}
Err(error) => Err(format!("kind:30618 build failed: {error}")),
}
} else {
Ok(())
};
// The admitted serving write spans the complete publication attempt. Fence
// acquisition cannot overtake the pointer CAS, durable 30618 insert, or
// local fan-out attempt; only now may the lease be released.
if let Err(error) = serving_write.finish().await {
warn!(owner = %ctx.owner, repo = %ctx.repo, %error, "failed to release community serving lease after push publication");
return (
StatusCode::SERVICE_UNAVAILABLE,
"community write lease lost during publication",
)
.into_response();
}
if let Err(error) = publication_result {
error!(
owner = %ctx.owner,
repo = %ctx.repo_id,
manifest = %success.manifest_key,
%error,
"push pointer committed but kind:30618 publication failed"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"push committed but ref-state publication failed; retry",
)
.into_response();
}
// Only now — after CAS commit and (optional) 30618 emission — build
// the 2xx. The tempdir's lifetime is tied to `ctx.repo_handle`, which
// we drop after building the response so the subprocess output bytes
// are fully consumed before the on-disk objects vanish.
let response = build_git_response("receive-pack", ctx.pack);
drop(ctx.repo_handle);
response
}
/// Build the git sub-router with its own body limit.
///
/// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size.
pub fn git_router(state: Arc<AppState>) -> Router {
let body_limit = state.config.git_max_pack_bytes as usize;
Router::new()
.route("/git/{owner}/{repo}/info/refs", get(info_refs))
.route("/git/{owner}/{repo}/git-upload-pack", post(upload_pack))
.route("/git/{owner}/{repo}/git-receive-pack", post(receive_pack))
.merge(super::settings::router())
.layer(RequestBodyLimitLayer::new(body_limit))
.with_state(state)
}
#[cfg(test)]
mod track_c_tests {
use super::*;
use crate::api::git::hydrate::{hydrate_for_write, HydrationOptions};
use crate::api::git::manifest::Manifest;
use buzz_core::CommunityId;
use nostr::{EventBuilder, Keys, Kind, Tag};
use std::collections::BTreeMap;
use std::io::Write;
use std::process::Output;
use tempfile::TempDir;
fn oid_sha1() -> String {
"cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string()
}
fn run_test_git(cwd: &Path, args: &[&str], extra_env: &[(&str, String)]) -> Output {
let mut cmd = std::process::Command::new("git");
cmd.current_dir(cwd)
.args(args)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null");
for (key, value) in extra_env {
cmd.env(key, value);
}
cmd.output().expect("run git")
}
fn run_test_receive_pack(repo: &Path, request: &[u8], extra_env: &[(&str, String)]) -> Output {
let mut cmd = std::process::Command::new("git");
cmd.arg("receive-pack")
.arg("--stateless-rpc")
.arg(repo)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null");
for (key, value) in extra_env {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("spawn receive-pack");
child
.stdin
.take()
.expect("receive-pack stdin")
.write_all(request)
.expect("write receive-pack request");
child.wait_with_output().expect("wait for receive-pack")
}
fn assert_git_success(output: Output, operation: &str) {
assert!(
output.status.success(),
"{operation} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn receive_pack_config_allows_deleting_current_branch() {
let root = tempfile::TempDir::new().expect("tempdir");
let remote = root.path().join("remote.git");
let source = root.path().join("source");
let remote_arg = remote.to_str().expect("utf-8 remote path");
let source_arg = source.to_str().expect("utf-8 source path");
assert_git_success(
run_test_git(
root.path(),
&["init", "--bare", "--initial-branch=main", remote_arg],
&[],
),
"initialize bare remote",
);
assert_git_success(
run_test_git(
root.path(),
&["init", "--initial-branch=main", source_arg],
&[],
),
"initialize source repository",
);
assert_git_success(
run_test_git(source.as_path(), &["config", "user.name", "Buzz Test"], &[]),
"configure user name",
);
assert_git_success(
run_test_git(
source.as_path(),
&["config", "user.email", "buzz-test@example.com"],
&[],
),
"configure user email",
);
std::fs::write(source.join("README.md"), "test\n").expect("write fixture");
assert_git_success(
run_test_git(source.as_path(), &["add", "README.md"], &[]),
"stage fixture",
);
assert_git_success(
run_test_git(source.as_path(), &["commit", "-m", "fixture"], &[]),
"commit fixture",
);
assert_git_success(
run_test_git(
source.as_path(),
&["push", remote_arg, "main:main", "main:master"],
&[],
),
"seed main and master",
);
let oid_output = run_test_git(remote.as_path(), &["rev-parse", "refs/heads/main"], &[]);
assert!(oid_output.status.success());
let old_oid = String::from_utf8(oid_output.stdout)
.expect("utf-8 oid")
.trim()
.to_string();
let command = format!(
"{old_oid} {} refs/heads/main\0report-status\n",
"0".repeat(40)
);
let mut request = format!("{:04x}", command.len() + 4).into_bytes();
request.extend_from_slice(command.as_bytes());
request.extend_from_slice(b"0000");
let git_config = receive_pack_git_config(remote.join("hooks").display().to_string());
let output = run_test_receive_pack(remote.as_path(), &request, &git_config);
assert!(
output.status.success(),
"receive-pack failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!receive_pack_report_rejected(&output.stdout),
"receive-pack rejected the deletion: {}",
String::from_utf8_lossy(&output.stdout)
);
assert!(!remote.join("refs/heads/main").exists());
assert!(remote.join("refs/heads/master").exists());
}
async fn run_finalize_git(repo: &Path, args: &[&str]) -> std::process::Output {
let mut command = Command::new("git");
command.current_dir(repo).args(args);
harden_git_env(&mut command);
let output = command.output().await.expect("spawn git");
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
async fn finalize_test_state() -> (Arc<AppState>, sqlx::PgPool) {
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE]
config.require_relay_membership = false;
config.redis_url = "redis://127.0.0.1:1".to_string();
config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_string());
let pool = sqlx::PgPool::connect(&config.database_url)
.await
.expect("connect test DB");
let db = buzz_db::Db::from_pool(pool.clone());
db.migrate().await.expect("migrate test DB");
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("redis pool");
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.expect("pubsub manager"),
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage");
let (state, _audit_shutdown) = AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
Keys::generate(),
media_storage,
);
(Arc::new(state), pool)
}
async fn approved_deletion(
state: &AppState,
host: &str,
) -> (
buzz_db::deletion::DeletionRequest,
buzz_db::deletion::ClaimedDeletion,
) {
use buzz_db::deletion::{
FrozenInventory, KeyStreamDigest, PrefixManifest, StorageManifest,
DEFAULT_LEASE_DURATION,
};
let store = state.db.deletion_store();
let request = store
.submit(host, "git-finalize-test", Some("post-CAS lease regression"))
.await
.expect("submit deletion");
let inventory = FrozenInventory {
schema: store
.inventory_schema(request.community_id)
.await
.expect("schema inventory"),
storage: StorageManifest {
version: 4,
prefixes: buzz_media::tenant_prefixes(*request.community_id.as_uuid())
.into_iter()
.map(|prefix| PrefixManifest {
prefix,
object_count: 0,
total_bytes: 0,
keys_digest: KeyStreamDigest::new().finish().0,
})
.collect(),
},
};
store
.freeze_inventory(request.id, &inventory)
.await
.expect("freeze inventory");
store
.approve(request.id, "git-finalize-test", None)
.await
.expect("approve deletion");
let claim = store
.claim_specific(request.id, "git-finalize-test", DEFAULT_LEASE_DURATION)
.await
.expect("claim deletion")
.expect("won deletion claim");
(request, claim)
}
async fn pushed_context(
state: &AppState,
community: CommunityId,
host: &str,
owner: String,
repo: String,
pusher: nostr::PublicKey,
scratch: &Path,
) -> PushContext {
let tenant = TenantContext::resolved(community, host);
let (hydrated, parent_state) = hydrate_for_write(
&state.git_store,
&tenant,
&owner,
&repo,
HydrationOptions {
pack_cache: &state.git_pack_cache,
scratch_dir: scratch,
max_pack_bytes: 1024 * 1024,
max_repo_bytes: 2 * 1024 * 1024,
},
)
.await
.expect("hydrate empty test repo");
let source = scratch.join("source");
tokio::fs::create_dir(&source)
.await
.expect("source directory");
run_finalize_git(&source, &["init", "--quiet", "--initial-branch=main"]).await;
run_finalize_git(&source, &["config", "user.email", "finalize@test"]).await;
run_finalize_git(&source, &["config", "user.name", "finalize"]).await;
tokio::fs::write(source.join("file.txt"), b"committed\n")
.await
.expect("write source file");
run_finalize_git(&source, &["add", "file.txt"]).await;
run_finalize_git(&source, &["commit", "--quiet", "-m", "committed"]).await;
let remote = hydrated.path().to_str().expect("hydrated path utf8");
run_finalize_git(&source, &["push", "--quiet", remote, "main"]).await;
PushContext {
pack: PackOutput {
stdout: b"push-ok".to_vec(),
ok: true,
},
parent_state,
owner,
repo: repo.clone(),
repo_id: repo,
pusher,
tenant,
repo_handle: hydrated,
}
}
async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() {
let (state, pool) = finalize_test_state().await;
let host = format!(
"git-announce-lease-{}.example",
uuid::Uuid::new_v4().simple()
);
let community = state
.db
.ensure_configured_community(&host)
.await
.expect("create test community")
.id;
let (request, claim) = approved_deletion(&state, &host).await;
let tenant = TenantContext::resolved(community, host.clone());
let owner_keys = Keys::generate();
let repo = format!("repo-{}", uuid::Uuid::new_v4().simple());
let event = EventBuilder::new(Kind::Custom(30_617), "")
.tags([Tag::parse(["d", &repo]).expect("d tag")])
.sign_with_keys(&owner_keys)
.expect("sign announcement");
let gate = Arc::new(crate::handlers::side_effects::GitRepoAnnouncementGate::default());
let hooks = crate::handlers::side_effects::GitRepoAnnouncementHooks {
post_lease_gate: Some(Arc::clone(&gate)),
};
let announce_state = Arc::clone(&state);
let announce_tenant = tenant.clone();
let announce = tokio::spawn(async move {
let result = crate::handlers::side_effects::handle_git_repo_announcement_inner(
&announce_tenant,
&event,
&announce_state,
&hooks,
)
.await;
let owner_hex = hex::encode(owner_keys.public_key().to_bytes());
(result, owner_hex)
});
gate.reached.notified().await;
state
.db
.deletion_store()
.begin_quiescing(&claim.lease)
.await
.expect("quiesce after announcement lease");
let error = state
.db
.deletion_store()
.fence(&claim.lease)
.await
.expect_err("announcement serving lease must block fence");
assert!(matches!(
error,
buzz_db::DbError::ServingWritesNotDrained { .. }
));
gate.resume.notify_one();
let (announce_result, owner_hex) = announce.await.expect("announcement task");
announce_result.expect("announcement completes");
let pointer_key = crate::api::git::manifest::pointer_key(community, &owner_hex, &repo);
assert!(
state
.git_store
.get_pointer(&pointer_key)
.await
.expect("read pointer")
.is_some(),
"announcement pointer must be durable before lease release"
);
assert!(state
.db
.deletion_store()
.serving_writes_drained(community)
.await
.expect("serving lease released"));
let generation = state
.db
.deletion_store()
.fence(&claim.lease)
.await
.expect("fence after pointer seed");
assert_eq!(generation, 1);
assert_eq!(
state
.db
.deletion_store()
.get(request.id)
.await
.expect("fenced request")
.stage,
buzz_db::deletion::DeletionStage::Fenced
);
drop(state);
pool.close().await;
}
async fn finalize_push_holds_serving_lease_through_post_cas_publication() {
let (state, pool) = finalize_test_state().await;
let host = format!("git-finalize-{}.example", uuid::Uuid::new_v4().simple());
let community = state
.db
.ensure_configured_community(&host)
.await
.expect("create test community")
.id;
let (request, claim) = approved_deletion(&state, &host).await;
let scratch = TempDir::new().expect("scratch");
let owner = format!("owner-{}", uuid::Uuid::new_v4().simple());
let repo = format!("repo-{}", uuid::Uuid::new_v4().simple());
let ctx = pushed_context(
&state,
community,
&host,
owner,
repo.clone(),
Keys::generate().public_key(),
scratch.path(),
)
.await;
let gate = Arc::new(PostCasGate::default());
let hooks = FinalizePushHooks {
post_cas_gate: Some(Arc::clone(&gate)),
fail_ref_state_insert: false,
};
let finalize_state = Arc::clone(&state);
let finalize =
tokio::spawn(async move { finalize_push_inner(&finalize_state, ctx, &hooks).await });
gate.reached.notified().await;
state
.db
.deletion_store()
.begin_quiescing(&claim.lease)
.await
.expect("quiesce after CAS");
let error = state
.db
.deletion_store()
.fence(&claim.lease)
.await
.expect_err("post-CAS serving lease must block fence");
assert!(matches!(
error,
buzz_db::DbError::ServingWritesNotDrained { .. }
));
assert!(!state
.db
.deletion_store()
.is_serving_active(community)
.await
.expect("quiescing rejects new serving work"));
gate.resume.notify_one();
let response = finalize.await.expect("finalize task");
assert_eq!(response.status(), StatusCode::OK);
let mut query = buzz_db::event::EventQuery::for_community(community);
query.kinds = Some(vec![30_618]);
query.d_tag = Some(repo);
let events = state.db.query_events(&query).await.expect("query 30618");
assert_eq!(events.len(), 1, "kind:30618 must be durable before release");
assert!(state
.db
.deletion_store()
.serving_writes_drained(community)
.await
.expect("serving lease released"));
let generation = state
.db
.deletion_store()
.fence(&claim.lease)
.await
.expect("fence after publication");
assert_eq!(generation, 1);
assert_eq!(
state
.db
.deletion_store()
.get(request.id)
.await
.expect("fenced request")
.stage,
buzz_db::deletion::DeletionStage::Fenced
);
drop(state);
pool.close().await;
}
async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() {
let (state, pool) = finalize_test_state().await;
let host = format!(
"git-finalize-fail-{}.example",
uuid::Uuid::new_v4().simple()
);
let community = state
.db
.ensure_configured_community(&host)
.await
.expect("create test community")
.id;
let scratch = TempDir::new().expect("scratch");
let ctx = pushed_context(
&state,
community,
&host,
format!("owner-{}", uuid::Uuid::new_v4().simple()),
format!("repo-{}", uuid::Uuid::new_v4().simple()),
Keys::generate().public_key(),
scratch.path(),
)
.await;
let hooks = FinalizePushHooks {
post_cas_gate: None,
fail_ref_state_insert: true,
};
let response = finalize_push_inner(&state, ctx, &hooks).await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(state
.db
.deletion_store()
.serving_writes_drained(community)
.await
.expect("serving lease released on failure"));
drop(state);
pool.close().await;
}
mod external_infra_minio_tests {
#[tokio::test]
#[ignore = "requires Postgres and MinIO"]
async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() {
super::repo_announcement_holds_serving_lease_until_pointer_is_seeded().await;
}
#[tokio::test]
#[ignore = "requires Postgres and MinIO"]
async fn finalize_push_holds_serving_lease_through_post_cas_publication() {
super::finalize_push_holds_serving_lease_through_post_cas_publication().await;
}
#[tokio::test]
#[ignore = "requires Postgres and MinIO"]
async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() {
super::finalize_push_db_failure_after_cas_is_not_success_and_releases_lease().await;
}
}
/// A gzip-encoded request body is transparently inflated before it
/// reaches the git subprocess. Git's smart-HTTP client gzips the
/// upload-pack/receive-pack request body past a size threshold (fires
/// on many-ref clones); without this the subprocess sees raw gzip and
/// dies with `fatal: protocol error: bad line length character`.
#[tokio::test]
async fn gzip_request_body_is_inflated() {
use axum::http::HeaderMap;
use std::io::Write;
let plaintext = b"0032want cb09a769da1c01f458fa6959d4e8eded38fac8d3\n0000";
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(plaintext).unwrap();
let gzipped = encoder.finish().unwrap();
assert_ne!(
gzipped, plaintext,
"precondition: body is actually compressed"
);
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let decoded = decode_git_request_body(&headers, Body::from(gzipped), u64::MAX);
let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap();
assert_eq!(bytes.as_ref(), plaintext);
}
/// Without a gzip `Content-Encoding`, the body is passed through byte
/// for byte (the common small-clone / already-inflated case).
#[tokio::test]
async fn identity_request_body_is_passthrough() {
use axum::http::HeaderMap;
let plaintext = b"0032want cb09a769da1c01f458fa6959d4e8eded38fac8d3\n0000";
let decoded =
decode_git_request_body(&HeaderMap::new(), Body::from(plaintext.to_vec()), u64::MAX);
let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap();
assert_eq!(bytes.as_ref(), plaintext);
}
/// A gzip bomb is cut off at the decoded-byte cap: the router's
/// `RequestBodyLimitLayer` only bounds *compressed* bytes, so the
/// decode seam must enforce the inflated bound itself. Highly
/// compressible input (1 MiB of zeros → ~1 KiB gzip) must error once
/// the decoded stream crosses the cap.
#[tokio::test]
async fn gzip_request_body_over_decoded_cap_errors() {
use axum::http::HeaderMap;
use std::io::Write;
let plaintext = vec![0u8; 1024 * 1024]; // inflates 1 MiB from ~1 KiB wire
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&plaintext).unwrap();
let gzipped = encoder.finish().unwrap();
assert!(
gzipped.len() < 64 * 1024,
"precondition: bomb is small on the wire (got {} bytes)",
gzipped.len()
);
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let cap: u64 = 64 * 1024;
let decoded = decode_git_request_body(&headers, Body::from(gzipped), cap);
let err = axum::body::to_bytes(decoded, usize::MAX)
.await
.expect_err("decoded stream must error past the cap");
assert!(
err.to_string().contains("decoded bytes"),
"error should name the decoded-size cap, got: {err}"
);
}
/// The decoded cap does not truncate bodies at or under the limit —
/// exactly-at-cap input passes through complete.
#[tokio::test]
async fn gzip_request_body_at_decoded_cap_passes() {
use axum::http::HeaderMap;
use std::io::Write;
let plaintext = vec![7u8; 8 * 1024];
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&plaintext).unwrap();
let gzipped = encoder.finish().unwrap();
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let decoded =
decode_git_request_body(&headers, Body::from(gzipped), plaintext.len() as u64);
let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap();
assert_eq!(bytes.as_ref(), plaintext);
}
/// Malformed gzip (valid header, corrupt deflate stream) surfaces as a
/// stream error rather than silently yielding garbage — the stdin pump
/// logs it and closes the subprocess's stdin early.
#[tokio::test]
async fn malformed_gzip_request_body_errors() {
use axum::http::HeaderMap;
// Valid 10-byte gzip header followed by garbage.
let mut bogus = vec![0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff];
bogus.extend_from_slice(b"this is not deflate data at all");
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let decoded = decode_git_request_body(&headers, Body::from(bogus), u64::MAX);
axum::body::to_bytes(decoded, usize::MAX)
.await
.expect_err("corrupt gzip must error the decoded stream");
}
fn branches_only_manifest() -> Manifest {
let mut refs = BTreeMap::new();
refs.insert("refs/heads/feature".to_string(), oid_sha1());
refs.insert("refs/heads/main".to_string(), oid_sha1());
Manifest {
version: 1,
head: "refs/heads/main".to_string(),
refs,
packs: vec!["packs/deadbeef".to_string()],
parent: None,
}
}
fn tenant(host: &str, n: u128) -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(uuid::Uuid::from_u128(n)), host)
}
fn git_nip98_event_json(keys: &Keys, url: &str, method: &str) -> String {
let tags = vec![
Tag::parse(["u", url]).expect("u tag"),
Tag::parse(["method", method]).expect("method tag"),
];
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.expect("sign NIP-98 event");
serde_json::to_string(&event).expect("serialize")
}
fn pkt(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
super::pkt_line(&mut out, payload);
out
}
#[test]
fn streaming_body_holds_git_permit_until_drop() {
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
let permit = Arc::clone(&semaphore)
.try_acquire_owned()
.expect("initial permit");
let stream = GitPermitStream {
inner: Box::pin(futures_util::stream::pending::<
Result<bytes::Bytes, std::io::Error>,
>()),
_permit: permit,
};
let body = Body::from_stream(stream);
assert_eq!(semaphore.available_permits(), 0);
drop(body);
assert_eq!(semaphore.available_permits(), 1);
}
#[tokio::test]
async fn upload_pack_stream_returns_timeout_error_at_deadline() {
use futures_util::StreamExt;
let mut stream = TimedByteStream::new(
futures_util::stream::pending::<Result<bytes::Bytes, std::io::Error>>(),
Duration::from_millis(10),
);
let error = tokio::time::timeout(Duration::from_secs(1), stream.next())
.await
.expect("stream deadline")
.expect("timeout item")
.expect_err("timeout error");
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn upload_pack_stream_counts_response_bytes() {
use futures_util::StreamExt;
let chunks = vec![
Ok(bytes::Bytes::from_static(b"abc")),
Ok(bytes::Bytes::from_static(b"de")),
];
let mut stream =
TimedByteStream::new(futures_util::stream::iter(chunks), Duration::from_secs(1));
while stream.next().await.is_some() {}
assert_eq!(stream.streamed_bytes, 5);
}
/// Wrap inner status pkt-lines in one side-band-64k band-1 (data) outer
/// pkt-line — the real shape git emits when side-band is negotiated:
/// `<outer-len>\x01<inner-pkt-lines>`.
fn sideband_data(inner_pkts: &[&[u8]]) -> Vec<u8> {
let mut inner = vec![0x01u8]; // band 1 = data channel
for p in inner_pkts {
inner.extend(pkt(p));
}
pkt(&inner)
}
#[test]
fn receive_pack_report_rejected_detects_plain_ng_line() {
// No side-band: status pkt-lines at the outer level.
let mut out = Vec::new();
out.extend(pkt(b"unpack ok\n"));
out.extend(pkt(b"ng refs/heads/main pre-receive hook declined\n"));
out.extend_from_slice(b"0000");
assert!(receive_pack_report_rejected(&out));
}
#[test]
fn receive_pack_report_rejected_detects_sideband_ng_line() {
// Real side-band-64k shape: status pkt-lines NESTED inside a band-1
// outer pkt-line. A naive "strip band byte, split on \n" parser misses
// this — the rejection surfaces as `0031ng refs/...`.
let mut out = sideband_data(&[
b"unpack ok\n",
b"ng refs/heads/main pre-receive hook declined\n",
]);
out.extend_from_slice(b"0000");
assert!(receive_pack_report_rejected(&out));
}
#[test]
fn receive_pack_report_rejected_ignores_ok_status() {
// Real side-band-64k success shape — nested `ok` pkt-lines, no `ng`.
let mut out = sideband_data(&[b"unpack ok\n", b"ok refs/heads/main\n"]);
out.extend_from_slice(b"0000");
assert!(!receive_pack_report_rejected(&out));
}
#[test]
fn receive_pack_report_rejected_matches_real_git_deny_shape() {
// Mirrors report-status captured from `git receive-pack` (2.50.1) on a
// pre-receive hook decline:
// <band2>policy: denied\n (progress/error text)
// <band1>000eunpack ok\n0031ng refs/heads/main pre-receive hook declined\n
// 0000
// The band-1 payload carries its OWN inner pkt stream — the exact case
// a flat parser returns `false` on.
let mut out = Vec::new();
out.extend(pkt(b"\x02policy: denied\n")); // band 2 = progress/error
out.extend(sideband_data(&[
b"unpack ok\n",
b"ng refs/heads/main pre-receive hook declined\n",
]));
out.extend_from_slice(b"0000");
assert!(
receive_pack_report_rejected(&out),
"must detect the nested band-1 ng line on a real deny"
);
}
#[test]
fn receive_pack_report_rejected_ignores_progress_channel_noise() {
// A band-2 (progress) line must never be mistaken for a status line,
// even if it contained bytes resembling a status — only band-1 counts.
let mut out = Vec::new();
out.extend(pkt(b"\x02ng-looking progress noise\n")); // band 2 noise
out.extend(sideband_data(&[b"unpack ok\n", b"ok refs/heads/main\n"]));
out.extend_from_slice(b"0000");
assert!(!receive_pack_report_rejected(&out));
}
#[test]
fn receive_pack_report_rejected_handles_truncated_and_malformed() {
// Defensive: malformed length / truncated frame yields no match, never
// a panic.
assert!(!receive_pack_report_rejected(b""));
assert!(!receive_pack_report_rejected(b"zzzz"));
assert!(!receive_pack_report_rejected(b"0048")); // length, no payload
}
#[test]
fn git_expected_url_uses_tenant_host_not_config_host() {
let tenant_a = tenant("host-a.example", 1);
let tenant_b = tenant("host-b.example", 2);
let url_a = git_expected_url(
"wss://config-host.example",
&tenant_a,
"/git/owner/repo/info/refs?service=git-upload-pack",
)
.expect("recognized info/refs path");
let url_b = git_expected_url(
"wss://config-host.example",
&tenant_b,
"/git/owner/repo/info/refs?service=git-upload-pack",
)
.expect("recognized info/refs path");
assert_eq!(url_a, "https://host-a.example/git/owner/repo");
assert_eq!(url_b, "https://host-b.example/git/owner/repo");
assert_ne!(url_a, url_b);
let url_a_alt_config = git_expected_url(
"wss://different-config.example",
&tenant_a,
"/git/owner/repo/git-upload-pack",
)
.expect("recognized upload-pack path");
assert_eq!(url_a_alt_config, "https://host-a.example/git/owner/repo");
}
/// GitAuth host-bind bite: a token signed for community A's repo URL must
/// fail when the request Host resolved to community B. If `git_expected_url`
/// is changed back to `config.relay_url`'s host, the expected URL below
/// becomes A's URL and this wrongly verifies.
#[test]
fn git_nip98_rejects_token_signed_for_wrong_community_host() {
let keys = Keys::generate();
let signed_for_a = "https://host-a.example/git/alice/repo";
let event_json = git_nip98_event_json(&keys, signed_for_a, "GET");
let tenant_b = tenant("host-b.example", 2);
let expected_for_b = git_expected_url(
"wss://host-a.example",
&tenant_b,
"/git/alice/repo/info/refs?service=git-upload-pack",
)
.expect("recognized info/refs path");
let err = buzz_auth::nip98::verify_nip98_event(&event_json, &expected_for_b, "GET", None)
.expect_err("cross-host git NIP-98 token must be rejected");
assert!(
err.to_string().contains("URL mismatch"),
"expected URL-mismatch rejection, got {err}"
);
}
#[test]
fn git_nip98_accepts_token_signed_for_matching_community_host() {
let keys = Keys::generate();
let signed_for_a = "https://host-a.example/git/alice/repo";
let event_json = git_nip98_event_json(&keys, signed_for_a, "GET");
let tenant_a = tenant("host-a.example", 1);
let expected_for_a = git_expected_url(
"wss://different-config.example",
&tenant_a,
"/git/alice/repo/git-upload-pack",
)
.expect("recognized upload-pack path");
let pubkey =
buzz_auth::nip98::verify_nip98_event(&event_json, &expected_for_a, "GET", None)
.expect("matching-host git NIP-98 token must verify");
assert_eq!(pubkey, keys.public_key());
}
/// Split a pkt-line stream into `(len_prefix, payload)` frames, validating
/// that each 4-hex length counts itself and that `0000` is a flush.
fn parse_pkt_lines(bytes: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut i = 0;
while i + 4 <= bytes.len() {
let len_hex = std::str::from_utf8(&bytes[i..i + 4]).unwrap();
let len = usize::from_str_radix(len_hex, 16).unwrap();
if len == 0 {
out.push(Vec::new()); // flush marker
i += 4;
continue;
}
assert!(len >= 4, "pkt-line length must count its own 4 bytes");
let payload = bytes[i + 4..i + len].to_vec();
out.push(payload);
i += len;
}
assert_eq!(i, bytes.len(), "pkt-line stream must consume exactly");
out
}
#[test]
fn fast_path_eligible_branches_only() {
assert!(fast_path_eligible(&branches_only_manifest()));
}
#[test]
fn fast_path_rejects_any_tag() {
let mut m = branches_only_manifest();
m.refs.insert("refs/tags/v1".to_string(), oid_sha1());
// Any tag → subprocess fallback (can't peel annotated tags from manifest).
assert!(!fast_path_eligible(&m));
}
#[test]
fn fast_path_rejects_head_not_in_refs() {
let mut m = branches_only_manifest();
m.head = "refs/heads/nonexistent".to_string();
// HEAD must resolve to an advertised ref to emit symref=HEAD:<ref>.
assert!(!fast_path_eligible(&m));
}
#[test]
fn fast_path_rejects_unsafe_refname() {
let mut m = branches_only_manifest();
// A pkt-line-injecting refname (newline) must never reach the emit path;
// eligibility is the safety gate → subprocess fallback re-validates.
m.refs.insert("refs/heads/evil\nx".to_string(), oid_sha1());
assert!(!fast_path_eligible(&m));
}
#[test]
fn fast_path_rejects_malformed_oid() {
let mut m = branches_only_manifest();
// A non-hex / wrong-length oid must degrade to subprocess, not be emitted.
m.refs
.insert("refs/heads/bad".to_string(), "not-a-valid-oid".to_string());
assert!(!fast_path_eligible(&m));
}
#[test]
fn fast_path_rejects_overlong_refname() {
let mut m = branches_only_manifest();
// A refname past MAX_FAST_PATH_REFNAME_LEN would overflow the 4-hex
// pkt-line length prefix; degrade to subprocess instead.
let long = format!("refs/heads/{}", "a".repeat(MAX_FAST_PATH_REFNAME_LEN));
m.refs.insert(long, oid_sha1());
assert!(!fast_path_eligible(&m));
}
#[test]
fn pkt_line_encodes_max_payload_without_overflow() {
// The largest payload that still fits a 4-hex frame length emits a
// single valid `ffff` (= 0xffff) frame — the boundary the guard
// protects, exercised on the safe side.
let payload = vec![b'a'; PKT_LINE_MAX_PAYLOAD];
let mut out = Vec::new();
pkt_line(&mut out, &payload);
assert_eq!(&out[..4], b"ffff", "frame length prefix");
assert_eq!(out.len(), 4 + PKT_LINE_MAX_PAYLOAD, "no truncation");
let frames = parse_pkt_lines(&out);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].len(), PKT_LINE_MAX_PAYLOAD);
}
// The overlong-payload guard degrades to an empty `0004` pkt-line in every
// build profile (no 5-hex length, stream stays parseable) and logs at
// `error`. Non-panicking, so it's testable directly.
#[test]
fn pkt_line_overlong_payload_degrades_to_empty_frame() {
let payload = vec![b'a'; PKT_LINE_MAX_PAYLOAD + 1];
let mut out = Vec::new();
pkt_line(&mut out, &payload);
// Empty pkt-line, payload dropped — never a malformed 5-hex length.
assert_eq!(out, b"0004");
}
#[test]
fn advertisement_framing_matches_git_oracle_shape() {
let body = build_upload_pack_advertisement(&branches_only_manifest());
let frames = parse_pkt_lines(&body);
// Layout: [service header, flush, HEAD line, feature, main, flush]
assert_eq!(frames.len(), 6, "frame count");
// 0: service header
assert_eq!(frames[0], b"# service=git-upload-pack\n");
// 1: flush after service header
assert!(frames[1].is_empty());
// 2: HEAD line — "<oid> HEAD\0<caps>\n"
let head = &frames[2];
let nul = head.iter().position(|&b| b == 0).expect("NUL in HEAD line");
assert_eq!(&head[..nul], format!("{} HEAD", oid_sha1()).as_bytes());
let caps = std::str::from_utf8(&head[nul + 1..head.len() - 1]).unwrap();
assert_eq!(*head.last().unwrap(), b'\n');
// symref + object-format are the load-bearing caps.
assert!(caps.contains("symref=HEAD:refs/heads/main"));
assert!(caps.contains("object-format=sha1"));
assert!(caps.contains("side-band-64k"));
// 3,4: refs sorted ascending — feature before main (BTreeMap order),
// each "<oid> <refname>\n", no NUL.
assert_eq!(
frames[3],
format!("{} refs/heads/feature\n", oid_sha1()).into_bytes()
);
assert_eq!(
frames[4],
format!("{} refs/heads/main\n", oid_sha1()).into_bytes()
);
// 5: trailing flush
assert!(frames[5].is_empty());
}
#[test]
fn advertisement_picks_sha256_from_oid_width() {
let mut m = branches_only_manifest();
let oid256 = "a".repeat(64);
m.refs.insert("refs/heads/main".to_string(), oid256.clone());
m.refs.insert("refs/heads/feature".to_string(), oid256);
let body = build_upload_pack_advertisement(&m);
let caps = String::from_utf8_lossy(&body);
assert!(caps.contains("object-format=sha256"));
}
}
#[cfg(test)]
mod sec005_postgres_tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
// ── Pure decision helpers ────────────────────────────────────────────
#[test]
fn read_role_allows_every_recognized_role() {
for role in ["owner", "admin", "member", "guest", "bot"] {
assert!(read_role_allows(Some(role)), "role {role:?} must allow");
}
}
#[test]
fn read_role_denies_non_members_and_unknown_roles() {
assert!(!read_role_allows(None), "no membership row must deny");
assert!(
!read_role_allows(Some("superuser")),
"unrecognized role must deny (fail-closed)"
);
assert!(!read_role_allows(Some("")), "empty role must deny");
}
#[test]
fn durable_ban_denies_git_even_with_otherwise_valid_auth() {
let restriction = buzz_db::moderation::RestrictionState {
banned: true,
muted_until: None,
};
assert_eq!(enforce_git_ban(&restriction), Err(StatusCode::FORBIDDEN));
}
#[test]
fn timeout_without_ban_does_not_revoke_git_access() {
let restriction = buzz_db::moderation::RestrictionState {
banned: false,
muted_until: Some(chrono::Utc::now()),
};
assert_eq!(enforce_git_ban(&restriction), Ok(()));
}
fn restriction(banned: bool) -> buzz_db::moderation::RestrictionState {
buzz_db::moderation::RestrictionState {
banned,
muted_until: None,
}
}
// ── Agent → owner ban cascade ────────────────────────────────────────
//
// Git accepts NIP-OA attestations on the signed NIP-98 token, so an agent
// key can act for its owner (`deny_banned_git_principal`). The NIP-42 gate
// in `handlers::auth` cascades the ban check to the proven owner for that
// reason, and Git must agree: if only the presented key were checked, a
// banned human would keep clone and push access through any agent key.
#[test]
fn banned_owner_denies_git_for_an_otherwise_clear_agent() {
assert_eq!(
enforce_git_ban_cascade(&restriction(false), Some(&restriction(true))),
Err(StatusCode::FORBIDDEN),
"an agent must inherit its proven owner's ban"
);
}
#[test]
fn banned_agent_denies_git_whatever_the_owner_state() {
for owner in [None, Some(restriction(false)), Some(restriction(true))] {
assert_eq!(
enforce_git_ban_cascade(&restriction(true), owner.as_ref()),
Err(StatusCode::FORBIDDEN),
"a directly banned agent must be denied"
);
}
}
#[test]
fn clear_agent_and_clear_owner_allow_git() {
assert_eq!(
enforce_git_ban_cascade(&restriction(false), Some(&restriction(false))),
Ok(())
);
}
#[test]
fn clear_agent_without_attested_owner_allows_git() {
// No NIP-OA tag on the request: nothing to inherit, so the agent's own
// state decides. A missing owner must not read as a ban.
assert_eq!(enforce_git_ban_cascade(&restriction(false), None), Ok(()));
}
fn announcement(keys: &Keys, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::Custom(30617), "")
.tags(tags)
.sign_with_keys(keys)
.expect("sign 30617")
}
// Binding *parse* semantics (first-tag fails-closed, duplicate-tag
// ambiguity, malformed vs. absent) are unit-tested where the resolver
// lives: `super::super::binding`. The tests below prove the *gate* wires
// each resolver outcome to the right response — allow, generic denial
// body, or the author remediation body — which the resolver tests
// cannot see.
/// Collapse an `authorize_git_read` denial to `(status, body)` so tests
/// can assert on the exact bytes a git client would see. A blind
/// `.is_err()` cannot distinguish the generic 404 from the remediation
/// 404 — and that distinction IS the security property.
async fn denial_parts<T: std::fmt::Debug>(result: Result<T, Response>) -> (StatusCode, String) {
let response = result.expect_err("expected a denial");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read denial body");
(
status,
String::from_utf8(bytes.to_vec()).expect("utf-8 body"),
)
}
const GENERIC_DENIAL: &str = "repository not found";
// ── authorize_git_read matrix (requires Postgres) ────────────────────
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
async fn setup_db() -> buzz_db::Db {
let url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_string());
let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB");
buzz_db::Db::from_pool(pool)
}
/// How the fixture's kind:30617 binds (or fails to bind) a channel.
enum Binding {
/// `buzz-channel` tag carrying the fixture channel's UUID.
Channel,
/// No `buzz-channel` tag at all.
Missing,
/// `buzz-channel` tag whose value is not a UUID.
Malformed,
/// `buzz-channel` tag carrying a well-formed UUID that names no
/// channel. The resolver reports `Bound`; the membership lookup
/// (whose SQL joins `channels … deleted_at IS NULL`) then returns
/// no role — the deliberate phase-1 posture for dead bindings.
UnknownChannel,
}
struct RepoFixture {
db: buzz_db::Db,
community: buzz_core::CommunityId,
channel: uuid::Uuid,
owner_keys: Keys,
owner_hex: String,
member_keys: Keys,
repo: String,
}
/// Community + channel + one plain member + a kind:30617 announcement.
/// The repo owner is a *different* key that is not a channel member —
/// deliberately, to pin "no repo-owner bypass".
async fn setup_repo(binding: Binding) -> RepoFixture {
let db = setup_db().await;
let host = format!("sec005-{}.example", uuid::Uuid::new_v4().simple());
let community = db
.ensure_configured_community(&host)
.await
.expect("community")
.id;
let owner_keys = Keys::generate();
let member_keys = Keys::generate();
let creator = Keys::generate(); // channel creator, distinct from repo owner
let creator_pk = creator.public_key().to_bytes().to_vec();
let member_pk = member_keys.public_key().to_bytes().to_vec();
db.ensure_user(community, &creator_pk).await.expect("user");
db.ensure_user(community, &member_pk).await.expect("user");
let channel = uuid::Uuid::new_v4();
db.create_channel_with_id(
community,
channel,
&format!("ch-{}", channel.simple()),
buzz_db::channel::ChannelType::Stream,
buzz_db::channel::ChannelVisibility::Open,
None,
&creator_pk,
None,
)
.await
.expect("channel");
db.add_member(
community,
channel,
&member_pk,
buzz_core::channel::MemberRole::Member,
Some(&creator_pk),
)
.await
.expect("member");
let repo = format!("repo-{}", uuid::Uuid::new_v4().simple());
let mut tags = vec![Tag::parse(["d", &repo]).unwrap()];
match binding {
Binding::Channel => {
tags.push(Tag::parse(["buzz-channel", &channel.to_string()]).unwrap());
}
Binding::Missing => {}
Binding::Malformed => {
tags.push(Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap());
}
Binding::UnknownChannel => {
tags.push(Tag::parse(["buzz-channel", &uuid::Uuid::new_v4().to_string()]).unwrap());
}
}
let event = announcement(&owner_keys, tags);
db.insert_event(community, &event, None)
.await
.expect("30617");
let owner_hex = owner_keys.public_key().to_hex();
RepoFixture {
db,
community,
channel,
owner_keys,
owner_hex,
member_keys,
repo,
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_allows_current_member_denies_removed_and_owner_bypass() {
let f = setup_repo(Binding::Channel).await;
// Current member: allowed.
let member = f.member_keys.public_key();
assert!(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
.await
.is_ok(),
"current member must be allowed to read"
);
// Never-a-member caller: denied.
let stranger = Keys::generate().public_key();
assert!(
authorize_git_read(&f.db, f.community, &stranger, &f.owner_hex, &f.repo)
.await
.is_err(),
"non-member must be denied"
);
// Member removed → denied. THE finding-005 exploit shape.
let member_pk = member.to_bytes().to_vec();
f.db.remove_member(f.community, f.channel, &member_pk, &member_pk)
.await
.expect("self-remove");
assert!(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
.await
.is_err(),
"removed member must be denied"
);
// No repo-owner bypass: the announcement author is not a channel
// member and must be denied too.
let owner = f.owner_keys.public_key();
assert!(
authorize_git_read(&f.db, f.community, &owner, &f.owner_hex, &f.repo)
.await
.is_err(),
"repo owner outside the channel must be denied (no owner bypass)"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() {
// Missing buzz-channel tag → deny even for a channel member, with
// the generic body: the remediation carve-out is author-only.
let f = setup_repo(Binding::Missing).await;
let member = f.member_keys.public_key();
let (status, body) = denial_parts(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo).await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"unbound repo read by a NON-author must get the generic body — \
remediation for anyone but the announcement author leaks repo existence"
);
// Malformed buzz-channel tag → deny with the generic body EVEN FOR
// THE AUTHOR. This is the assertion that pins the carve-out to
// NotBound: if it ever fires on Broken, this fails on bytes, not
// on Ok/Err (which cannot see the difference).
let g = setup_repo(Binding::Malformed).await;
let g_owner = g.owner_keys.public_key();
let (status, body) = denial_parts(
authorize_git_read(&g.db, g.community, &g_owner, &g.owner_hex, &g.repo).await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"broken binding must stay generic even for the author (ambiguity fails closed)"
);
// Well-formed UUID naming a nonexistent channel → resolver says
// Bound, membership lookup finds nothing → generic denial for
// everyone, author included. The dead-channel case must be
// indistinguishable from non-membership (phase-1 posture; ingest
// validation closes the front door in phase 2).
let u = setup_repo(Binding::UnknownChannel).await;
let u_owner = u.owner_keys.public_key();
let (status, body) = denial_parts(
authorize_git_read(&u.db, u.community, &u_owner, &u.owner_hex, &u.repo).await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"binding to a nonexistent channel must deny generically, even for the author"
);
// Nonexistent announcement → deny.
let (status, body) = denial_parts(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo").await,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
body, GENERIC_DENIAL,
"nonexistent repo must deny generically"
);
// Owner-mismatch: URL owner differs from announcement author → deny.
let impostor_hex = Keys::generate().public_key().to_hex();
assert!(
authorize_git_read(&f.db, f.community, &member, &impostor_hex, &f.repo)
.await
.is_err(),
"URL owner that never announced this repo must deny"
);
// Invalid owner hex in URL → deny (never panics).
assert!(
authorize_git_read(&f.db, f.community, &member, "zz-not-hex", &f.repo)
.await
.is_err(),
"malformed owner hex must deny"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_gives_author_of_unbound_repo_remediation_body() {
// Issue #3527: the author of a never-bound announcement is the one
// identity that can fix it (30617 is keyed by (author, d)) and the
// one identity remediation cannot leak anything to. Status must stay
// 404 — identical to every other denial — with the bind command in
// the body.
let f = setup_repo(Binding::Missing).await;
let author = f.owner_keys.public_key();
let response = authorize_git_read(&f.db, f.community, &author, &f.owner_hex, &f.repo)
.await
.expect_err("unbound repo must still deny its author");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// Guard against a future "tidy" into Json(...) or a custom
// IntoResponse: git prints `remote:` lines only for text/plain
// bodies — any other content-type makes the remediation silently
// invisible in the user's terminal with no failing assertion.
assert_eq!(
response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
"remediation body must stay text/plain or git clients will swallow it"
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read remediation body");
let body = String::from_utf8(bytes.to_vec()).expect("utf-8 body");
assert!(
body.starts_with(&format!("run: buzz repos bind --id {}", f.repo)),
"remediation must lead with the actionable command (got {body:?})"
);
assert_ne!(body, GENERIC_DENIAL);
// Same repo, same state, different caller: a member of some channel
// who is not the author still gets the generic body.
let member = f.member_keys.public_key();
let (_, body) = denial_parts(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo).await,
)
.await;
assert_eq!(body, GENERIC_DENIAL, "remediation is author-only");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_gate_follows_current_announcement_not_stale_registry() {
// Max's registry/pointer concern: a soft-deleted 30617 can leave the
// `git_repo_names` reservation and the manifest pointer alive. Reads
// must follow the current authoritative announcement — once it's
// deleted, the gate denies even a current channel member.
let f = setup_repo(Binding::Channel).await;
let member = f.member_keys.public_key();
assert!(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
.await
.is_ok(),
"precondition: member allowed while announcement is live"
);
let owner_pk = f.owner_keys.public_key().to_bytes().to_vec();
// Tombstone timestamped after the announcement, per NIP-09's
// at-or-before scoping in `soft_delete_by_coordinate`.
let deleted =
f.db.soft_delete_by_coordinate(
f.community,
30617,
&owner_pk,
&f.repo,
chrono::Utc::now().timestamp() + 60,
)
.await
.expect("soft delete 30617");
assert!(deleted, "precondition: a live announcement row was deleted");
assert!(
authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
.await
.is_err(),
"deleted announcement must deny reads even for channel members"
);
}
// ── Ban gate wiring (requires Postgres) ──────────────────────────────
//
// The pure tests above fix the decision table; these prove the gate is
// actually wired to the durable store — that it reads the real ban row,
// resolves the NIP-OA owner from a live attestation, and fails closed when
// the store is unreachable. `deny_banned_git_principal` runs inside the
// `GitAuth` extractor, which every Git route (`info/refs`, `git-upload-pack`,
// `git-receive-pack`) goes through, so advertise, fetch and push all
// inherit these outcomes.
/// Community + a ban actor, without the channel/repo fixture the read-gate
/// tests need — the ban gate runs before any repo is resolved.
async fn setup_ban_community() -> (buzz_db::Db, buzz_core::CommunityId, Vec<u8>) {
let db = setup_db().await;
let host = format!("ban-git-{}.example", uuid::Uuid::new_v4().simple());
let community = db
.ensure_configured_community(&host)
.await
.expect("community")
.id;
let actor = Keys::generate().public_key().to_bytes().to_vec();
db.ensure_user(community, &actor).await.expect("actor");
(db, community, actor)
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn ban_gate_denies_banned_member_and_allows_clear_member() {
let (db, community, actor) = setup_ban_community().await;
let member = Keys::generate();
let member_pk = member.public_key().to_bytes().to_vec();
db.ensure_user(community, &member_pk).await.expect("member");
assert!(
deny_banned_git_principal(&db, community, &member.public_key(), None, None)
.await
.is_ok(),
"precondition: an unbanned member passes the git ban gate"
);
db.ban_community_member(community, &member_pk, &actor, Some("test"), None)
.await
.expect("ban");
let (status, body) = denial_parts(
deny_banned_git_principal(&db, community, &member.public_key(), None, None).await,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body, "blocked: banned from this community");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn ban_gate_cascades_to_a_banned_nip_oa_owner() {
let (db, community, actor) = setup_ban_community().await;
let owner = Keys::generate();
let agent = Keys::generate();
let owner_pk = owner.public_key().to_bytes().to_vec();
let agent_pk = agent.public_key().to_bytes().to_vec();
db.ensure_user(community, &owner_pk).await.expect("owner");
db.ensure_user(community, &agent_pk).await.expect("agent");
// A real attestation: the gate must verify it, not trust a claim.
let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "kind=9")
.expect("auth tag");
assert!(
deny_banned_git_principal(
&db,
community,
&agent.public_key(),
Some(&auth_tag),
Some(200),
)
.await
.is_ok(),
"precondition: neither agent nor owner is banned"
);
// Ban the human only. The agent's own row stays clear.
db.ban_community_member(community, &owner_pk, &actor, Some("test"), None)
.await
.expect("ban owner");
let (status, _) = denial_parts(
deny_banned_git_principal(
&db,
community,
&agent.public_key(),
Some(&auth_tag),
Some(200),
)
.await,
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"banning the owner must revoke its agent's git access"
);
// An unattested request from the same agent key is unaffected: the
// cascade must follow a verified owner, not punish every agent.
assert!(
deny_banned_git_principal(&db, community, &agent.public_key(), None, None)
.await
.is_ok(),
"without an attestation there is no owner to inherit from"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn ban_gate_fails_closed_with_503_when_the_store_is_unreachable() {
let url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_string());
let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB");
let db = buzz_db::Db::from_pool(pool.clone());
// Closing the pool is the cheapest faithful stand-in for the
// restriction store being unavailable mid-request.
pool.close().await;
let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4());
let (status, body) = denial_parts(
deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None, None)
.await,
)
.await;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"a store outage must deny as retryable, never allow and never claim a 403"
);
assert_eq!(body, "authorization unavailable");
}
}
// ── R4 Off-mode precedence regression tests ───────────────────────────────
//
// Proves that in Off mode, missing/malformed Authorization headers are rejected
// with 401 + WWW-Authenticate BEFORE any tenant lookup. [FI-INV-15]
//
// The `parse_git_auth_header` helper is the single source of this behavior;
// these tests cover all four cases Thufir specified.
//
// Mutation evidence for all tests: replacing the Off-mode early-exit
// (`if matches!(mode, NipFiMode::Off) { parse_git_auth_header(...)?; }`)
// with a no-op causes the request to proceed to `bind_community()`. On an
// unmapped host that returns 404 `repository not found` — the test's
// status assertion fires (404 ≠ 401). On a mapped host the parse
// runs inside the NIP-FI closure, but at that point the `admit_nip_fi_http`
// wrapper (Off mode) propagates the legacy response — so the status still
// matches. Only the unmapped-host cases truly distinguish the regression.
// Both cases are included so the full invariant (credential-before-DB) is
// visible in the test record.
#[cfg(test)]
mod off_mode_precedence_tests {
use super::*;
use axum::http::HeaderMap;
fn make_headers(auth: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(a) = auth {
h.insert(
header::AUTHORIZATION,
a.parse().expect("valid header value"),
);
}
h
}
// ── Case A: missing Authorization header ─────────────────────────────
/// Off mode, no Authorization header → 401 + WWW-Authenticate challenge.
///
/// Scope: this test exercises `parse_git_auth_header` directly, not the
/// full `GitAuth::from_request_parts` path. It proves the parser
/// rejects a missing header with the correct status and challenge.
///
/// Falsifying mutation: return `Ok(())` from `parse_git_auth_header`
/// when no Authorization header is present → `unwrap_err()` panics.
#[test]
fn off_mode_missing_auth_header_returns_401_with_challenge() {
let headers = make_headers(None);
let err = parse_git_auth_header(&headers, "GET").unwrap_err();
assert_eq!(
err.status(),
StatusCode::UNAUTHORIZED,
"Off mode: missing Authorization must yield 401, not a tenant-lookup result"
);
let challenge = err
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
challenge.contains("Nostr realm=\"buzz\""),
"Off mode: missing Authorization must include WWW-Authenticate: Nostr challenge; got {challenge:?}"
);
}
// ── Case B: wrong scheme (not "Nostr ") ──────────────────────────────
/// Off mode, wrong Authorization scheme → 401 + challenge.
///
/// Scope: parser-only test (calls `parse_git_auth_header` directly).
/// Falsifying mutations: return `Ok(())` for non-Nostr schemes, or
/// emit 403 instead of 401 — status assertion fires; or omit the
/// WWW-Authenticate header — challenge assertion fires.
#[test]
fn off_mode_wrong_scheme_returns_401_with_challenge() {
let headers = make_headers(Some("Bearer sometoken"));
let err = parse_git_auth_header(&headers, "GET").unwrap_err();
assert_eq!(
err.status(),
StatusCode::UNAUTHORIZED,
"Off mode: wrong auth scheme must yield 401"
);
let challenge = err
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
challenge.contains("Nostr realm=\"buzz\""),
"Off mode: wrong scheme must include Nostr challenge"
);
}
// ── Case C: invalid base64 ────────────────────────────────────────────
/// Off mode, Authorization: Nostr <invalid-base64> → 401.
///
/// Scope: parser-only test. Falsifying mutation: accept invalid
/// base64 and return `Ok(())` → `unwrap_err()` panics.
#[test]
fn off_mode_invalid_base64_returns_401() {
let headers = make_headers(Some("Nostr !!!not-base64!!!"));
let err = parse_git_auth_header(&headers, "GET").unwrap_err();
assert_eq!(
err.status(),
StatusCode::UNAUTHORIZED,
"Off mode: invalid base64 must yield 401"
);
}
// ── Case D: valid base64 but invalid UTF-8 bytes ─────────────────────
/// Off mode, Authorization: Nostr <valid-base64-but-not-utf8> → 401.
///
/// Scope: parser-only test. Falsifying mutation: skip UTF-8 check,
/// return `Ok(())` → `unwrap_err()` panics.
#[test]
fn off_mode_invalid_utf8_returns_401() {
// 0xC3 0x28 is invalid UTF-8.
let bad_utf8 = base64::engine::general_purpose::STANDARD.encode([0xC3u8, 0x28]);
let headers = make_headers(Some(&format!("Nostr {bad_utf8}")));
let err = parse_git_auth_header(&headers, "GET").unwrap_err();
assert_eq!(
err.status(),
StatusCode::UNAUTHORIZED,
"Off mode: non-UTF-8 base64 payload must yield 401"
);
}
// ── Positive control ──────────────────────────────────────────────────
/// Valid Nostr base64 JSON payload passes syntax validation.
///
/// Scope: parser-only test. A structurally correct credential passes
/// `parse_git_auth_header`, allowing the request to proceed to tenant
/// lookup in the full path.
///
/// Falsifying mutation: always return `Err(...)` from
/// `parse_git_auth_header` → `is_ok()` fails and the assertion fires.
/// Without this positive control, an always-denying parser could pass
/// all four negative cases above while also breaking valid requests.
#[test]
fn off_mode_valid_nostr_token_passes_syntax_check() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let keys = Keys::generate();
let tags = vec![
Tag::parse(["u", "http://example.local/git/abc/def"]).unwrap(),
Tag::parse(["method", "GET"]).unwrap(),
];
let event = EventBuilder::new(Kind::Custom(27235), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let token = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap())
);
let headers = make_headers(Some(&token));
assert!(
parse_git_auth_header(&headers, "GET").is_ok(),
"Off mode: a valid Nostr token must pass syntax validation"
);
}
// ── Router-level Off-precedence tests (require Postgres) ─────────────
//
// These tests go through `git_router` → `GitAuth::from_request_parts`
// and prove that the Off-mode early-exit at transport.rs:100-102 fires
// BEFORE `bind_community()`.
//
// Key falsifiability: the unmapped-host cases assert 401. Deleting lines
// 100-102 causes `bind_community()` to run for the unmapped host and
// return 404 — the status assertions fire. Parser-only unit tests above
// cannot prove this ordering because they never call `from_request_parts`.
#[cfg(test)]
mod postgres_tests {
use super::*;
use axum::body::to_bytes;
use tower::ServiceExt;
const UNMAPPED_HOST: &str = "off-prec-unmapped.git.test.invalid";
// Valid 64-hex owner (all zeros except last digit = 1) so validate_repo_id
// passes owner validation in the handler body. The NIP-FI gate fires in
// GitAuth::from_request_parts BEFORE validate_repo_id in Enforce mode;
// the valid owner ensures tests that probe post-auth behavior see the
// correct downstream path.
const OWNER_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
const GIT_PATH: &str = concat!(
"/git/0000000000000000000000000000000000000000000000000000000000000001",
"/myrepo/info/refs?service=git-upload-pack"
);
async fn off_mode_state() -> Option<Arc<AppState>> {
let mut config = crate::config::Config::for_test();
config.nip_fi.mode = buzz_auth::NipFiMode::Off;
config.require_auth_token = false;
config.require_relay_membership = false;
config.redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1
let pool = sqlx::PgPool::connect(&config.database_url).await.ok()?;
let db = buzz_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.ok()?;
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.ok()?,
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
let (state, _) = AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
nostr::Keys::generate(),
media_storage,
);
Some(Arc::new(state))
}
/// A NIP-98-authenticated Git request whose restriction lookup fails
/// answers 503, not the 403 a real ban or non-member gets. Enforce sends
/// the canonical NIP-FI `authorization unavailable` bytes; Off keeps the
/// legacy text. Mutations: treat every membership-step error as 403, or
/// skip the Enforce mapping → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn failed_restriction_lookup_is_503_not_403() {
use base64::Engine as _;
struct AdmitAnyAssertion(nostr::PublicKey);
impl buzz_auth::VerifyAssertion for AdmitAnyAssertion {
fn verify_assertion(
&self,
_token: &str,
_community: &buzz_auth::CommunityBinding,
) -> Result<buzz_auth::VerifiedAssertion, buzz_auth::VerifierError>
{
Ok(buzz_auth::VerifiedAssertion::new_for_test(self.0))
}
}
for mode in [buzz_auth::NipFiMode::Off, buzz_auth::NipFiMode::Enforce] {
let Some(mut state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let host = format!("git-ban-lookup-{}.test", uuid::Uuid::new_v4().simple());
state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community");
let (db, admin, schema) =
crate::test_support::restriction_lookup_failing_db().await;
let keys = nostr::Keys::generate();
{
let s = Arc::get_mut(&mut state).expect("fixture state is uniquely owned");
s.db = db;
Arc::make_mut(&mut s.config).nip_fi.mode = mode;
Arc::make_mut(&mut s.config).nip_fi.communities =
crate::nip_fi_core::test_support::any_host(
"https://git-ban-lookup.relay.invalid",
);
s.nip_fi_verifier = Some(Arc::new(AdmitAnyAssertion(keys.public_key())));
}
let scheme = if state.config.relay_url.starts_with("wss://") {
"https"
} else {
"http"
};
let signed_url = format!("{scheme}://{host}/git/{OWNER_HEX}/myrepo");
let event_json = serde_json::to_string(
&nostr::EventBuilder::new(nostr::Kind::HttpAuth, "")
.tags([
nostr::Tag::parse(["u", signed_url.as_str()]).expect("u tag"),
nostr::Tag::parse(["method", "GET"]).expect("method tag"),
])
.sign_with_keys(&keys)
.expect("sign NIP-98 event"),
)
.expect("serialize");
let auth = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(event_json)
);
let req = axum::http::Request::builder()
.method("GET")
.uri(GIT_PATH)
.header("host", &host)
.header("authorization", &auth)
.header(buzz_auth::CLIENT_ATTACHED_HEADER, "Bearer any.valid.token")
.body(axum::body::Body::empty())
.expect("build request");
let resp = git_router(Arc::clone(&state))
.oneshot(req)
.await
.expect("router oneshot");
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
let expected: &[u8] = match mode {
buzz_auth::NipFiMode::Off => b"authorization unavailable",
_ => b"authorization unavailable\n",
};
assert_eq!(
status,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"{mode:?}: {body:?}"
);
assert_eq!(
content_type.as_deref(),
Some("text/plain; charset=utf-8"),
"{mode:?}"
);
assert_eq!(body.as_ref(), expected, "{mode:?}");
let _ = sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
.execute(&admin)
.await;
}
}
async fn git_request(
state: Arc<AppState>,
host: &str,
auth: Option<&str>,
) -> (axum::http::StatusCode, axum::http::HeaderMap, bytes::Bytes) {
let mut builder = axum::http::Request::builder()
.method("GET")
.uri(GIT_PATH)
.header("host", host);
if let Some(a) = auth {
builder = builder.header("authorization", a);
}
let req = builder
.body(axum::body::Body::empty())
.expect("build request");
let resp = git_router(Arc::clone(&state))
.oneshot(req)
.await
.expect("router oneshot");
let status = resp.status();
let headers = resp.headers().clone();
let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
(status, headers, body)
}
// ── Unmapped host — missing auth: must be 401 before DB lookup ────
//
// An unmapped host has no community row. If the Off-mode early-exit
// is removed, `bind_community()` returns 404 for this host.
// The assertion fires because 404 ≠ 401.
//
// Falsifying mutation: delete the `if matches!(mode, Off)` block
// (transport.rs:101-104) → unmapped host proceeds to `bind_community()` → 404.
//
// NOTE: missing-auth and wrong-scheme carry WWW-Authenticate; bad-base64
// and bad-UTF8 do NOT (those use `into_response()` without the header).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_unmapped_host_missing_auth_returns_401_before_db() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let (status, headers, body) = git_request(state, UNMAPPED_HOST, None).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Off mode + unmapped host + missing auth must yield 401 BEFORE \
bind_community (not 404). \
Falsifying mutation: delete Off-mode early-exit (transport.rs:101-104) \
→ bind_community returns 404 for unmapped host → assertion fires."
);
assert_eq!(
body.as_ref(),
b"missing Authorization header",
"missing-auth 401 body must be exact 'missing Authorization header'"
);
// FI-INV-15: Off-mode bytes match origin/main, whose legacy 401
// builder sets no Content-Type on this response.
assert!(
headers.get("content-type").is_none(),
"missing-auth 401 must carry no Content-Type (legacy Off bytes); got {:?}",
headers.get("content-type")
);
let challenge = headers
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
challenge,
"Nostr realm=\"buzz\", method=\"GET\"",
"missing-auth 401 must carry exact WWW-Authenticate: Nostr realm=\"buzz\", method=\"GET\"; got {challenge:?}"
);
}
// ── Unmapped host — wrong scheme: must be 401 before DB lookup ────
//
// Same falsifiability as the missing-auth case.
//
// Falsifying mutation: delete transport.rs:101-104 → 404.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_unmapped_host_wrong_scheme_returns_401_before_db() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let (status, headers, body) =
git_request(state, UNMAPPED_HOST, Some("Bearer token")).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Off mode + unmapped host + wrong auth scheme must yield 401 before \
bind_community. \
Falsifying mutation: delete transport.rs:101-104 → 404."
);
assert_eq!(
body.as_ref(),
b"expected Authorization: Nostr <base64>",
"wrong-scheme 401 body must be 'expected Authorization: Nostr <base64>'"
);
assert!(
headers.get("content-type").is_none(),
"wrong-scheme 401 must carry no Content-Type (legacy Off bytes); got {:?}",
headers.get("content-type")
);
let challenge_ws = headers
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
challenge_ws, "Nostr realm=\"buzz\", method=\"GET\"",
"wrong-scheme 401 must carry exact WWW-Authenticate: \
Nostr realm=\"buzz\", method=\"GET\"; got {challenge_ws:?}"
);
}
// ── Unmapped host — invalid base64: must be 401 before DB lookup ──
//
// bad-base64 and bad-UTF8 use `into_response()` — NO WWW-Authenticate.
// Falsifying mutation: delete transport.rs:101-104 → 404.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_unmapped_host_invalid_base64_returns_401_before_db() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let (status, headers, body) =
git_request(state, UNMAPPED_HOST, Some("Nostr !!!not-base64!!!")).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Off mode + unmapped host + invalid base64 must yield 401 before \
bind_community. \
Falsifying mutation: delete transport.rs:101-104 → 404."
);
assert_eq!(
body.as_ref(),
b"invalid base64",
"invalid-base64 401 body must be exact 'invalid base64'"
);
assert!(
headers.get("www-authenticate").is_none(),
"invalid-base64 401 MUST NOT carry WWW-Authenticate \
(uses into_response(), not the WWW-Authenticate builder path)"
);
}
// ── Unmapped host — bad UTF-8 payload: must be 401 before DB lookup ─
//
// A Nostr token where base64 decodes to non-UTF8 bytes triggers the
// UTF-8 guard in `parse_git_auth_header_full` (transport.rs:379-380).
// Body: "invalid utf-8"; NO WWW-Authenticate (uses `into_response()`).
//
// Falsifying mutation: delete transport.rs:101-104 → 404.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_unmapped_host_bad_utf8_returns_401_before_db() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
// base64-encode non-UTF8 bytes (0xff 0xfe is an invalid UTF-8 start)
let bad_utf8_b64 = base64::engine::general_purpose::STANDARD.encode(b"\xff\xfe\x00");
let (status, headers, body) =
git_request(state, UNMAPPED_HOST, Some(&format!("Nostr {bad_utf8_b64}"))).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Off mode + unmapped host + bad-UTF8 payload must yield 401 before \
bind_community. \
Falsifying mutation: delete transport.rs:101-104 → 404."
);
assert_eq!(
body.as_ref(),
b"invalid utf-8",
"bad-UTF8 401 body must be exact 'invalid utf-8'"
);
assert!(
headers.get("www-authenticate").is_none(),
"bad-UTF8 401 MUST NOT carry WWW-Authenticate \
(uses into_response(), not the WWW-Authenticate builder path)"
);
}
// ── Mapped host — missing auth: compatibility control ──────────────
//
// Proves the same missing-auth behavior holds for mapped hosts.
// Compatibility control: the Off-mode early-exit (`transport.rs:101-104`)
// and the Enforce-mode NIP-FI closure both route through
// `parse_git_auth_header_full()`, which produces the same 401 + challenge.
//
// Falsifying mutation: replace `parse_git_auth_header` with always-pass
// → missing auth is not caught → request proceeds to URL verification
// → different status or body.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_mapped_host_missing_auth_returns_401() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let host = format!(
"off-prec-mapped-{}.git.test.invalid",
uuid::Uuid::new_v4().simple()
);
state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community");
let (status, headers, body) = git_request(state, &host, None).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Off mode + mapped host + missing auth must yield 401 from Off-mode early-exit \
(compatibility control: same response via NIP-FI closure in Enforce mode). \
Falsifying mutation: skip parse_git_auth_header for missing auth → different error."
);
assert_eq!(
body.as_ref(),
b"missing Authorization header",
"mapped-host missing-auth 401 body must be 'missing Authorization header'"
);
let challenge = headers
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
challenge, "Nostr realm=\"buzz\", method=\"GET\"",
"mapped-host missing-auth 401 must carry exact WWW-Authenticate: Nostr realm=\"buzz\", method=\"GET\"; got {challenge:?}"
);
}
// ── Mapped host — invalid base64: compatibility control ───────────
//
// Proves the bad-base64 check holds for mapped hosts.
// `parse_git_auth_header_full()` handles bad base64 and returns 401 (no
// WWW-Authenticate). This is a compatibility assertion: both the Off-mode
// early-exit and the Enforce-mode NIP-FI closure call the same function, so
// the same 401 body is produced in both modes.
//
// Falsifying mutation: remove `parse_git_auth_header` error for bad base64
// → bad-base64 request passes syntax check → URL verification fails differently.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_mapped_host_invalid_base64_returns_401() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let host = format!(
"off-prec-mapped-b64-{}.git.test.invalid",
uuid::Uuid::new_v4().simple()
);
state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community");
let (status, headers, body) =
git_request(state, &host, Some("Nostr !!!not-base64!!!")).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Off mode + mapped host + invalid base64 must yield 401. \
Falsifying mutation: delete transport.rs:101-104 → different error."
);
assert_eq!(
body.as_ref(),
b"invalid base64",
"mapped-host invalid-base64 401 body must be 'invalid base64'"
);
assert!(
headers.get("www-authenticate").is_none(),
"invalid-base64 401 MUST NOT carry WWW-Authenticate"
);
}
// ── Positive control: valid syntax with unmapped host reaches DB ──
//
// A syntactically valid Nostr token PASSES the Off-mode early-exit
// and proceeds to `bind_community()`. The unmapped host then yields
// 404 — proving the early-exit was NOT the blocker.
//
// Falsifying mutation: always-deny `parse_git_auth_header` regardless
// of input → this control returns 401 instead of 404 → assertion fires.
// The negative cases above prove the opposite direction.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn off_mode_unmapped_host_valid_syntax_reaches_db_and_returns_404() {
let Some(state) = off_mode_state().await else {
panic!("local Postgres not reachable");
};
let keys = nostr::Keys::generate();
let tags = vec![
nostr::Tag::parse(["u", &format!("http://{UNMAPPED_HOST}{GIT_PATH}")]).unwrap(),
nostr::Tag::parse(["method", "GET"]).unwrap(),
];
let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let token = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&event).unwrap())
);
let (status, _headers, _body) = git_request(state, UNMAPPED_HOST, Some(&token)).await;
assert_eq!(
status,
axum::http::StatusCode::NOT_FOUND,
"Off mode + unmapped host + valid syntax: parser passes → \
bind_community fires → 404. \
Falsifying mutation: always-deny parser → 401 instead of 404."
);
}
// ── Enforce mode: missing assertion on info/refs → exact body/CT/challenge ─
//
// Proves that git routes (info/refs, upload-pack, receive-pack) produce the
// exact contract bytes for MissingEvidence in Enforce mode. All three routes
// share `GitAuth::from_request_parts`; the pack routes are additionally
// tested in `enforce_mode_git_pack_routes_missing_assertion_exact_bytes`.
//
// A Nostr-scheme Authorization header is present (syntactically valid)
// so the Off-mode early-exit passes; no Nostr-Federated-Identity header
// is sent, so `extract_bearer_token` returns MissingEvidence → 401.
//
// Falsifying mutation: remove the `admit_nip_fi_http_on_state` call
// from `GitAuth::from_request_parts` → `GitAuth` falls back to the
// legacy NIP-98 verifier → valid proof is accepted → request reaches
// `validate_repo_id` which succeeds (OWNER_HEX is valid 64-hex), then
// `authorize_git_read` which denies (repo not member of a channel) →
// different status or body → assertion fires.
//
// Why no assertion header: in Enforce mode with no verifier configured
// (startup race) an assertion present + no verifier would return 503.
// The MissingEvidence path (no assertion header) is the correct gate
// test for git routes and is the most discriminating falsifiable case.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn enforce_mode_git_info_refs_missing_assertion_exact_bytes() {
use buzz_auth::NipFiMode;
let mut config = crate::config::Config::for_test();
config.nip_fi.mode = NipFiMode::Enforce;
config.nip_fi.communities =
crate::nip_fi_core::test_support::any_host("https://git-pack-test.relay.invalid");
config.require_auth_token = false;
config.require_relay_membership = false;
// Pin relay_url to a ws:// value so git_expected_url() deterministically
// derives the http:// scheme for the NIP-98 `u` tag.
config.relay_url = "ws://nip-fi-git-test.invalid".to_string();
config.redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1
let pool = match sqlx::PgPool::connect(&config.database_url).await {
Ok(p) => p,
Err(_) => panic!("local Postgres not reachable (enforce git)"),
};
let db = buzz_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("redis pool");
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.expect("pubsub"),
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage =
buzz_media::MediaStorage::new(&config.media).expect("media storage");
let (state, _) = AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
nostr::Keys::generate(),
media_storage,
);
let state = Arc::new(state);
// Register a mapped host so bind_community succeeds → the NIP-FI
// gate is the first denial point after Off-mode early-exit.
let host = format!(
"nip-fi-git-enf-{}.test.invalid",
uuid::Uuid::new_v4().simple()
);
state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community");
// Build a syntactically valid Nostr token signed for the repo-root URL.
// `git_expected_url()` strips `/info/refs?service=…` and keeps only
// the repository path prefix: `http://{host}/git/{OWNER_HEX}/myrepo`.
// No Nostr-Federated-Identity header → MissingEvidence in Enforce.
let keys = nostr::Keys::generate();
// GIT_PATH strips "/info/refs?..." suffix → repo root used for URL signing.
// OWNER_HEX is valid 64-hex so validate_repo_id would pass if auth succeeded.
let git_repo_root = format!("/git/{OWNER_HEX}/myrepo");
let tags = vec![
nostr::Tag::parse(["u", &format!("http://{host}{git_repo_root}")]).unwrap(),
nostr::Tag::parse(["method", "GET"]).unwrap(),
];
let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let auth_token = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&event).unwrap())
);
let (status, headers, body) = git_request(state, &host, Some(&auth_token)).await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"Enforce mode + info/refs + missing assertion MUST deny 401 MissingEvidence. \
Falsifying mutation: remove admit_nip_fi_http_on_state from GitAuth → \
legacy NIP-98 verifier accepts the valid proof → request proceeds past \
the admission gate → different status or body → assertion fires."
);
assert_eq!(
body.as_ref(),
b"authentication required\n",
"Enforce mode: git MissingEvidence body must be exact 'authentication required\\n' \
[FI-TRACE-DENIAL-ORACLE]. Different body means the legacy git error path fired \
instead of the NIP-FI gate."
);
let ct = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
ct, "text/plain; charset=utf-8",
"Enforce mode: git 401 content-type must be text/plain; charset=utf-8"
);
let www_auth = headers
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
www_auth, "Nostr",
"Enforce mode: git 401 must carry WWW-Authenticate: Nostr"
);
}
// ── Enforce mode: pack routes (upload-pack + receive-pack) missing assertion ─
//
// Parameterized test: both POST pack routes share `GitAuth::from_request_parts`
// and must produce the same exact denial bytes as `info/refs` when the
// Nostr-Federated-Identity assertion header is absent.
//
// For each route:
// - missing assertion → 401 MissingEvidence (exact body + CT + challenge)
// - EvidenceRejected (invalid base64 Nostr token) → 401 (NIP-FI maps it)
// - duplicate Authorization headers → 403 cardinality in Enforce mode
//
// Falsifying mutation: remove `admit_nip_fi_http_on_state` from
// `GitAuth::from_request_parts` → NIP-98 validates the token, no assertion
// check, request reaches `validate_repo_id` → succeeds (OWNER_HEX valid)
// → `authorize_git_read` denies (no channel membership) → 403 or 404
// → 401 assertion fires.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn enforce_mode_git_pack_routes_missing_assertion_exact_bytes() {
use buzz_auth::NipFiMode;
let mut config = crate::config::Config::for_test();
config.nip_fi.mode = NipFiMode::Enforce;
config.nip_fi.communities =
crate::nip_fi_core::test_support::any_host("https://git-pack-test.relay.invalid");
config.require_auth_token = false;
config.require_relay_membership = false;
config.relay_url = "ws://nip-fi-git-pack-test.invalid".to_string();
config.redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1
let pool = match sqlx::PgPool::connect(&config.database_url).await {
Ok(p) => p,
Err(_) => panic!("local Postgres not reachable (pack routes)"),
};
let db = buzz_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("redis pool");
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.expect("pubsub"),
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage =
buzz_media::MediaStorage::new(&config.media).expect("media storage");
let (state, _) = AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
nostr::Keys::generate(),
media_storage,
);
// Inject a static NIP-FI verifier into the Enforce state so that
// every case requiring cryptographic assertion validation (including
// the malformed-assertion Case 3) reaches the verifier rather than
// hitting the absent-verifier 503. Without this, Case 3 sends
// `Bearer !!!not-valid-base64!!!` which passes `extract_bearer_token`
// (scheme/cardinality checks only) and then trips the absent-verifier
// check in `nip_fi_core::evaluate_attached_assertion` → 503, not 403.
// [FI-TRACE-DENIAL-ORACLE: verifier required for all crypto cases]
let state = {
use buzz_auth::{
AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy,
IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion,
};
use jsonwebtoken::{jwk::JwkSet, Algorithm, EncodingKey};
const GIT_TEST_ISSUER: &str = "https://git-pack-test.issuer.invalid";
const GIT_TEST_AUDIENCE: &str = "https://git-pack-test.relay.invalid";
const GIT_TEST_KID: &str = "git-pack-test-key-1";
const GIT_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\
WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\
zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\
-----END PRIVATE KEY-----\n";
let jwks: JwkSet = serde_json::from_value(serde_json::json!({
"keys": [{
"kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256",
"kid": GIT_TEST_KID,
"x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI",
"y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"
}]
}))
.expect("valid test JWKS");
let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600);
let key_set = AssertionKeySet::new_for_test(
GIT_TEST_ISSUER.to_owned(),
1,
jwks,
hard_deadline,
)
.expect("valid test key set");
let jwks_contract = buzz_auth::JwksSourceContract::new(
format!("{GIT_TEST_ISSUER}/.well-known/jwks.json"),
300,
3600,
)
.expect("valid jwks contract");
let policy = IssuerPolicy::new(
GIT_TEST_ISSUER.to_owned(),
vec![GIT_TEST_AUDIENCE.to_owned()],
TokenClass::DedicatedNipFi,
FreshnessClass::OfflineJwt,
vec![Algorithm::ES256],
60,
3600,
None,
jwks_contract,
)
.expect("valid issuer policy");
let mut registry = IssuerRegistry::new();
registry.insert(policy);
let verifier: Arc<dyn VerifyAssertion> = Arc::new(FederatedAssertionVerifier::new(
registry,
StaticIssuerKeySource::new([key_set]),
));
// Encode the test key for use in Case 4 assertions.
// Store in a Mutex so it can be read from the outer scope later.
let enc_key =
EncodingKey::from_ec_pem(GIT_TEST_EC_PEM.as_bytes()).expect("valid EC PEM");
let mut s = state;
s.nip_fi_verifier = Some(verifier);
(
Arc::new(s),
GIT_TEST_ISSUER,
GIT_TEST_AUDIENCE,
GIT_TEST_KID,
enc_key,
)
};
let (state, git_test_issuer, git_test_audience, git_test_kid, git_enc_key) = state;
let host = format!(
"nip-fi-git-pack-{}.test.invalid",
uuid::Uuid::new_v4().simple()
);
state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community");
// Build a valid NIP-98 token signed for the upload-pack repo root.
// Git credential helper signs once with GET and reuses for POST pack requests.
let keys = nostr::Keys::generate();
let repo_root = format!("/git/{OWNER_HEX}/myrepo");
let tags = vec![
nostr::Tag::parse(["u", &format!("http://{host}{repo_root}")]).unwrap(),
nostr::Tag::parse(["method", "GET"]).unwrap(),
];
let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let nip98_token = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&event).unwrap())
);
// Helper: send a pack-route POST request and return (status, headers, body).
let send_pack_request =
|state: Arc<AppState>,
route: &'static str,
auth_headers: Vec<(&'static str, String)>| {
let host = host.clone();
async move {
let uri = format!("/git/{OWNER_HEX}/myrepo/{route}");
let mut builder = axum::http::Request::builder()
.method("POST")
.uri(&uri)
.header("host", &host)
.header(
"content-type",
if route == "git-upload-pack" {
"application/x-git-upload-pack-request"
} else {
"application/x-git-receive-pack-request"
},
);
for (name, value) in &auth_headers {
builder = builder.header(*name, value);
}
let req = builder
.body(axum::body::Body::empty())
.expect("build request");
let resp = git_router(Arc::clone(&state))
.oneshot(req)
.await
.expect("router oneshot");
let status = resp.status();
let headers = resp.headers().clone();
let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
(status, headers, body)
}
};
// ── Same-key material: built once, shared by Cases 5–7 and Case 4 ──
//
// Hoisted here so Cases 5–7 (inside the per-route loop below) can
// reference `same_key_assertion` without a forward-reference error.
// Case 4 reuses the same variables — no duplication.
{
use jsonwebtoken::{Algorithm, Header};
let test_keys_outer = nostr::Keys::generate();
let test_pubkey_hex_outer = test_keys_outer.public_key().to_hex();
let now_outer = chrono::Utc::now().timestamp();
let claims_outer = serde_json::json!({
"iss": git_test_issuer,
"aud": git_test_audience,
"iat": now_outer,
"exp": now_outer + 600,
"sub": "test-subject",
"nostr_pubkey": test_pubkey_hex_outer,
});
let mut hdr_outer = Header::new(Algorithm::ES256);
hdr_outer.kid = Some(git_test_kid.to_owned());
hdr_outer.typ = Some("nip-fi+jwt".to_owned());
let same_key_assertion =
jsonwebtoken::encode(&hdr_outer, &claims_outer, &git_enc_key)
.expect("sign assertion");
for route in &["git-upload-pack", "git-receive-pack"] {
let route: &'static str = route;
// ── Case 1: missing assertion → 401 MissingEvidence ─────────
let (status, headers, body) = send_pack_request(
Arc::clone(&state),
route,
vec![("authorization", nip98_token.clone())],
)
.await;
assert_eq!(
status,
axum::http::StatusCode::UNAUTHORIZED,
"{route}: missing assertion MUST deny 401 MissingEvidence. \
Falsifying mutation: remove admit_nip_fi_http_on_state from GitAuth \
→ legacy NIP-98 accepts → reaches validate_repo_id (valid owner) \
→ authorize_git_read denies → different status/body."
);
assert_eq!(
body.as_ref(),
b"authentication required\n",
"{route}: MissingEvidence body must be exact 'authentication required\\n'. \
[FI-TRACE-DENIAL-ORACLE]"
);
let ct = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
ct, "text/plain; charset=utf-8",
"{route}: 401 content-type must be 'text/plain; charset=utf-8'"
);
let www_auth = headers
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
www_auth, "Nostr",
"{route}: 401 must carry WWW-Authenticate: Nostr"
);
// ── Case 2: duplicate Authorization → 403 cardinality ───────
// Two NIP-98 tokens → cardinality gate fires before NIP-FI assertion check.
// No assertion header needed — cardinality fires first.
let (dup_status, dup_headers, dup_body) = send_pack_request(
Arc::clone(&state),
route,
vec![
("authorization", nip98_token.clone()),
("authorization", nip98_token.clone()),
],
)
.await;
assert_eq!(
dup_status,
axum::http::StatusCode::FORBIDDEN,
"{route}: duplicate Authorization headers MUST deny 403 EvidenceRejected \
(cardinality gate). \
Falsifying mutation: remove cardinality check from admit_nip_fi_http \
→ request reaches NIP-FI assertion check → different denial."
);
assert_eq!(
dup_body.as_ref(),
b"evidence rejected\n",
"{route}: cardinality 403 body must be exact 'evidence rejected\\n'. \
[FI-TRACE-DENIAL-ORACLE]"
);
let dup_ct = dup_headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
dup_ct, "text/plain; charset=utf-8",
"{route}: cardinality 403 content-type must be 'text/plain; charset=utf-8'"
);
assert!(
dup_headers.get("www-authenticate").is_none(),
"{route}: cardinality 403 MUST NOT carry WWW-Authenticate \
(client has a token, it's malformed — not absent)"
);
// ── Case 3: invalid base64 assertion → 403 EvidenceRejected ─
// A syntactically invalid Nostr-Federated-Identity value (non-base64
// after the "Nostr " prefix) → EvidenceRejected → 403.
// This is distinct from MissingEvidence (absent header → 401).
//
// Falsifying mutation: skip assertion validation for malformed tokens →
// request reaches handler → different status/body.
let (inv_status, inv_headers, inv_body) = send_pack_request(
Arc::clone(&state),
route,
vec![
("authorization", nip98_token.clone()),
(
buzz_auth::CLIENT_ATTACHED_HEADER,
"Bearer !!!not-valid-base64!!!".to_string(),
),
],
)
.await;
assert_eq!(
inv_status,
axum::http::StatusCode::FORBIDDEN,
"{route}: invalid base64 assertion MUST deny 403 EvidenceRejected. \
Falsifying mutation: skip assertion parsing on bad input → \
handler reached → different status/body."
);
assert_eq!(
inv_body.as_ref(),
b"evidence rejected\n",
"{route}: invalid assertion body must be exact 'evidence rejected\\n'. \
[FI-TRACE-DENIAL-ORACLE]"
);
let inv_ct = inv_headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
inv_ct, "text/plain; charset=utf-8",
"{route}: invalid assertion 403 content-type must be 'text/plain; charset=utf-8'"
);
assert!(
inv_headers.get("www-authenticate").is_none(),
"{route}: invalid assertion 403 MUST NOT carry WWW-Authenticate \
(client has a token, it's malformed — not absent)"
);
// ── Case 5: missing proof + valid assertion → 401 MissingEvidence ─
//
// Valid NFI assertion present, but NO Authorization (NIP-98) header.
// NIP-98 extraction closure returns MissingEvidence (no Authorization) →
// maps to 401 `authentication required\n`.
// Proves proof validation is not bypassed by a valid assertion.
//
// Falsifying mutation: make the NIP-98 closure skip missing-auth →
// NIP-98 proves something other than 401 → assertion fires.
let (miss_proof_status, _miss_proof_headers, miss_proof_body) =
send_pack_request(
Arc::clone(&state),
route,
vec![(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
)],
)
.await;
assert_eq!(
miss_proof_status,
axum::http::StatusCode::UNAUTHORIZED,
"{route}: missing proof + valid assertion MUST deny 401 MissingEvidence. \
A valid assertion does NOT bypass NIP-98 proof requirement. \
Falsifying mutation: skip NIP-98 when assertion present → handler reached."
);
assert_eq!(
miss_proof_body.as_ref(),
b"authentication required\n",
"{route}: missing-proof 401 body must be exact 'authentication required\\n'. \
[FI-TRACE-DENIAL-ORACLE]"
);
// ── Case 6: malformed proof + valid assertion → 403 EvidenceRejected ─
//
// Valid NFI assertion + syntactically malformed NIP-98 (`Nostr !!!bad!!!`).
// NIP-98 closure fails (bad base64) → maps to 403 EvidenceRejected.
// Proves malformed-proof detection is not bypassed by a valid assertion.
//
// Falsifying mutation: accept malformed NIP-98 when assertion present →
// admission bypassed → response is not 403 EvidenceRejected.
let (mal_proof_status, _mal_proof_headers, mal_proof_body) = send_pack_request(
Arc::clone(&state),
route,
vec![
("authorization", "Nostr !!!not-valid-base64!!!".to_string()),
(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
),
],
)
.await;
assert_eq!(
mal_proof_status,
axum::http::StatusCode::FORBIDDEN,
"{route}: malformed proof + valid assertion MUST deny 403 EvidenceRejected. \
Falsifying mutation: skip NIP-98 validation when assertion present → \
admission bypassed → not 403."
);
assert_eq!(
mal_proof_body.as_ref(),
b"evidence rejected\n",
"{route}: malformed-proof 403 body must be exact 'evidence rejected\\n'. \
[FI-TRACE-DENIAL-ORACLE]"
);
// ── Case 7: duplicate proof + valid assertion → 403 cardinality ─
//
// Two Authorization headers + valid NFI assertion. The cardinality gate
// fires before NIP-98 extraction (it runs on Authorization count).
// 403 EvidenceRejected — same result as Case 2 (dup without assertion),
// proving the assertion does not gate the cardinality check.
//
// `nip98_token` is signed by `keys` while `same_key_assertion` names
// `test_keys_outer`, so disabling cardinality predicts key pairing
// denial: 403 `authorization denied\n`. The exact
// `evidence rejected\n` body below distinguishes that mutation.
let (dup_proof_status, _dup_proof_headers, dup_proof_body) = send_pack_request(
Arc::clone(&state),
route,
vec![
("authorization", nip98_token.clone()),
("authorization", nip98_token.clone()),
(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
),
],
)
.await;
assert_eq!(
dup_proof_status,
axum::http::StatusCode::FORBIDDEN,
"{route}: duplicate proof + valid assertion MUST deny 403 cardinality. \
Disabling cardinality → key pairing 403 'authorization denied\\n'."
);
assert_eq!(
dup_proof_body.as_ref(),
b"evidence rejected\n",
"{route}: dup-proof 403 body must be exact 'evidence rejected\\n'. \
[FI-TRACE-DENIAL-ORACLE]"
);
}
// ── Case 4: same-key admission → passes NIP-FI, reaches handler ──
//
// The verifier was injected into `state` at the top of this test.
// Same-key: NIP-98 signed by test_keys; assertion nostr_pubkey =
// test_keys.public_key(). Key pairing passes → request reaches
// `validate_repo_id` → `authorize_git_read`.
//
// The repo does not exist in the test database, so `authorize_git_read`
// returns 404 "repository not found" — not a NIP-FI code.
//
// Falsifying mutation: replace the pairing check with always-deny →
// 403 `authorization denied\n` → status/body checks fire.
//
// Also covers `info/refs` (GET) with the same assertion; the route
// shares `GitAuth::from_request_parts` and `authorize_git_read`.
{
use jsonwebtoken::{Algorithm, Header};
// Same-key: NIP-98 signed by test_keys; assertion nostr_pubkey =
// test_keys.public_key(). Pairing passes.
let test_keys = nostr::Keys::generate();
let test_pubkey_hex = test_keys.public_key().to_hex();
let now = chrono::Utc::now().timestamp();
let claims = serde_json::json!({
"iss": git_test_issuer,
"aud": git_test_audience,
"iat": now,
"exp": now + 600,
"sub": "test-subject",
"nostr_pubkey": test_pubkey_hex,
});
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(git_test_kid.to_owned());
header.typ = Some("nip-fi+jwt".to_owned());
let same_key_assertion = jsonwebtoken::encode(&header, &claims, &git_enc_key)
.expect("sign assertion");
// Build same-key NIP-98 token for upload-pack repo root.
let admitted_nip98_tags = vec![
nostr::Tag::parse(["u", &format!("http://{host}{repo_root}")]).unwrap(),
nostr::Tag::parse(["method", "GET"]).unwrap(),
];
let admitted_event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "")
.tags(admitted_nip98_tags)
.sign_with_keys(&test_keys)
.unwrap();
let admitted_nip98_token = format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&admitted_event).unwrap())
);
// ── git-upload-pack (POST): same-key admission → 404 ─────────
//
// upload-pack calls authorize_git_read which queries DB for
// kind:30617 announcement. Repo absent → 404 "repository not found".
//
// Falsifying mutation: key-pairing always-deny → 403
// `authorization denied\n` → body check fires.
{
let (s_up, _h_up, b_up) = send_pack_request(
Arc::clone(&state),
"git-upload-pack",
vec![
("authorization", admitted_nip98_token.clone()),
(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
),
],
)
.await;
assert_eq!(
s_up,
axum::http::StatusCode::NOT_FOUND,
"git-upload-pack: same-key admission MUST reach authorize_git_read \
→ 404 (repo absent). \
If 401/403: NIP-FI denial — check verifier injection and key pairing. \
Body: {b_up:?}"
);
assert_eq!(
b_up.as_ref(),
b"repository not found",
"git-upload-pack: same-key admitted 404 body must be exact \
'repository not found'. \
Falsifying mutation: key pairing always-deny → 403 \
'authorization denied\\n'."
);
}
// ── git-receive-pack (POST): same-key admission → git busy 503 ─
//
// Every `git_semaphore` permit is held, so an admitted request
// stops at `receive_pack` → `acquire_git_permit`, which returns
// exactly 503, `Retry-After: 5`, body `git service busy`, no
// Content-Type, no challenge — before hydration, the
// subprocess, or finalize. Any NIP-FI denial (401
// `authentication required\n`, 403 `evidence rejected\n` /
// `authorization denied\n`, 503 `authorization unavailable\n`)
// happens in `GitAuth` before the handler and cannot produce
// these bytes.
{
let held: Vec<_> = std::iter::from_fn(|| {
Arc::clone(&state.git_semaphore).try_acquire_owned().ok()
})
.collect();
assert!(
!held.is_empty(),
"fixture must hold at least one git permit"
);
let (s_rp, h_rp, b_rp) = send_pack_request(
Arc::clone(&state),
"git-receive-pack",
vec![
("authorization", admitted_nip98_token.clone()),
(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
),
],
)
.await;
drop(held);
assert_eq!(
s_rp,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"git-receive-pack: admitted request MUST reach acquire_git_permit \
→ 503 busy. Body: {b_rp:?}"
);
assert_eq!(
h_rp.get("retry-after").and_then(|v| v.to_str().ok()),
Some("5"),
"git-receive-pack: busy 503 carries Retry-After: 5"
);
assert!(
h_rp.get("content-type").is_none(),
"git-receive-pack: busy 503 carries no Content-Type"
);
assert!(
h_rp.get("www-authenticate").is_none(),
"git-receive-pack: busy 503 carries no challenge"
);
assert_eq!(
b_rp.as_ref(),
b"git service busy",
"git-receive-pack: exact busy body from acquire_git_permit"
);
}
// ── info/refs (GET): shares GitAuth + authorize_git_read ──────
//
// info/refs is a GET with ?service=git-upload-pack sharing
// `GitAuth::from_request_parts` and `authorize_git_read`: missing
// assertion, missing / malformed / duplicate proof with a valid
// assertion, and the same-key positive.
{
let uri =
format!("/git/{OWNER_HEX}/myrepo/info/refs?service=git-upload-pack");
// ── info/refs Case 1: missing assertion → 401 ────────────
let (s, _, b) = {
let req = axum::http::Request::builder()
.method("GET")
.uri(&uri)
.header("host", &host)
.header("authorization", &nip98_token)
.body(axum::body::Body::empty())
.expect("build request");
let resp = git_router(Arc::clone(&state))
.oneshot(req)
.await
.expect("router oneshot");
let st = resp.status();
let hd = resp.headers().clone();
let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
(st, hd, bd)
};
assert_eq!(
s,
axum::http::StatusCode::UNAUTHORIZED,
"info/refs: missing assertion MUST deny 401. Body: {b:?}"
);
assert_eq!(
b.as_ref(),
b"authentication required\n",
"info/refs: missing assertion 401 body must be 'authentication required\\n'."
);
// ── info/refs Case 5: missing proof + valid assertion → 401 ─
let (s5, _, b5) = {
let req = axum::http::Request::builder()
.method("GET")
.uri(&uri)
.header("host", &host)
.header(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
)
.body(axum::body::Body::empty())
.expect("build request");
let resp = git_router(Arc::clone(&state))
.oneshot(req)
.await
.expect("router oneshot");
let st = resp.status();
let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
(st, (), bd)
};
assert_eq!(
s5,
axum::http::StatusCode::UNAUTHORIZED,
"info/refs: missing proof + valid assertion MUST deny 401. Body: {b5:?}"
);
assert_eq!(
b5.as_ref(),
b"authentication required\n",
"info/refs: missing-proof 401 body must be 'authentication required\\n'."
);
// ── info/refs Cases 6/7: malformed / duplicate proof +
// valid same-key assertion → 403 `evidence rejected\n` ─
for (authorization, case) in [
(
vec!["Nostr !!!not-valid-base64!!!".to_string()],
"malformed proof",
),
(
vec![admitted_nip98_token.clone(), admitted_nip98_token.clone()],
"duplicate proof",
),
] {
let mut builder = axum::http::Request::builder()
.method("GET")
.uri(&uri)
.header("host", &host)
.header(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
);
for value in &authorization {
builder = builder.header("authorization", value);
}
let resp = git_router(Arc::clone(&state))
.oneshot(builder.body(axum::body::Body::empty()).expect("build"))
.await
.expect("router oneshot");
let st = resp.status();
let hd = resp.headers().clone();
let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
assert_eq!(
st,
axum::http::StatusCode::FORBIDDEN,
"info/refs {case}: MUST deny 403. Body: {bd:?}"
);
assert_eq!(
hd.get("content-type").and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
"info/refs {case}: 403 Content-Type"
);
assert!(
hd.get("www-authenticate").is_none(),
"info/refs {case}: 403 carries no challenge"
);
assert_eq!(
bd.as_ref(),
b"evidence rejected\n",
"info/refs {case}: exact EvidenceRejected body"
);
}
// ── info/refs Case 4 (same-key positive) → 404 ───────────
let (s4, _, b4) = {
let req = axum::http::Request::builder()
.method("GET")
.uri(&uri)
.header("host", &host)
.header("authorization", &admitted_nip98_token)
.header(
buzz_auth::CLIENT_ATTACHED_HEADER,
format!("Bearer {same_key_assertion}"),
)
.body(axum::body::Body::empty())
.expect("build request");
let resp = git_router(Arc::clone(&state))
.oneshot(req)
.await
.expect("router oneshot");
let st = resp.status();
let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default();
(st, (), bd)
};
assert_eq!(
s4,
axum::http::StatusCode::NOT_FOUND,
"info/refs: same-key admission MUST reach handler → \
404 (repo not found). Body: {b4:?}"
);
assert_eq!(
b4.as_ref(),
b"repository not found",
"info/refs: same-key admitted 404 body must be 'repository not found'. \
Falsifying mutation: key pairing always-deny → 403 body."
);
}
}
} // closes same-key outer block (test_keys_outer / same_key_assertion)
}
}
}