Files
buzz/crates/buzz-relay/src/api/git/transport.rs
Tal Weiss fc598f5f8d fix(git): allow deleting the default branch (#4297)
Tal here, human. Trying to help. This bug bugged me...

## Summary

A repository's first branch becomes its symbolic `HEAD`, and Git's
bare-repository default rejects deleting that branch even when another
branch survives.

This change:

- sets `receive.denyDeleteCurrent=ignore` only for the ephemeral `git
receive-pack` process
- preserves the existing server-side `core.hooksPath` override and
authorization hook
- lets the existing CAS publication logic select a surviving branch as
the next manifest `HEAD`
- adds regression coverage using a real stateless `git receive-pack`
request and a manifest HEAD-selection test

This lets users replace an accidental default branch without deleting
the object-storage manifest pointer.

### Related issue

Fixes #3572

### Testing

- `cargo test -p buzz-relay api::git::` (128 passed, 5 ignored)
- `just ci`
- live E2E roundtrip against a release relay with PostgreSQL, Redis, and
MinIO:
  - created a repository through signed Nostr events
  - verified authorized pushes and rejected unauthorized clone/push
  - pushed a surviving `master` branch
  - deleted the active `main` branch over authenticated Smart HTTP
- freshly cloned the repository and verified `master` became HEAD,
`origin/main` was absent, and repository content remained intact

Signed-off-by: Tal Weiss <major.tal@gmail.com>
2026-08-02 13:29:41 -04:00

2969 lines
120 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 `AuthenticatedUpload` in media.rs.
///
/// 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;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let method = parts.method.as_str();
let auth_header = parts
.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())?;
// 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 raw_host = parts
.headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
.await
.map_err(|_| (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())?;
// Repo-root URL verification.
//
// The credential helper signs a NIP-98 token with:
// u = <repo-root> (e.g., http://host/git/{owner}/{repo})
//
// Git's credential protocol does NOT pass query strings to helpers, so
// service-scoping (`?service=...`) cannot be implemented at the NIP-98
// level without protocol changes. The token is repo-scoped, not service-scoped.
//
// Security is still provided by:
// - ±60s timestamp window (limits replay)
// - HTTPS in production (prevents token theft)
// - Pre-receive hook for push authorization (role + protection rules)
// - Endpoint routing (clone/push are different HTTP paths)
// 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.
let event_method = 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());
// 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).
// body=None: can't buffer streaming pack data to verify payload hash.
// Token is time-bounded (±60s) and URL-locked — acceptable trade-off.
let pubkey =
buzz_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, 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())?;
// 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 = parts
.headers
.get("x-auth-tag")
.and_then(|value| value.to_str().ok());
let auth_tag = event_auth_tag.as_deref().or(header_auth_tag);
if crate::api::relay_members::enforce_relay_membership(
state,
tenant.community(),
pubkey.as_bytes(),
auth_tag,
)
.await
.is_err()
{
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response());
}
Ok(GitAuth { pubkey, tenant })
}
}
/// 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
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.
async fn authorize_git_read(
db: &buzz_db::Db,
community: buzz_core::CommunityId,
caller: &nostr::PublicKey,
owner_hex: &str,
repo_name: &str,
) -> Result<(), 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(()),
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,
}
/// 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 {
// 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;
}
// 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.
let success = match 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,
},
)
.await
{
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();
}
};
// 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
};
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.
match state
.db
.insert_event(ctx.tenant.community(), &event, None)
.await
{
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((_, false)) => {
info!(
owner = %ctx.owner,
repo = %ctx.repo_id,
"kind:30618 deduplicated by relay db"
);
}
Err(e) => {
warn!(
owner = %ctx.owner,
repo = %ctx.repo_id,
error = %e,
"kind:30618 insert failed; push remains durable in object store"
);
}
}
}
Err(e) => {
warn!(
owner = %ctx.owner,
repo = %ctx.repo_id,
error = %e,
"kind:30618 build failed; push remains durable in object store"
);
}
}
}
// 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))
.layer(RequestBodyLimitLayer::new(body_limit))
.with_state(state)
}
#[cfg(test)]
mod track_c_tests {
use super::*;
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;
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());
}
/// 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_read_gate_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");
}
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(result: Result<(), 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"
);
}
}