diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index 97d32f912c7..5a8011539ae 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -484,6 +484,19 @@ jobs: VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" + - name: Git default-branch route and clone regressions + env: + # The preceding step already applied and reconciled schema/schema.sql. + BUZZ_TEST_SCHEMA_MODE: desired + BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + BUZZ_TEST_REDIS_URL: redis://localhost:6379 + BUZZ_TEST_S3_ENDPOINT: http://localhost:9000 + BUZZ_TEST_S3_BUCKET: buzz-media + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/api::git::settings::tests::external_infra::/)' \ + --run-ignored ignored-only - name: Workflow message provenance unit tests # The relay's workflow_sink suite is not selected by the infra-free # unit job. Its ignored database cases run in the isolated PostgreSQL diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index d3ece3fdfc2..a5ed98167cb 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -161,9 +161,11 @@ pub fn verify_nip98_event( ))); } } - let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content()); - - if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) { + // Keep a present-but-malformed tag distinct from an absent (optional) tag. + if let (Some(payload_tag), Some(body_bytes)) = (event.tags.find(TagKind::Payload), body) { + let payload_hex = payload_tag.content().ok_or_else(|| { + AuthError::Nip98Invalid("payload tag is missing its SHA-256 hash".to_string()) + })?; let computed: [u8; 32] = Sha256::digest(body_bytes).into(); let computed_hex = hex::encode(computed); if computed_hex != payload_hex { @@ -313,6 +315,26 @@ mod tests { assert!(matches!(result, Err(AuthError::Nip98Invalid(_)))); } + #[test] + fn payload_tag_without_hash_rejected_with_body() { + let keys = Keys::generate(); + for payload in [vec!["payload"], vec!["payload", ""]] { + let json = make_nip98_event_raw_tags( + &keys, + vec![ + nostr::Tag::parse(["u", TEST_URL]).unwrap(), + nostr::Tag::parse(["method", TEST_METHOD]).unwrap(), + nostr::Tag::parse(payload).unwrap(), + ], + ); + let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body")); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "{result:?}" + ); + } + } + #[test] fn payload_tag_absent_with_body_passes() { // Contract: the shared verifier does NOT require a payload tag even when diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index ef9ce7c7921..85a04b47120 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -168,6 +168,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | `repos` | `create` | Announce a git repository (NIP-34) | | | `get` | Get a repository announcement | | | `list` | List repository announcements | +| | `default-branch get/set` | Read or select an existing default branch (requires relay support) | | | `protect list` | List branch and tag protection rules | | | `protect set` | Create or replace a protection rule | | | `protect remove` | Remove a protection rule | @@ -196,3 +197,29 @@ stdout: raw relay JSON stderr: {"error": "category", "message": "detail"} exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict ``` + + +### Default branch + +After deploying relay support, select an existing published branch without +renaming or deleting any branch: + +```bash +buzz repos default-branch get --owner --id my-repo +buzz repos default-branch set --owner --id my-repo --branch main +# For an explicitly reviewed version, use the manifest digest returned by get: +buzz repos default-branch set --owner --id my-repo --branch main \ + --expected-manifest +``` + +`--owner` defaults to the signing identity, not an agent's attested human owner. +`set` without `--expected-manifest` reads the current version first. Success +returns `branch`, `head`, `manifest` and `changed`; `get` omits `changed`. +A stale version returns conflict (exit 5). Ambiguous write failures return +`delivery_unknown` with `retryable:false` and the original digest: **read before +retrying**, and do not blindly re-run against a newly fetched version. + +The signer must be a current channel member and a repository manager, directly +or through an unrestricted, valid NIP-OA owner attestation; permission to push is +not permission to change the default. See the +[protocol and authorization contract](../../docs/git-on-object-storage.md#default-branch-management). diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 75c87aa427f..6ade19f1cad 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -911,6 +911,56 @@ impl BuzzClient { .await } + /// Send a state-changing JSON command exactly once. Ambiguous delivery + /// never invites an automatic re-run with a newly observed version. + pub async fn post_json_once_authed( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}{path}", self.relay_url); + let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?; + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body))?; + let unknown = |detail: String| CliError::DeliveryUnknown(detail); + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) + .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) + .build()?; + let response = self + .with_auth_tag( + http.post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body), + ) + .send() + .await + .map_err(|e| { + if e.is_connect() || e.is_builder() { + CliError::Network(e) + } else { + unknown(e.to_string()) + } + })?; + let status = response.status(); + let body = response.text().await.map_err(|e| unknown(e.to_string()))?; + let message = extract_relay_message_field(&body).unwrap_or_else(|| body.clone()); + if status.is_server_error() + || status.is_redirection() + || (status.as_u16() == 429 && !message.starts_with("rate-limited:")) + { + return Err(unknown(format!("HTTP {}: {message}", status.as_u16()))); + } + if !status.is_success() { + return Err(CliError::Relay { + status: status.as_u16(), + body: message, + }); + } + Ok(body) + } + /// Submit a signed Nostr event via POST /events. /// /// For non-idempotent moderation command kinds (9040–9044), an ambiguous diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 7ed03f9d060..fed35ec201e 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod pr; pub mod project_channel; pub mod projects; pub mod reactions; +mod repo_default_branch; pub mod repos; pub mod social; pub mod upload; diff --git a/crates/buzz-cli/src/commands/repo_default_branch.rs b/crates/buzz-cli/src/commands/repo_default_branch.rs new file mode 100644 index 00000000000..23aba9f5426 --- /dev/null +++ b/crates/buzz-cli/src/commands/repo_default_branch.rs @@ -0,0 +1,314 @@ +//! Thin client for the relay's CAS-backed default-branch operation. + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{client::BuzzClient, error::CliError, ReposDefaultBranchCmd}; + +#[derive(Deserialize)] +struct DefaultBranch { + branch: String, + head: String, + manifest: String, +} + +fn parse_snapshot(raw: &str) -> Result { + let value: Value = serde_json::from_str(raw).map_err(|_| { + CliError::Other("relay did not return default-branch JSON; it may need updating".into()) + })?; + let snapshot: DefaultBranch = serde_json::from_value(value.clone()).map_err(|_| { + CliError::Other( + "relay response is missing default-branch state; it may need updating".into(), + ) + })?; + crate::validate::validate_hex64(&snapshot.manifest)?; + if snapshot.branch.is_empty() || snapshot.head != format!("refs/heads/{}", snapshot.branch) { + return Err(CliError::Other("relay returned an invalid HEAD".into())); + } + Ok(value) +} + +fn classify(error: CliError) -> CliError { + match error { + CliError::Relay { status: 409, body } => CliError::Conflict(body), + CliError::Relay { status: 401 | 403, body } => CliError::Auth(body), + CliError::Relay { status: 400, body } => CliError::Usage(body), + CliError::Relay { status: 404, body } => CliError::NotFound(format!("{body}; check repository access and that this relay supports default-branch management")), + other => other, + } +} + +pub(super) async fn dispatch( + command: ReposDefaultBranchCmd, + client: &BuzzClient, +) -> Result<(), CliError> { + let (id, owner, update) = match command { + ReposDefaultBranchCmd::Get { id, owner } => (id, owner, None), + ReposDefaultBranchCmd::Set { + id, + owner, + branch, + expected_manifest, + } => (id, owner, Some((branch, expected_manifest))), + }; + crate::validate::validate_repo_id(&id)?; + let owner = owner.unwrap_or_else(|| client.keys().public_key().to_hex()); + crate::validate::validate_hex64(&owner)?; + let path = format!("/git/{owner}/{id}/default-branch"); + let result = match update { + None => parse_snapshot(&client.get_authed(&path).await.map_err(classify)?)?, + Some((branch, expected)) => { + let expected = match expected { + Some(digest) => { + crate::validate::validate_hex64(&digest)?; + digest + } + None => { + let raw = client.get_authed(&path).await.map_err(classify)?; + let snapshot = parse_snapshot(&raw)?; + snapshot["manifest"] + .as_str() + .ok_or_else(|| CliError::Other("missing manifest".into()))? + .to_string() + } + }; + let uncertain = |detail: String| { + CliError::DeliveryUnknown(format!( + "{detail}; attempted branch {branch:?} against manifest {expected}. Read the current default branch before deciding what to do; do not blindly re-run set without --expected-manifest {expected}" + )) + }; + let raw = client + .post_json_once_authed( + &path, + &json!({"branch": branch, "expected_manifest": expected}), + ) + .await + .map_err(|e| match e { + CliError::DeliveryUnknown(detail) => uncertain(detail), + other => classify(other), + })?; + let result = parse_snapshot(&raw).map_err(|e| uncertain(e.to_string()))?; + if !result["changed"].is_boolean() || result["branch"].as_str() != Some(branch.as_str()) + { + return Err(uncertain( + "relay did not confirm the default-branch update".into(), + )); + } + result + } + }; + println!("{result}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{Body, Bytes}, + http::{HeaderMap, Response, StatusCode}, + routing::get, + Router, + }; + use base64::Engine; + use clap::Parser; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + #[test] + fn default_branch_cli_parses_get_and_set() { + for operation in [ + vec!["get", "--id", "demo"], + vec![ + "set", + "--id", + "demo", + "--branch", + "release/v1", + "--expected-manifest", + &"a".repeat(64), + ], + ] { + let mut args = vec!["buzz", "repos", "default-branch"]; + args.extend(operation); + assert!(crate::Cli::try_parse_from(args).is_ok()); + } + assert!(crate::Cli::try_parse_from([ + "buzz", + "repos", + "default-branch", + "set", + "--id", + "demo" + ]) + .is_err()); + } + + #[tokio::test] + async fn default_branch_reads_validate_state_before_any_mutation() { + for (branch, head, valid) in [ + (Some("release/v1"), "refs/heads/release/v1", true), + (None, "refs/heads/main", false), + (Some("main"), "refs/tags/main", false), + (Some("main"), "refs/heads/other", false), + (Some(""), "refs/heads/", false), + ] { + let posts = Arc::new(AtomicUsize::new(0)); + let post_count = posts.clone(); + let keys = nostr::Keys::generate(); + let path = format!("/git/{}/demo/default-branch", keys.public_key().to_hex()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let route = get(move || async move { + axum::Json(json!({"branch":branch, "head":head, "manifest":"a".repeat(64)})) + }) + .post(move || { + post_count.fetch_add(1, Ordering::SeqCst); + async { StatusCode::INTERNAL_SERVER_ERROR } + }); + let app = Router::new().route(&path, route); + let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); + let client = BuzzClient::new(url, keys, None, None).unwrap(); + let result = dispatch( + ReposDefaultBranchCmd::Get { + id: "demo".into(), + owner: None, + }, + &client, + ) + .await; + assert_eq!(result.is_ok(), valid, "{branch:?} {head}: {result:?}"); + if !valid { + let error = dispatch( + ReposDefaultBranchCmd::Set { + id: "demo".into(), + owner: None, + branch: "main".into(), + expected_manifest: None, + }, + &client, + ) + .await + .unwrap_err(); + assert!( + !matches!(error, CliError::DeliveryUnknown(_)), + "no mutation attempted: {error}" + ); + } + assert_eq!(posts.load(Ordering::SeqCst), 0); + server.abort(); + } + } + + #[tokio::test] + async fn default_branch_command_binds_observed_digest_and_does_not_retry_or_follow_redirects() { + let valid = json!({"head":"refs/heads/main", "branch":"main", "manifest":"b".repeat(64), "changed":true}); + let mut no_op = valid.clone(); + no_op["changed"] = json!(false); + no_op["manifest"] = json!("a".repeat(64)); + let mut cases = vec![(200u16, valid.clone(), true), (200, no_op, true)]; + for status in [307, 308, 409, 500, 502, 503, 504] { + cases.push((status, json!({"error":"test outcome"}), false)); + } + for (field, value) in [ + ("branch", None), + ("branch", Some(json!(""))), + ("head", Some(json!("refs/tags/main"))), + ("head", Some(json!("refs/heads/other"))), + ("manifest", Some(json!("not-a-digest"))), + ("changed", None), + ] { + let mut invalid = valid.clone(); + if let Some(value) = value { + invalid[field] = value; + } else { + invalid.as_object_mut().unwrap().remove(field); + } + cases.push((200, invalid, false)); + } + let mut other_branch = valid; + other_branch["branch"] = json!("other"); + other_branch["head"] = json!("refs/heads/other"); + cases.push((200, other_branch, false)); + for (status, reply, success) in cases { + let posts = Arc::new(AtomicUsize::new(0)); + let gets = Arc::new(AtomicUsize::new(0)); + let captured = Arc::new(Mutex::new(None)); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let path = format!("/git/{owner}/demo/default-branch"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let expected_url = format!("{url}{path}"); + let get_count = gets.clone(); + let post_count = posts.clone(); + let capture = captured.clone(); + let route = get(move || { + get_count.fetch_add(1, Ordering::SeqCst); + async { axum::Json(json!({"head":"refs/heads/legacy", "branch":"legacy", "manifest":"a".repeat(64)})) } + }).post(move |headers: HeaderMap, body: Bytes| { + let post_count = post_count.clone(); + let capture = capture.clone(); + let expected_url = expected_url.clone(); + let reply = reply.clone(); + async move { + post_count.fetch_add(1, Ordering::SeqCst); + let auth = headers["authorization"].to_str().unwrap().strip_prefix("Nostr ").unwrap(); + let event = String::from_utf8(base64::engine::general_purpose::STANDARD.decode(auth).unwrap()).unwrap(); + let event: nostr::Event = serde_json::from_str(&event).unwrap(); + event.verify().unwrap(); + assert!(event.tags.iter().any(|t| t.as_slice() == ["u", &expected_url])); + assert!(event.tags.iter().any(|t| t.as_slice() == ["method", "POST"])); + use sha2::Digest; + let digest = hex::encode(sha2::Sha256::digest(&body)); + assert!(event.tags.iter().any(|t| t.as_slice() == ["payload", &digest])); + *capture.lock().unwrap() = Some(serde_json::from_slice::(&body).unwrap()); + Response::builder().status(status).header("location", "/redirect-target") + .body(Body::from(reply.to_string())).unwrap() + } + }); + let redirected = posts.clone(); + let app = Router::new().route(&path, route).route( + "/redirect-target", + axum::routing::post(move || { + redirected.fetch_add(1, Ordering::SeqCst); + async { StatusCode::OK } + }), + ); + let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); + let client = BuzzClient::new(url, keys, None, None).unwrap(); + let result = crate::commands::repos::dispatch( + crate::ReposCmd::DefaultBranch(ReposDefaultBranchCmd::Set { + id: "demo".into(), + owner: None, + branch: "main".into(), + expected_manifest: None, + }), + &client, + ) + .await; + assert_eq!(gets.load(Ordering::SeqCst), 1); + assert_eq!( + posts.load(Ordering::SeqCst), + 1, + "HTTP {status} must not cause another POST" + ); + assert_eq!( + *captured.lock().unwrap(), + Some(json!({"branch":"main", "expected_manifest":"a".repeat(64)})) + ); + match status { + 200 if success => assert!(result.is_ok(), "{result:?}"), + 409 => assert!(matches!(result, Err(CliError::Conflict(_)))), + _ => { + let error = result.unwrap_err(); + assert!(matches!(error, CliError::DeliveryUnknown(_)), "{error}"); + assert!(!crate::error::is_retryable_error(&error)); + assert!(error.to_string().contains(&"a".repeat(64))); + assert!(error.to_string().contains("attempted branch \"main\"")); + } + } + server.abort(); + } + } +} diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 886d6e04192..3e6b0ac15cf 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -442,6 +442,9 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await, ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await, ReposCmd::Bind { id, channel } => cmd_bind_repo(client, &id, &channel).await, + ReposCmd::DefaultBranch(command) => { + super::repo_default_branch::dispatch(command, client).await + } ReposCmd::Protect(command) => match command { ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await, ReposProtectCmd::Set { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3f2bea73979..dfb75e487f0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1237,6 +1237,38 @@ pub enum ReposCmd { /// Manage branch and tag protection rules on one of your repositories. #[command(subcommand)] Protect(ReposProtectCmd), + /// Inspect or change the relay-hosted repository's default branch. + #[command(subcommand)] + DefaultBranch(ReposDefaultBranchCmd), +} + +/// Commands for the authoritative Git default branch, not announcement metadata. +#[derive(Subcommand)] +pub enum ReposDefaultBranchCmd { + /// Read the default branch and observed manifest version. + Get { + /// Repository identifier. + #[arg(long)] + id: String, + /// Repository owner (64-char hex). Defaults to your signing identity. + #[arg(long)] + owner: Option, + }, + /// Select an existing branch without moving or deleting any refs. + Set { + /// Repository identifier. + #[arg(long)] + id: String, + /// Repository owner (64-char hex). Defaults to your signing identity. + #[arg(long)] + owner: Option, + /// Short branch name, e.g. main or release/v1 (not refs/heads/main). + #[arg(long)] + branch: String, + /// Manifest digest returned by get. Omit to read it before updating. + #[arg(long)] + expected_manifest: Option, + }, } /// Commands for inspecting and changing repository protection rules. @@ -2399,12 +2431,13 @@ mod tests { ); assert_eq!( names(&cmd, "repos"), - vec!["bind", "create", "get", "list", "protect"] + vec!["bind", "create", "default-branch", "get", "list", "protect"] ); let repos = cmd .get_subcommands() .find(|subcommand| subcommand.get_name() == "repos") .expect("repos command"); + assert_eq!(names(repos, "default-branch"), vec!["get", "set"]); let protect = repos .get_subcommands() .find(|subcommand| subcommand.get_name() == "protect") @@ -2476,7 +2509,7 @@ mod tests { ("pr", 5), ("projects", 8), ("reactions", 3), - ("repos", 5), + ("repos", 6), ("social", 7), ("upload", 1), ("users", 5), diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 3ce809d18f7..080bbd5c1cc 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -243,7 +243,7 @@ pub async fn hydrate_for_write( /// /// `Ok(None)` if the pointer is absent (caller decides 404 vs first-push /// per call site). `Err(_)` on any below-pointer failure. -async fn load_pointer( +pub(super) async fn load_pointer( store: &GitStore, ctx: &TenantContext, owner: &str, diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index dd69d7dc36e..1db75ce39c5 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -30,6 +30,7 @@ pub mod manifest; pub mod manifest_event; pub mod pack_cache; pub mod policy; +mod settings; pub mod store; pub mod transport; diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs new file mode 100644 index 00000000000..2e1f99811f6 --- /dev/null +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -0,0 +1,418 @@ +//! Default-branch management of the authoritative Git manifest. +//! +//! This is a Git control-plane operation, not a replaceable announcement: +//! the pointer CAS is the commit point, shared with receive-pack. A separate +//! strict NIP-98 request prevents reusable Smart HTTP credentials authorizing +//! metadata changes (URL, method, payload and replay are all checked). + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::{DefaultBodyLimit, Path, State}, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use buzz_core::TenantContext; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::{ + binding::{resolve_repo_binding, RepoBinding}, + hydrate::load_pointer, + manifest::{is_safe_refname, pointer_key, Manifest}, + manifest_event::{build_ref_state_event, RefStateInputs}, + store::{CasOutcome, ETag, GitStore, Precond}, + transport::{authorize_git_read, deny_banned_git_principal, validate_repo_id}, +}; +use crate::{ + api::{api_error, bridge, relay_members}, + state::AppState, +}; + +fn error(status: StatusCode, message: &str) -> Response { + api_error(status, message).into_response() +} + +fn backend(error: impl std::fmt::Display) -> Response { + tracing::error!(%error, "git settings backend failure"); + self::error( + StatusCode::INTERNAL_SERVER_ERROR, + "git settings backend unavailable; read the default branch before retrying", + ) +} + +fn conflict() -> Response { + error( + StatusCode::CONFLICT, + "repository changed concurrently; read the latest manifest and retry", + ) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SetDefaultBranch { + branch: String, + expected_manifest: String, +} + +/// A loaded snapshot cannot be rebound to another pointer or refreshed at CAS. +struct DefaultBranchSnapshot { + pointer: String, + etag: ETag, + digest: String, + manifest: Manifest, +} + +impl DefaultBranchSnapshot { + async fn load( + store: &GitStore, + tenant: &TenantContext, + owner: &str, + repo: &str, + ) -> Result { + let (etag, digest, manifest) = load_pointer(store, tenant, owner, repo) + .await + .map_err(backend)? + .ok_or_else(|| { + error( + StatusCode::NOT_FOUND, + "repository has no published Git state; push a branch first", + ) + })?; + Ok(Self { + pointer: pointer_key(tenant.community(), owner, repo), + etag, + digest, + manifest, + }) + } + + fn response(&self) -> Value { + json!({"branch": self.manifest.head.strip_prefix("refs/heads/"), "head": self.manifest.head, "manifest": self.digest}) + } + + async fn set( + mut self, + store: &GitStore, + request: SetDefaultBranch, + ) -> Result<(Self, bool), Response> { + if self.digest != request.expected_manifest { + return Err(conflict()); + } + let head = format!("refs/heads/{}", request.branch); + if request.branch.is_empty() + || request.branch.len() > 1024 + || request.branch.starts_with('-') + || !is_safe_refname(&head) + || request.branch.ends_with('.') + || request + .branch + .split('/') + .any(|part| part.starts_with('.') || part.ends_with(".lock")) + { + return Err(error( + StatusCode::BAD_REQUEST, + "invalid branch name; use a short branch name such as main or release/v1", + )); + } + if !self.manifest.refs.contains_key(&head) { + return Err(error( + StatusCode::BAD_REQUEST, + "default branch must name an existing published branch", + )); + } + let changed = self.manifest.head != head; + if changed { + self.manifest.head = head; + self.manifest.parent = Some(self.digest.clone()); + self.manifest.validate().map_err(backend)?; + let bytes = self.manifest.canonical_bytes().map_err(backend)?; + let key = store.put_manifest(&bytes).await.map_err(backend)?; + self.digest = key + .strip_prefix("manifests/") + .ok_or_else(|| backend("invalid manifest key"))? + .to_string(); + } + // Even a no-op checks the observed ETag: concurrent deletion/push must + // not be reported as a successful setting of a now-missing branch. + match store + .put_pointer( + &self.pointer, + self.digest.as_bytes(), + Precond::IfMatch(self.etag.clone()), + ) + .await + .map_err(backend)? + { + CasOutcome::Won(etag) => self.etag = etag, + CasOutcome::LostRace => return Err(conflict()), + } + Ok((self, changed)) + } +} + +struct SettingsAuth { + tenant: TenantContext, + caller: nostr::PublicKey, + delegated_owner: Option, +} + +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: Option<&[u8]>, +) -> Result { + let host = headers + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, host) + .await + .map_err(|_| error(StatusCode::NOT_FOUND, "repository not found"))?; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let auth = bridge::verify_bridge_auth_with_options( + headers, + if body.is_some() { "POST" } else { "GET" }, + &url, + body, + true, + body.is_some(), + ) + .map_err(IntoResponse::into_response)?; + bridge::enforce_http_admission(state, &tenant, &auth.pubkey) + .await + .map_err(IntoResponse::into_response)?; + bridge::check_nip98_replay(state, &tenant, auth.event_id_bytes) + .await + .map_err(IntoResponse::into_response)?; + let tag = relay_members::extract_auth_tag_header(headers); + relay_members::enforce_relay_membership( + state, + tenant.community(), + auth.pubkey.as_bytes(), + tag, + auth.signed_created_at, + ) + .await + .map_err(IntoResponse::into_response)?; + deny_banned_git_principal( + &state.db, + tenant.community(), + &auth.pubkey, + tag, + auth.signed_created_at, + ) + .await?; + // Admission ignores kind= restrictions by design (NIP-AA). Repository + // management must not turn a message-only credential into write authority. + // This HTTP operation has no event kind: only kind-unrestricted credentials + // may inherit management authority. Temporal clauses are still enforced. + let delegated_owner = tag + .filter(|tag| { + serde_json::from_str::>(tag) + .ok() + .and_then(|parts| parts.get(2).cloned()) + .is_some_and(|conditions| { + !conditions + .split('&') + .any(|clause| clause.starts_with("kind=")) + }) + }) + .and_then(|tag| { + relay_members::extract_nip_oa_owner( + auth.pubkey.as_bytes(), + Some(tag), + auth.signed_created_at, + ) + }); + Ok(SettingsAuth { + tenant, + caller: auth.pubkey, + delegated_owner, + }) +} + +async fn authorize_management( + state: &AppState, + auth: &SettingsAuth, + repo: &nostr::Event, +) -> Result<(), Response> { + let RepoBinding::Bound(channel) = resolve_repo_binding(repo) else { + return Err(error(StatusCode::NOT_FOUND, "repository not found")); + }; + let community = auth.tenant.community(); + let bound = state + .db + .get_channel(community, channel) + .await + .map_err(backend)?; + if bound.archived_at.is_some() { + return Err(error( + StatusCode::FORBIDDEN, + "channel is archived (read-only)", + )); + } + let named_manager = |key: &nostr::PublicKey| { + *key == repo.pubkey + || repo.tags.iter().any(|tag| { + let values = tag.as_slice(); + values.first().is_some_and(|name| name == "maintainers") + && values + .iter() + .skip(1) + .any(|value| nostr::PublicKey::parse(value).ok().as_ref() == Some(key)) + }) + }; + // Direct authority is independent of an optional owner credential. + if named_manager(&auth.caller) + || state + .db + .is_agent_owner(community, repo.pubkey.as_bytes(), auth.caller.as_bytes()) + .await + .map_err(backend)? + { + return Ok(()); + } + if let Some(principal) = &auth.delegated_owner { + let role = state + .db + .get_member_role(community, channel, principal.as_bytes()) + .await + .map_err(backend)?; + if role.is_some_and(|r| r.parse::().is_ok()) + && (named_manager(principal) + || state + .db + .is_agent_owner(community, repo.pubkey.as_bytes(), principal.as_bytes()) + .await + .map_err(backend)?) + { + return Ok(()); + } + } + Err(error( + StatusCode::FORBIDDEN, + "only the repository owner or a named maintainer may change its default branch", + )) +} + +async fn get_default_branch( + State(state): State>, + Path((owner, repo)): Path<(String, String)>, + headers: HeaderMap, +) -> Result, Response> { + let path = format!("/git/{owner}/{repo}/default-branch"); + let repo_name = validate_repo_id(&owner, &repo)?; + let auth = authenticate(&state, &headers, &path, None).await?; + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.caller, + &owner, + repo_name, + ) + .await?; + let snapshot = + DefaultBranchSnapshot::load(&state.git_store, &auth.tenant, &owner, repo_name).await?; + Ok(Json(snapshot.response())) +} + +async fn set_default_branch( + State(state): State>, + Path((owner, repo)): Path<(String, String)>, + headers: HeaderMap, + body: Bytes, +) -> Result, Response> { + let path = format!("/git/{owner}/{repo}/default-branch"); + let repo_name = validate_repo_id(&owner, &repo)?; + let auth = authenticate(&state, &headers, &path, Some(&body)).await?; + let announcement = authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.caller, + &owner, + repo_name, + ) + .await?; + authorize_management(&state, &auth, &announcement).await?; + let request: SetDefaultBranch = serde_json::from_slice(&body).map_err(|_| { + error( + StatusCode::BAD_REQUEST, + "expected branch and expected_manifest strings", + ) + })?; + let snapshot = + DefaultBranchSnapshot::load(&state.git_store, &auth.tenant, &owner, repo_name).await?; + let serving_write = buzz_deletion::acquire_serving_write( + &state.db, + auth.tenant.community(), + "git_default_branch", + ) + .await + .map_err(|_| { + error( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are fenced", + ) + })?; + serving_write.verify().await.map_err(backend)?; + let (snapshot, changed) = serving_write + .protect(snapshot.set(&state.git_store, request)) + .await + .map_err(backend)??; + let publication = async { + if changed { + let actor = auth.caller.to_hex(); + let event = build_ref_state_event( + &RefStateInputs { + repo_id: repo_name, + head: &snapshot.manifest.head, + refs: &snapshot.manifest.refs, + actor_pubkey_hex: &actor, + }, + &state.relay_keypair, + ) + .map_err(backend)?; + let (stored, inserted) = state + .db + .insert_event_with_serving_write_guard(serving_write.lease(), &event, None) + .await + .map_err(backend)?; + if inserted { + crate::handlers::event::fan_out_event_to_local_subscribers( + &state, + auth.tenant.community(), + &stored, + ) + .await; + } + } + Ok::<(), Response>(()) + } + .await; + serving_write.finish().await.map_err(backend)?; + // Publication failure is not mistaken for a rolled-back manifest. + if publication.is_err() { + return Err(error(StatusCode::INTERNAL_SERVER_ERROR, "default branch committed but notification failed; read the current default branch before retrying")); + } + let mut response = snapshot.response(); + response["changed"] = json!(changed); + Ok(Json(response)) +} + +pub(super) fn router() -> Router> { + Router::new() + .route( + "/git/{owner}/{repo}/default-branch", + get(get_default_branch).post(set_default_branch), + ) + .layer(DefaultBodyLimit::max(4096)) +} + +#[cfg(test)] +#[path = "settings_tests.rs"] +mod tests; diff --git a/crates/buzz-relay/src/api/git/settings_tests.rs b/crates/buzz-relay/src/api/git/settings_tests.rs new file mode 100644 index 00000000000..55c04dddcf7 --- /dev/null +++ b/crates/buzz-relay/src/api/git/settings_tests.rs @@ -0,0 +1,880 @@ +//! Live route/store/clone regressions. Require explicit isolated service URLs; +//! never fall back to a developer's Desktop database. + +mod external_infra { + use super::super::*; + use axum::{ + body::{to_bytes, Body}, + http::Request, + }; + use base64::Engine; + use buzz_core::channel::MemberRole; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use sha2::{Digest, Sha256}; + use tower::ServiceExt; + + struct Fixture { + state: Arc, + pool: sqlx::PgPool, + tenant: TenantContext, + owner: Keys, + member: Keys, + maintainer: Keys, + channel: uuid::Uuid, + repo: String, + scratch: tempfile::TempDir, + } + + impl Fixture { + async fn new() -> Self { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .expect("explicit isolated BUZZ_TEST_DATABASE_URL"); + let redis_url = std::env::var("BUZZ_TEST_REDIS_URL") + .expect("explicit isolated BUZZ_TEST_REDIS_URL"); + let endpoint = std::env::var("BUZZ_TEST_S3_ENDPOINT") + .expect("explicit isolated BUZZ_TEST_S3_ENDPOINT"); + let scratch = tempfile::tempdir().unwrap(); + let mut config = crate::config::Config::from_env().unwrap(); + config.database_url = database_url; + config.redis_url = redis_url; + config.relay_url = "ws://127.0.0.1".into(); + config.require_relay_membership = false; + config.git_repo_path = scratch.path().to_path_buf(); + config.git_pack_cache_path = scratch.path().join("cache"); + config.media.s3_endpoint = endpoint; + config.media.s3_bucket = + std::env::var("BUZZ_TEST_S3_BUCKET").unwrap_or_else(|_| "buzz-git".into()); + config.media.s3_access_key = "buzz_dev".into(); + config.media.s3_secret_key = "buzz_dev_secret".into(); + let pool = sqlx::PgPool::connect(&config.database_url).await.unwrap(); + let db = buzz_db::Db::from_pool(pool.clone()); + // CI provisions schema/schema.sql with pgschema before this suite. + // Only migration-backed local fixtures own the migration lifecycle. + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.unwrap(); + } + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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 = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media = buzz_media::MediaStorage::new(&config.media).unwrap(); + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow, + Keys::generate(), + media, + ); + let state = Arc::new(state); + let host = format!("settings-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let tenant = TenantContext::resolved(community, &host); + let owner = Keys::generate(); + let member = Keys::generate(); + let maintainer = Keys::generate(); + let channel = uuid::Uuid::new_v4(); + state + .db + .ensure_user(community, owner.public_key().as_bytes()) + .await + .unwrap(); + state + .db + .create_channel_with_id( + community, + channel, + &format!("settings-{channel}"), + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + for (key, role) in [ + (&member, MemberRole::Admin), + (&maintainer, MemberRole::Member), + (&owner, MemberRole::Owner), + ] { + state + .db + .ensure_user(community, key.public_key().as_bytes()) + .await + .unwrap(); + state + .db + .add_member( + community, + channel, + key.public_key().as_bytes(), + role, + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(); + } + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let announcement = EventBuilder::new(Kind::Custom(30617), "") + .tags([ + Tag::parse(["d", &repo]).unwrap(), + Tag::parse(["buzz-channel", &channel.to_string()]).unwrap(), + Tag::parse(["maintainers", &maintainer.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + state + .db + .insert_event(community, &announcement, None) + .await + .unwrap(); + let f = Self { + state, + pool, + tenant, + owner, + member, + maintainer, + channel, + repo, + scratch, + }; + f.seed_git().await; + f + } + + fn path(&self) -> String { + format!( + "/git/{}/{}/default-branch", + self.owner.public_key().to_hex(), + self.repo + ) + } + + async fn snapshot(&self) -> DefaultBranchSnapshot { + DefaultBranchSnapshot::load( + &self.state.git_store, + &self.tenant, + &self.owner.public_key().to_hex(), + &self.repo, + ) + .await + .unwrap() + } + + async fn seed_git(&self) { + let source = self.scratch.path().join("source"); + std::fs::create_dir(&source).unwrap(); + git(&source, &["init", "--initial-branch=legacy"]).await; + git(&source, &["config", "user.name", "Git settings test"]).await; + git( + &source, + &["config", "user.email", "git-settings@example.invalid"], + ) + .await; + git(&source, &["commit", "--allow-empty", "-m", "legacy"]).await; + git(&source, &["branch", "main"]).await; + git(&source, &["checkout", "main"]).await; + std::fs::write(source.join("main.txt"), b"selected branch\n").unwrap(); + git(&source, &["add", "main.txt"]).await; + git(&source, &["commit", "-m", "main"]).await; + git(&source, &["checkout", "legacy"]).await; + super::super::super::cas_publish::cas_publish( + &self.state.git_store, + &self.tenant, + &source, + &self.owner.public_key().to_hex(), + &self.repo, + &super::super::super::cas_publish::ParentState::fresh(), + limits(0), + ) + .await + .unwrap(); + } + + async fn call( + &self, + key: &Keys, + body: Option, + tag: Option<&str>, + ) -> (StatusCode, Value) { + let body = body.map(|value| value.to_string()); + let method = if body.is_some() { "POST" } else { "GET" }; + let path = self.path(); + let token = token( + key, + method, + &format!("http://{}{path}", self.tenant.host()), + body.as_deref(), + ); + let mut request = Request::builder() + .method(method) + .uri(&path) + .header("host", self.tenant.host()) + .header("authorization", token); + if let Some(tag) = tag { + request = request.header("x-auth-tag", tag); + } + let request = request.body(Body::from(body.unwrap_or_default())).unwrap(); + response( + super::super::super::transport::git_router(self.state.clone()) + .oneshot(request) + .await + .unwrap(), + ) + .await + } + + async fn set(&self, key: &Keys, branch: &str, tag: Option<&str>) -> (StatusCode, Value) { + let digest = self.snapshot().await.digest; + self.call( + key, + Some(json!({"branch": branch, "expected_manifest": digest})), + tag, + ) + .await + } + + async fn add(&self, key: &Keys) { + self.state + .db + .ensure_user(self.tenant.community(), key.public_key().as_bytes()) + .await + .unwrap(); + self.state + .db + .add_member( + self.tenant.community(), + self.channel, + key.public_key().as_bytes(), + MemberRole::Bot, + Some(self.owner.public_key().as_bytes()), + ) + .await + .unwrap(); + } + } + + fn limits(parent_hydrated_bytes: u64) -> super::super::super::cas_publish::PublishLimits { + super::super::super::cas_publish::PublishLimits { + parent_hydrated_bytes, + max_pack_bytes: 1024 * 1024, + max_repo_bytes: 2 * 1024 * 1024, + } + } + + async fn git(path: &std::path::Path, args: &[&str]) -> String { + let mut command = tokio::process::Command::new("git"); + command.current_dir(path).args(args); + super::super::super::transport::harden_git_env(&mut command); + let result = command.output().await.unwrap(); + assert!( + result.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() + } + + fn token(keys: &Keys, method: &str, url: &str, body: Option<&str>) -> String { + token_with_payload( + keys, + method, + url, + body.map(|body| Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap()), + ) + } + + fn token_with_payload(keys: &Keys, method: &str, url: &str, payload: Option) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]; + if let Some(payload) = payload { + tags.push(payload); + } + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + async fn response(response: Response) -> (StatusCode, Value) { + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + ( + status, + serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({"error": String::from_utf8_lossy(&bytes)})), + ) + } + + #[tokio::test] + #[ignore = "requires isolated Postgres, Redis and MinIO"] + async fn default_branch_route_permissions_and_protocol() { + let f = Fixture::new().await; + let before = f.snapshot().await; + assert_eq!( + f.call(&f.member, None, None).await.1["head"], + "refs/heads/legacy" + ); + assert_eq!( + f.set(&f.member, "main", None).await.0, + StatusCode::FORBIDDEN, + "push-capable channel admin is not a repo manager" + ); + assert_eq!( + f.set(&Keys::generate(), "main", None).await.0, + StatusCode::NOT_FOUND + ); + for branch in [ + "", + "absent", + "../main", + "refs/heads/main", + "main.lock", + "bad\nref", + "main/", + "-main", + ".main", + ] { + assert_eq!( + f.set(&f.owner, branch, None).await.0, + StatusCode::BAD_REQUEST, + "{branch:?}" + ); + } + assert_eq!( + f.snapshot().await.digest, + before.digest, + "denials do not write" + ); + let result = f.set(&f.maintainer, "main", None).await; + assert_eq!(result.0, StatusCode::OK, "{result:?}"); + assert_eq!(result.1["changed"], true); + let after = f.snapshot().await; + assert_eq!(after.manifest.head, "refs/heads/main"); + assert_eq!(after.manifest.refs, before.manifest.refs); + assert_eq!(after.manifest.packs, before.manifest.packs); + assert_eq!(after.manifest.parent.as_ref(), Some(&before.digest)); + let result = f.set(&f.owner, "main", None).await; + assert_eq!(result.0, StatusCode::OK); + assert_eq!(result.1["changed"], false); + assert_eq!(f.snapshot().await.digest, after.digest); + assert_eq!( + f.call( + &f.owner, + Some(json!({"branch":"legacy", "expected_manifest": before.digest})), + None + ) + .await + .0, + StatusCode::CONFLICT + ); + let notification_query = buzz_db::EventQuery { + kinds: Some(vec![30618]), + d_tag: Some(f.repo.clone()), + global_only: true, + ..buzz_db::EventQuery::for_community(f.tenant.community()) + }; + let events = f.state.db.query_events(¬ification_query).await.unwrap(); + let event_ids: Vec<_> = events.iter().map(|e| e.event.id).collect(); + assert!( + events.iter().any(|e| e + .event + .tags + .iter() + .any(|t| t.as_slice() == ["HEAD", "ref: refs/heads/main"])), + "committed default notification: {events:?}" + ); + + // Strict credentials: each mutated property must be rejected at the real route. + let body = json!({"branch":"legacy", "expected_manifest": after.digest}).to_string(); + let path = f.path(); + let url = format!("http://{}{path}", f.tenant.host()); + let requests = [ + token_with_payload( + &f.owner, + "POST", + &url, + Some(Tag::parse(["payload"]).unwrap()), + ), + token_with_payload( + &f.owner, + "POST", + &url, + Some(Tag::parse(["payload", ""]).unwrap()), + ), + token(&f.owner, "GET", &url, Some(&body)), + token(&f.owner, "POST", &url, None), + token(&f.owner, "POST", &url, Some("{}")), + token( + &f.owner, + "POST", + &url.replace(f.tenant.host(), "other.example"), + Some(&body), + ), + token( + &f.owner, + "GET", + url.trim_end_matches("/default-branch"), + None, + ), + ]; + for token in requests { + let request = Request::builder() + .method("POST") + .uri(&path) + .header("host", f.tenant.host()) + .header("authorization", token) + .body(Body::from(body.clone())) + .unwrap(); + let status = super::super::super::transport::git_router(f.state.clone()) + .oneshot(request) + .await + .unwrap() + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!( + f.snapshot().await.digest, + after.digest, + "auth denial changed pointer" + ); + let denied_events = f.state.db.query_events(¬ification_query).await.unwrap(); + assert_eq!( + denied_events.iter().map(|e| e.event.id).collect::>(), + event_ids, + "auth denial published kind:30618" + ); + } + let reusable = token(&f.owner, "GET", &url, None); + for expected in [StatusCode::OK, StatusCode::UNAUTHORIZED] { + let request = Request::builder() + .uri(&path) + .header("host", f.tenant.host()) + .header("authorization", &reusable) + .body(Body::empty()) + .unwrap(); + assert_eq!( + super::super::super::transport::git_router(f.state.clone()) + .oneshot(request) + .await + .unwrap() + .status(), + expected + ); + } + let other_host = format!("other-{}.example", uuid::Uuid::new_v4()); + f.state + .db + .ensure_configured_community(&other_host) + .await + .unwrap(); + let token = token(&f.owner, "GET", &format!("http://{other_host}{path}"), None); + let request = Request::builder() + .uri(&path) + .header("host", &other_host) + .header("authorization", token) + .body(Body::empty()) + .unwrap(); + assert_eq!( + super::super::super::transport::git_router(f.state.clone()) + .oneshot(request) + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + #[ignore = "requires isolated Postgres, Redis and MinIO"] + async fn default_branch_delegation_and_revocation() { + let f = Fixture::new().await; + let agent = Keys::generate(); + f.add(&agent).await; + let tag = buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &agent.public_key(), "").unwrap(); + assert_eq!(f.set(&agent, "main", None).await.0, StatusCode::FORBIDDEN); + let limited = + buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &agent.public_key(), "kind=1").unwrap(); + assert_eq!( + f.set(&agent, "main", Some(&limited)).await.0, + StatusCode::FORBIDDEN + ); + let expired = + buzz_sdk::nip_oa::compute_auth_tag(&f.owner, &agent.public_key(), "created_at<1") + .unwrap(); + assert_eq!( + f.set(&agent, "main", Some(&expired)).await.0, + StatusCode::FORBIDDEN + ); + assert_eq!(f.set(&agent, "main", Some(&tag)).await.0, StatusCode::OK); + // Optional credential does not take direct authority away. + let absent_owner = Keys::generate(); + let own_tag = + buzz_sdk::nip_oa::compute_auth_tag(&absent_owner, &f.owner.public_key(), "").unwrap(); + assert_eq!( + f.set(&f.owner, "legacy", Some(&own_tag)).await.0, + StatusCode::OK + ); + // A human can administer a repository announced by their managed agent. + f.state + .db + .set_agent_owner( + f.tenant.community(), + f.owner.public_key().as_bytes(), + f.member.public_key().as_bytes(), + ) + .await + .unwrap(); + assert_eq!(f.set(&f.member, "main", None).await.0, StatusCode::OK); + f.state + .db + .add_member( + f.tenant.community(), + f.channel, + f.maintainer.public_key().as_bytes(), + MemberRole::Owner, + Some(f.owner.public_key().as_bytes()), + ) + .await + .unwrap(); + f.state + .db + .remove_member( + f.tenant.community(), + f.channel, + f.owner.public_key().as_bytes(), + f.owner.public_key().as_bytes(), + ) + .await + .unwrap(); + assert_eq!( + f.set(&agent, "legacy", Some(&tag)).await.0, + StatusCode::FORBIDDEN + ); + assert_eq!( + f.set(&f.owner, "legacy", None).await.0, + StatusCode::NOT_FOUND + ); + // Durable ban cascades even when the signer has independent maintainer rights. + let ban_tag = + buzz_sdk::nip_oa::compute_auth_tag(&f.member, &f.maintainer.public_key(), "").unwrap(); + f.state + .db + .ban_community_member( + f.tenant.community(), + f.member.public_key().as_bytes(), + f.member.public_key().as_bytes(), + Some("test"), + None, + ) + .await + .unwrap(); + assert_eq!( + f.set(&f.maintainer, "legacy", Some(&ban_tag)).await.0, + StatusCode::FORBIDDEN + ); + sqlx::query("UPDATE channels SET archived_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(f.tenant.community().as_uuid()) + .bind(f.channel) + .execute(&f.pool) + .await + .unwrap(); + assert_eq!( + f.set(&f.maintainer, "legacy", None).await.0, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + #[ignore = "requires isolated Postgres, Redis and MinIO"] + async fn default_branch_push_races_and_fresh_clone() { + let f = Fixture::new().await; + let a = f.snapshot().await; + let b = f.snapshot().await; + let old_digest = a.digest.clone(); + let (_, changed) = a + .set( + &f.state.git_store, + SetDefaultBranch { + branch: "main".into(), + expected_manifest: old_digest.clone(), + }, + ) + .await + .unwrap(); + assert!(changed); + let loser = b + .set( + &f.state.git_store, + SetDefaultBranch { + branch: "legacy".into(), + expected_manifest: old_digest, + }, + ) + .await + .err() + .unwrap(); + assert_eq!( + loser.status(), + StatusCode::CONFLICT, + "stale no-op must CAS too" + ); + // Snapshot a push before the metadata update; it must not restore stale HEAD. + let options = || super::super::super::hydrate::HydrationOptions { + pack_cache: &f.state.git_pack_cache, + scratch_dir: f.scratch.path(), + max_pack_bytes: 1024 * 1024, + max_repo_bytes: 2 * 1024 * 1024, + }; + let (push, parent) = super::super::super::hydrate::hydrate_for_write( + &f.state.git_store, + &f.tenant, + &f.owner.public_key().to_hex(), + &f.repo, + options(), + ) + .await + .unwrap(); + assert_eq!(f.set(&f.owner, "legacy", None).await.0, StatusCode::OK); + let result = super::super::super::cas_publish::cas_publish( + &f.state.git_store, + &f.tenant, + push.path(), + &f.owner.public_key().to_hex(), + &f.repo, + &parent, + limits(push.hydrated_bytes()), + ) + .await; + assert!(matches!( + result, + Err(super::super::super::cas_publish::CasError::Conflict { .. }) + )); + // Other direction: a push deletes the candidate after settings loaded it. + let stale = f.snapshot().await; + let digest = stale.digest.clone(); + let (push, parent) = super::super::super::hydrate::hydrate_for_write( + &f.state.git_store, + &f.tenant, + &f.owner.public_key().to_hex(), + &f.repo, + options(), + ) + .await + .unwrap(); + git(push.path(), &["update-ref", "-d", "refs/heads/main"]).await; + super::super::super::cas_publish::cas_publish( + &f.state.git_store, + &f.tenant, + push.path(), + &f.owner.public_key().to_hex(), + &f.repo, + &parent, + limits(push.hydrated_bytes()), + ) + .await + .unwrap(); + assert_eq!( + stale + .set( + &f.state.git_store, + SetDefaultBranch { + branch: "main".into(), + expected_manifest: digest + } + ) + .await + .err() + .unwrap() + .status(), + StatusCode::CONFLICT + ); + assert!(!f + .snapshot() + .await + .manifest + .refs + .contains_key("refs/heads/main")); + // Restore main and add release/v1, then select the non-main branch so + // Git's initial-branch default cannot mask a lost hydrated HEAD. + let (push, parent) = super::super::super::hydrate::hydrate_for_write( + &f.state.git_store, + &f.tenant, + &f.owner.public_key().to_hex(), + &f.repo, + options(), + ) + .await + .unwrap(); + let main = git(&f.scratch.path().join("source"), &["rev-parse", "main"]).await; + git(push.path(), &["update-ref", "refs/heads/main", main.trim()]).await; + git( + push.path(), + &["update-ref", "refs/heads/release/v1", main.trim()], + ) + .await; + super::super::super::cas_publish::cas_publish( + &f.state.git_store, + &f.tenant, + push.path(), + &f.owner.public_key().to_hex(), + &f.repo, + &parent, + limits(push.hydrated_bytes()), + ) + .await + .unwrap(); + assert_eq!(f.set(&f.owner, "release/v1", None).await.0, StatusCode::OK); + let (push, parent) = super::super::super::hydrate::hydrate_for_write( + &f.state.git_store, + &f.tenant, + &f.owner.public_key().to_hex(), + &f.repo, + options(), + ) + .await + .unwrap(); + git( + push.path(), + &["update-ref", "refs/heads/later", main.trim()], + ) + .await; + super::super::super::cas_publish::cas_publish( + &f.state.git_store, + &f.tenant, + push.path(), + &f.owner.public_key().to_hex(), + &f.repo, + &parent, + limits(push.hydrated_bytes()), + ) + .await + .unwrap(); + assert_eq!(f.snapshot().await.manifest.head, "refs/heads/release/v1"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + // Add a reachable host alias for the same tenant solely in this fixture. + sqlx::query("UPDATE communities SET host = $1 WHERE id = $2") + .bind(addr.to_string()) + .bind(f.tenant.community().as_uuid()) + .execute(&f.pool) + .await + .unwrap(); + let router = super::super::super::transport::git_router(f.state.clone()); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let repo_url = format!( + "http://{addr}/git/{}/{}", + f.owner.public_key().to_hex(), + f.repo + ); + let auth = format!( + "http.extraHeader=Authorization: {}", + token(&f.owner, "GET", &repo_url, None) + ); + let refs = git( + f.scratch.path(), + &["-c", &auth, "ls-remote", "--symref", &repo_url, "HEAD"], + ) + .await; + assert!(refs.contains("ref: refs/heads/release/v1\tHEAD"), "{refs}"); + git( + f.scratch.path(), + &["-c", &auth, "clone", &repo_url, "clone"], + ) + .await; + assert_eq!( + git(&f.scratch.path().join("clone"), &["symbolic-ref", "HEAD"]) + .await + .trim(), + "refs/heads/release/v1" + ); + assert_eq!( + std::fs::read(f.scratch.path().join("clone/main.txt")).unwrap(), + b"selected branch\n" + ); + server.abort(); + } + + struct UnavailableReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for UnavailableReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { + Err(buzz_auth::AuthError::Nip98Invalid( + "injected backend failure".into(), + )) + }) + } + } + + #[tokio::test] + #[ignore = "requires isolated Postgres, Redis and MinIO"] + async fn default_branch_replay_outage_and_deletion_fail_closed() { + let mut f = Fixture::new().await; + let before = f.snapshot().await.digest; + let original = f.state.clone(); + let mut state = (*original).clone(); + state.nip98_replay = Arc::new(UnavailableReplayGuard); + f.state = Arc::new(state); + for body in [ + None, + Some(json!({"branch":"main", "expected_manifest":before})), + ] { + let (status, body) = f.call(&f.owner, body, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}"); + assert!(body.to_string().contains("replay check unavailable")); + } + assert_eq!(f.snapshot().await.digest, before); + f.state = original; + // Enter the deletion executor's transaction scope in this disposable + // fixture; the DB correctly rejects unfenced ad-hoc state changes. + let mut tx = f.pool.begin().await.unwrap(); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)") + .bind(f.tenant.community().to_string()) + .execute(&mut *tx).await.unwrap(); + sqlx::query("UPDATE communities SET deletion_state = 'quiescing' WHERE id = $1") + .bind(f.tenant.community().as_uuid()) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + assert_ne!(f.set(&f.owner, "main", None).await.0, StatusCode::OK); + assert_eq!(f.snapshot().await.digest, before); + } +} diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 638e3c7156b..704bbf1c1d6 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -247,7 +247,7 @@ impl axum::extract::FromRequestParts> for GitAuth { /// 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. -async fn deny_banned_git_principal( +pub(super) async fn deny_banned_git_principal( db: &buzz_db::Db, community: buzz_core::CommunityId, pubkey: &nostr::PublicKey, @@ -362,7 +362,7 @@ fn git_expected_url( /// repo root — but the *name* validation stays because owner/repo are /// still used as object-store key components via `manifest::pointer_key`. #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers -fn validate_repo_id<'a>(owner: &str, repo: &'a str) -> Result<&'a str, Response> { +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 @@ -483,13 +483,13 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp /// — so the remediation body leaks nothing, and only the author can rebind /// (kind:30617 is keyed by `(author, d)`). A *broken* binding stays generic /// even for the author: ambiguity fails closed. -async fn authorize_git_read( +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<(), Response> { +) -> Result { fn denied() -> Response { (StatusCode::NOT_FOUND, "repository not found").into_response() } @@ -552,7 +552,7 @@ async fn authorize_git_read( .get_member_role(community, channel_id, &caller.to_bytes()) .await { - Ok(role) if read_role_allows(role.as_deref()) => Ok(()), + 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)"); @@ -2118,6 +2118,7 @@ pub fn git_router(state: Arc) -> Router { .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) } @@ -3305,7 +3306,7 @@ mod sec005_postgres_tests { /// can assert on the exact bytes a git client would see. A blind /// `.is_err()` cannot distinguish the generic 404 from the remediation /// 404 — and that distinction IS the security property. - async fn denial_parts(result: Result<(), Response>) -> (StatusCode, String) { + async fn denial_parts(result: Result) -> (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) diff --git a/docs/git-on-object-storage.md b/docs/git-on-object-storage.md index 1d87720edc7..f97cbff5de6 100644 --- a/docs/git-on-object-storage.md +++ b/docs/git-on-object-storage.md @@ -133,6 +133,61 @@ with the relay event published best-effort afterward — so making manifest-CAS commit, with the event derived from it, is the change, not just a storage swap. See §Implementation Correspondence.) +### Default-branch management + +`buzz repos default-branch get/set` reads or changes the published manifest's +symbolic `head`. It selects an **existing** `refs/heads/`; it never moves +branch tips, creates/deletes refs, or changes packs. A subsequent push preserves +that HEAD while the branch exists, and fresh clones check it out. + +This is a narrow, intentional exception to the Nostr-first API preference: +`GET`/`POST /git/{owner}/{repo}/default-branch` completes against the host-local +Git pointer transaction. A kind:30617 metadata edit is not equivalent: normal +event ingest persists before side effects and duplicate acknowledgements do not +rerun them. A new signed command/result lifecycle could drive the same CAS, but +adds a second completion/retry protocol for this one Git-host operation. Repo +metadata and ACLs remain in Nostr; kind:30618 remains a derived notification, +never authoritative Git state. This exception is not a general repository-settings +HTTP API. + +- Both methods require request-specific NIP-98 (exact host-derived URL/method, + timestamp, signature, shared fail-closed replay check). POST also requires a + payload hash. Reusable Smart HTTP credentials are insufficient. +- GET requires current membership of the channel bound by the current 30617 + announcement. POST additionally requires a non-archived channel and the repo + author, a named maintainer, or the recorded human owner of an agent-authored + repo. Push permission, channel admin status and project membership alone do + not grant management. Relay admission and signer/attested-owner bans apply. +- An agent may inherit management from a verified NIP-OA owner who is also a + current channel member and manager. Kind-restricted credentials cannot grant + this HTTP authority (there is no event kind); temporal restrictions still + apply. Direct signer authority does not depend on an optional owner's channel + membership. A stored agent-owner relationship is an ownership lookup, not a + live delegation credential. +- Authorization is **admission-time**: later membership/maintainer removal, + rebinding or archival does not cancel an already admitted write. This is + narrower than the vision's instantaneous-revocation aspiration. The shared + serving-write lease separately fences community deletion through the write. + +GET returns `{branch, head, manifest}`. POST accepts only +`{branch, expected_manifest}` (maximum 4096 bytes) and returns the same snapshot +plus `changed`. The caller's digest must match the loaded pointer. A changed +manifest records the prior digest as its parent, then commits with that pointer's +observed ETag. Even a no-op checks the ETag. A concurrent push or default change +returns 409 instead of silently reloading and overwriting it. + +The pointer CAS is the commit point. Notification failure after commit returns +an explicit error, not a rollback claim. The CLI sends POST once, with redirects +disabled. Ambiguous delivery (including server errors or a lost response) is +non-retryable `delivery_unknown` and retains the original expected digest. Read +current state before deciding on another write; blindly running `set` without +`--expected-manifest` would observe a fresh version and can override someone +else's later decision. A conflict is exit code 5. + +Deploy relay support **before** using an updated CLI against that relay. This +requires no schema migration and changes no live repository merely by deploying. +See the [CLI examples](../crates/buzz-cli/README.md#default-branch). + ## Axioms The protocol's safety is proved *relative to* the following properties of the