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