diff --git a/Cargo.lock b/Cargo.lock index 632f91128..eb6b5fd4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,6 +943,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "uuid", ] [[package]] diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index b5bdba5cb..8f5d206d7 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -33,3 +33,4 @@ futures-core = "0.3" [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +uuid = { workspace = true } diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 48bcee5ad..4b01806a4 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -3,6 +3,8 @@ use std::path::Path; use std::pin::Pin; +use buzz_core::tenant::{CommunityId, TenantContext}; + use crate::config::MediaConfig; use crate::error::MediaError; use bytes::Bytes; @@ -147,18 +149,118 @@ impl MediaStorage { } } - /// Read sidecar JSON for a given sha256 (bare hash, no extension). - pub async fn get_sidecar(&self, sha256: &str) -> Result { - let key = format!("_meta/{sha256}.json"); + /// Build the community-scoped sidecar key for a given sha256 (bare hash). + /// + /// Raw media bytes remain shared content-addressed CAS (`{sha}.{ext}`), but + /// the metadata sidecar is the tenant read gate. A blob in another + /// community must never be observable through a global `_meta/{sha}.json` + /// lookup. + pub fn sidecar_key(community: CommunityId, sha256: &str) -> String { + format!("_meta/{community}/{sha256}.json") + } + + /// Build the community-scoped sidecar key from the resolved request tenant. + pub fn ctx_sidecar_key(ctx: &TenantContext, sha256: &str) -> String { + Self::sidecar_key(ctx.community(), sha256) + } + + /// Read community-scoped sidecar JSON for a given sha256 (bare hash). + pub async fn get_sidecar( + &self, + ctx: &TenantContext, + sha256: &str, + ) -> Result { + let key = Self::ctx_sidecar_key(ctx, sha256); let resp = self.bucket.get_object(&key).await?; let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?; Ok(meta) } - /// Convenience: read just the MIME type from the sidecar. - pub async fn read_sidecar_mime(&self, sha256_ext: &str) -> Option { + /// Write community-scoped sidecar JSON for a given sha256 (bare hash). + /// + /// `ctx` must be the server-resolved request tenant. Callers must never + /// derive the community from client-supplied blob metadata, URLs, or event + /// tags; this sidecar key is the tenant read gate for otherwise shared CAS + /// bytes. + pub async fn put_sidecar( + &self, + ctx: &TenantContext, + sha256: &str, + meta: &BlobMeta, + ) -> Result<(), MediaError> { + let key = Self::ctx_sidecar_key(ctx, sha256); + let meta_json = serde_json::to_vec(meta)?; + self.put(&key, &meta_json, "application/json").await + } + + /// Convenience: read just the MIME type from the community sidecar. + /// + /// Returns `None` for both absent sidecars and storage read failures. Public + /// read handlers intentionally collapse that distinction to 404 so an + /// A-bound request cannot distinguish a B-only blob from a missing blob. + pub async fn read_sidecar_mime(&self, ctx: &TenantContext, sha256_ext: &str) -> Option { let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); - self.get_sidecar(sha256).await.ok().map(|m| m.mime_type) + self.get_sidecar(ctx, sha256) + .await + .ok() + .map(|m| m.mime_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn tenant(n: u128) -> TenantContext { + TenantContext::resolved( + CommunityId::from_uuid(uuid::Uuid::from_u128(n)), + "media.example", + ) + } + + #[test] + fn sidecar_keys_are_community_scoped() { + let a = tenant(1); + let b = tenant(2); + let sha = "f".repeat(64); + + assert_eq!( + MediaStorage::ctx_sidecar_key(&a, &sha), + format!("_meta/{}/{sha}.json", a.community()) + ); + assert_ne!( + MediaStorage::ctx_sidecar_key(&a, &sha), + MediaStorage::ctx_sidecar_key(&b, &sha) + ); + assert_ne!( + MediaStorage::ctx_sidecar_key(&a, &sha), + format!("_meta/{sha}.json") + ); + } + + /// Mutate-bite shape for the media substrate: same CAS bytes/hash can be + /// known in A and B, but the sidecar is the read/existence gate. If the + /// community segment is dropped from `sidecar_key`, B's metadata overwrites + /// A's in this map and A observes B's MIME (wrong answer, not absence). + #[test] + fn same_sha_sidecars_do_not_bleed_between_communities() { + let a = tenant(1); + let b = tenant(2); + let sha = "a".repeat(64); + let mut sidecars = HashMap::new(); + + sidecars.insert(MediaStorage::ctx_sidecar_key(&a, &sha), "image/png"); + sidecars.insert(MediaStorage::ctx_sidecar_key(&b, &sha), "video/mp4"); + + assert_eq!( + sidecars[&MediaStorage::ctx_sidecar_key(&a, &sha)], + "image/png" + ); + assert_eq!( + sidecars[&MediaStorage::ctx_sidecar_key(&b, &sha)], + "video/mp4" + ); } } @@ -167,7 +269,7 @@ pub struct BlobHeadMeta { pub size: u64, } -/// Full blob metadata — stored as sidecar JSON in `_meta/{sha256}.json`. +/// Full blob metadata — stored as sidecar JSON in `_meta/{community}/{sha256}.json`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BlobMeta { /// Pixel dimensions ("WxH"). diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 0a324ce9a..33ca0b96e 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -1,5 +1,6 @@ //! Upload pipeline — validate, store, thumbnail, sidecar. +use buzz_core::tenant::TenantContext; use bytes::Bytes; use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; @@ -33,6 +34,7 @@ use crate::validation::{ async fn process_buffered_upload( storage: &MediaStorage, config: &MediaConfig, + ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, validate: V, @@ -58,14 +60,14 @@ where .map_err(|_| MediaError::Internal)??; let key = format!("{sha256}.{ext}"); - let meta_key = format!("_meta/{sha256}.json"); + let meta_key = MediaStorage::ctx_sidecar_key(ctx, &sha256); // Idempotent: short-circuit only if BOTH sidecar and blob exist. If the // sidecar exists but the blob is missing, fall through to re-upload. let sidecar_exists = storage.head(&meta_key).await?; let blob_exists = storage.head(&key).await?; if sidecar_exists && blob_exists { - let meta = storage.get_sidecar(&sha256).await?; + let meta = storage.get_sidecar(ctx, &sha256).await?; return Ok(build_descriptor( config, &sha256, @@ -134,12 +136,14 @@ struct MetadataInput { pub async fn process_upload( storage: &MediaStorage, config: &MediaConfig, + ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, ) -> Result { process_buffered_upload( storage, config, + ctx, auth_event, body, |bytes, cfg| { @@ -147,18 +151,7 @@ pub async fn process_upload( let ext = mime_to_ext(&mime).to_string(); Ok((mime, ext)) }, - |input| async move { - generate_and_store_metadata( - storage, - config, - &input.sha256, - &input.ext, - &input.mime, - &input.body, - input.uploaded_at, - ) - .await - }, + |input| async move { generate_and_store_metadata(storage, config, ctx, input).await }, ) .await } @@ -176,12 +169,14 @@ pub async fn process_upload( pub async fn process_file_upload( storage: &MediaStorage, config: &MediaConfig, + ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, ) -> Result { process_buffered_upload( storage, config, + ctx, auth_event, body, |bytes, cfg| validate_file_content(bytes, cfg), @@ -197,11 +192,7 @@ pub async fn process_file_upload( uploaded_at: input.uploaded_at, duration_secs: None, }; - let meta_key = format!("_meta/{}.json", input.sha256); - let meta_json = serde_json::to_vec(&meta)?; - storage - .put(&meta_key, &meta_json, "application/json") - .await?; + storage.put_sidecar(ctx, &input.sha256, &meta).await?; Ok(meta) }, ) @@ -222,6 +213,7 @@ pub async fn process_file_upload( pub async fn process_video_upload( storage: &MediaStorage, config: &MediaConfig, + ctx: &TenantContext, auth_event: &nostr::Event, body_stream: impl futures_core::Stream> + Send + 'static, content_length: Option, @@ -352,13 +344,13 @@ pub async fn process_video_upload( let ext = "mp4"; let key = format!("{sha256_hex}.{ext}"); - let meta_key = format!("_meta/{sha256_hex}.json"); + let meta_key = MediaStorage::ctx_sidecar_key(ctx, &sha256_hex); // --- 5. Idempotency check --- let sidecar_exists = storage.head(&meta_key).await?; let blob_exists = storage.head(&key).await?; if sidecar_exists && blob_exists { - let meta = storage.get_sidecar(&sha256_hex).await?; + let meta = storage.get_sidecar(ctx, &sha256_hex).await?; return Ok(build_descriptor( config, &sha256_hex, @@ -387,10 +379,7 @@ pub async fn process_video_upload( uploaded_at, duration_secs: Some(video_meta.duration_secs), }; - let meta_json = serde_json::to_vec(&meta)?; - storage - .put(&meta_key, &meta_json, "application/json") - .await?; + storage.put_sidecar(ctx, &sha256_hex, &meta).await?; Ok(build_descriptor( config, @@ -408,16 +397,13 @@ pub async fn process_video_upload( async fn generate_and_store_metadata( storage: &MediaStorage, config: &MediaConfig, - sha256: &str, - ext: &str, - mime: &str, - body: &Bytes, - uploaded_at: i64, + ctx: &TenantContext, + input: MetadataInput, ) -> Result { - let body_ref = body.clone(); - let mime_ref = mime.to_string(); - let ext_ref = ext.to_string(); - let sha256_ref = sha256.to_string(); + let body_ref = input.body.clone(); + let mime_ref = input.mime.clone(); + let ext_ref = input.ext.clone(); + let sha256_ref = input.sha256.clone(); let cfg_ref = config.clone(); let (mut meta, thumb_bytes) = tokio::task::spawn_blocking(move || { generate_image_metadata_sync(&cfg_ref, &sha256_ref, &body_ref, &mime_ref, &ext_ref) @@ -425,18 +411,14 @@ async fn generate_and_store_metadata( .await .map_err(|_| MediaError::Internal)??; - meta.uploaded_at = uploaded_at; + meta.uploaded_at = input.uploaded_at; if let Some(ref tb) = thumb_bytes { - let thumb_key = format!("{sha256}.thumb.jpg"); + let thumb_key = format!("{}.thumb.jpg", input.sha256); storage.put(&thumb_key, tb, "image/jpeg").await?; } - let meta_key = format!("_meta/{sha256}.json"); - let meta_json = serde_json::to_vec(&meta)?; - storage - .put(&meta_key, &meta_json, "application/json") - .await?; + storage.put_sidecar(ctx, &input.sha256, &meta).await?; Ok(meta) } diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index f0c09ecb7..a9c076747 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -70,6 +70,7 @@ use tracing::{debug, warn}; use crate::api::git::manifest::{pointer_key, Manifest, ManifestError, MANIFEST_VERSION}; use crate::api::git::store::{CasOutcome, ETag, GitStore, Precond, StoreError}; +use buzz_core::TenantContext; /// Errors `cas_publish` surfaces. Distinguished so `finalize_push` can map /// each to the right HTTP status (the spec's 412 → 409 mapping is here). @@ -443,7 +444,7 @@ fn digest_from_manifest_key(key: &str) -> Result { /// /// **Caller contract — `Inv_RefDerivedFromParent` is structural.** The /// `parent_state` you pass in must be the same one the workspace was -/// hydrated from. Concretely: `hydrate::hydrate_for_write(store, owner, +/// hydrated from. Concretely: `hydrate::hydrate_for_write(store, ctx, owner, /// repo)` returns `(HydratedRepo, ParentState)` from a single pointer /// observation → `install_hook(repo.path())` → run `receive-pack` /// against the workspace → call this with the **same `parent_state`**. @@ -458,12 +459,13 @@ fn digest_from_manifest_key(key: &str) -> Result { /// model proves safe. pub async fn cas_publish( store: &GitStore, + ctx: &TenantContext, repo_path: &Path, owner: &str, repo: &str, parent_state: &ParentState, ) -> Result { - let pkey = pointer_key(owner, repo); + let pkey = pointer_key(ctx.community(), owner, repo); // Snapshot post-receive-pack state from disk. `parent_state.parent.refs` // are the refs the workspace was hydrated from — `pack-objects --revs` diff --git a/crates/buzz-relay/src/api/git/hook.rs b/crates/buzz-relay/src/api/git/hook.rs index 8b0136661..57a75e243 100644 --- a/crates/buzz-relay/src/api/git/hook.rs +++ b/crates/buzz-relay/src/api/git/hook.rs @@ -23,6 +23,7 @@ use tracing::{error, info}; /// - `BUZZ_HOOK_URL` — internal policy endpoint (http://127.0.0.1:{port}/internal/git/policy) /// - `BUZZ_HOOK_SECRET` — per-push HMAC secret /// - `BUZZ_REPO_ID` — repo identifier (d-tag) +/// - `BUZZ_COMMUNITY_ID` — server-resolved community UUID for the git HTTP request /// - `BUZZ_PUSHER_PUBKEY` — authenticated pusher's hex pubkey /// /// Git sets automatically (quarantine): @@ -42,6 +43,7 @@ ZERO="0000000000000000000000000000000000000000" # Fail-closed: required env vars must be set by the relay. : "${BUZZ_REPO_ID:?error: BUZZ_REPO_ID not set}" : "${BUZZ_REPO_OWNER:?error: BUZZ_REPO_OWNER not set}" +: "${BUZZ_COMMUNITY_ID:?error: BUZZ_COMMUNITY_ID not set}" : "${BUZZ_PUSHER_PUBKEY:?error: BUZZ_PUSHER_PUBKEY not set}" : "${BUZZ_HOOK_URL:?error: BUZZ_HOOK_URL not set}" : "${BUZZ_HOOK_SECRET:?error: BUZZ_HOOK_SECRET not set}" @@ -92,13 +94,13 @@ done # Phase 2: Compute HMAC-SHA256 signature. # Payload format MUST match relay's compute_hmac() in policy.rs: -# repo_id | repo_owner | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp +# repo_id | repo_owner | community_id | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp TIMESTAMP=$(date +%s) # Structurally unambiguous HMAC format (matches Rust's compute_hmac): # len(repo_id):repo_id | repo_owner | pusher | (old_oid + new_oid + len(ref):ref + is_anc)* | timestamp REPO_ID_LEN=${#BUZZ_REPO_ID} -HMAC_INPUT="${REPO_ID_LEN}:${BUZZ_REPO_ID}|${BUZZ_REPO_OWNER}|${BUZZ_PUSHER_PUBKEY}|" +HMAC_INPUT="${REPO_ID_LEN}:${BUZZ_REPO_ID}|${BUZZ_REPO_OWNER}|${BUZZ_COMMUNITY_ID}|${BUZZ_PUSHER_PUBKEY}|" # Sort by ref_name (field 1) — matches Rust's sort_by(|a, b| a.ref_name.cmp(&b.ref_name)) if [ -f "$HMAC_FILE" ]; then sort "$HMAC_FILE" | while IFS=' ' read ref_name old_oid new_oid is_anc; do @@ -118,9 +120,9 @@ fi # Phase 3: POST to policy endpoint — FAIL-CLOSED. # repo_id is free-form (user-chosen d-tag) — must be escaped for JSON safety. -# repo_owner and pusher_pubkey are validated 64-char lowercase hex — no escaping needed. +# repo_owner, community_id, and pusher_pubkey are validated fixed-shape strings — no escaping needed. SAFE_REPO_ID=$(printf '%s' "$BUZZ_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g') -BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${BUZZ_REPO_OWNER}\",\"pusher_pubkey\":\"${BUZZ_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}" +BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${BUZZ_REPO_OWNER}\",\"community_id\":\"${BUZZ_COMMUNITY_ID}\",\"pusher_pubkey\":\"${BUZZ_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}" HTTP_CODE=$(curl --silent --max-time 10 \ -o "$RESP_FILE" \ diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 770c5666b..8d6e54576 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -37,6 +37,7 @@ use tokio::process::Command; use super::cas_publish::ParentState; use super::manifest::{is_hex_oid, is_safe_refname, pointer_key, Manifest, ManifestError}; use super::store::{ETag, GitStore, StoreError}; +use buzz_core::TenantContext; /// A bare repo hydrated to a temporary directory. /// @@ -87,10 +88,11 @@ pub enum HydrateError { /// failure is a backend/data error. pub async fn hydrate_for_read( store: &GitStore, + ctx: &TenantContext, owner: &str, repo: &str, ) -> Result, HydrateError> { - let Some((_etag, _digest, manifest)) = load_pointer(store, owner, repo).await? else { + let Some((_etag, _digest, manifest)) = load_pointer(store, ctx, owner, repo).await? else { return Ok(None); }; Ok(Some(materialize_manifest(store, &manifest).await?)) @@ -103,10 +105,11 @@ pub async fn hydrate_for_read( /// pointer-named manifest digest before returning it. pub async fn load_manifest_for_read( store: &GitStore, + ctx: &TenantContext, owner: &str, repo: &str, ) -> Result, HydrateError> { - Ok(load_pointer(store, owner, repo) + Ok(load_pointer(store, ctx, owner, repo) .await? .map(|(_etag, _digest, manifest)| manifest)) } @@ -134,10 +137,11 @@ pub async fn load_manifest_for_read( /// install a brand-new history alongside the broken one. pub async fn hydrate_for_write( store: &GitStore, + ctx: &TenantContext, owner: &str, repo: &str, ) -> Result<(HydratedRepo, ParentState), HydrateError> { - match load_pointer(store, owner, repo).await? { + match load_pointer(store, ctx, owner, repo).await? { Some((etag, digest, manifest)) => { let repo = materialize_manifest(store, &manifest).await?; let parent = ParentState::from_loaded(etag, digest, manifest); @@ -168,10 +172,11 @@ pub async fn hydrate_for_write( /// per call site). `Err(_)` on any below-pointer failure. async fn load_pointer( store: &GitStore, + ctx: &TenantContext, owner: &str, repo: &str, ) -> Result, HydrateError> { - let pkey = pointer_key(owner, repo); + let pkey = pointer_key(ctx.community(), owner, repo); let (etag, pointer_bytes) = match store.get_pointer(&pkey).await? { Some(p) => p, None => return Ok(None), @@ -371,6 +376,13 @@ mod tests { // `pointer_key` is tested in `super::manifest::tests` — single source. + fn tenant() -> TenantContext { + TenantContext::resolved( + buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(1)), + "git.example", + ) + } + // -------- Live MinIO + real git roundtrip ---------------------------------- // // Run manually: @@ -468,7 +480,8 @@ mod tests { let owner = format!("probe-{}", uuid::Uuid::new_v4()); let repo = "hello"; - let pkey = pointer_key(&owner, repo); + let ctx = tenant(); + let pkey = pointer_key(ctx.community(), &owner, repo); match st .put_pointer( &pkey, @@ -483,7 +496,7 @@ mod tests { } // Hydrate. - let hydrated = hydrate_for_read(&st, &owner, repo) + let hydrated = hydrate_for_read(&st, &ctx, &owner, repo) .await .expect("hydrate") .expect("hydrate Some"); @@ -555,7 +568,10 @@ mod tests { } let st = store(); let owner = format!("nope-{}", uuid::Uuid::new_v4()); - let result = hydrate_for_read(&st, &owner, "ghost").await.expect("ok"); + let ctx = tenant(); + let result = hydrate_for_read(&st, &ctx, &owner, "ghost") + .await + .expect("ok"); assert!(result.is_none(), "missing pointer must surface as None"); } @@ -585,7 +601,8 @@ mod tests { let manifest_digest = manifest_key.strip_prefix("manifests/").unwrap(); let owner = format!("empty-{}", uuid::Uuid::new_v4()); - let pkey = pointer_key(&owner, "void"); + let ctx = tenant(); + let pkey = pointer_key(ctx.community(), &owner, "void"); match st .put_pointer( &pkey, @@ -599,7 +616,7 @@ mod tests { super::super::store::CasOutcome::LostRace => panic!("first INM* must win"), } - let hydrated = hydrate_for_read(&st, &owner, "void") + let hydrated = hydrate_for_read(&st, &ctx, &owner, "void") .await .expect("hydrate") .expect("hydrate Some"); diff --git a/crates/buzz-relay/src/api/git/manifest.rs b/crates/buzz-relay/src/api/git/manifest.rs index 6506789e7..9c67af0d8 100644 --- a/crates/buzz-relay/src/api/git/manifest.rs +++ b/crates/buzz-relay/src/api/git/manifest.rs @@ -27,6 +27,7 @@ use std::collections::BTreeMap; +use buzz_core::tenant::CommunityId; use serde::{Deserialize, Serialize}; /// Current manifest schema version. Bump on incompatible change. @@ -133,15 +134,16 @@ fn is_manifest_digest(s: &str) -> bool { s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) } -/// The canonical pointer key for a repo: `repos///pointer`. +/// The canonical pointer key for a repo: `repos////pointer`. /// /// Single source of truth shared by `cas_publish` (write side) and `hydrate` /// (read side). Strips a trailing `.git` if the caller passed it. The -/// `repos///` namespace leaves room for future sibling keys -/// (archive flag, gc state, etc.) co-located under each repo. -pub fn pointer_key(owner: &str, repo: &str) -> String { +/// `repos////` namespace keeps the existing repo-local +/// subtree intact under the server-resolved community boundary, while shared +/// pack/manifest CAS objects remain outside that scoped pointer namespace. +pub fn pointer_key(community: CommunityId, owner: &str, repo: &str) -> String { let repo = repo.strip_suffix(".git").unwrap_or(repo); - format!("repos/{owner}/{repo}/pointer") + format!("repos/{community}/{owner}/{repo}/pointer") } impl Manifest { @@ -392,11 +394,50 @@ mod tests { #[test] fn pointer_key_strips_dot_git() { - assert_eq!(pointer_key("alice", "myrepo"), "repos/alice/myrepo/pointer"); + let c = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); assert_eq!( - pointer_key("alice", "myrepo.git"), - "repos/alice/myrepo/pointer" + pointer_key(c, "alice", "myrepo"), + format!("repos/{c}/alice/myrepo/pointer") ); + assert_eq!( + pointer_key(c, "alice", "myrepo.git"), + format!("repos/{c}/alice/myrepo/pointer") + ); + } + + #[test] + fn pointer_key_is_community_scoped() { + let a = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + let b = CommunityId::from_uuid(uuid::Uuid::from_u128(2)); + + assert_ne!( + pointer_key(a, "alice", "repo"), + pointer_key(b, "alice", "repo") + ); + assert_eq!( + pointer_key(a, "alice", "repo"), + format!("repos/{a}/alice/repo/pointer") + ); + assert_ne!(pointer_key(a, "alice", "repo"), "repos/alice/repo/pointer"); + } + + /// Mutate-bite shape for git hosting: same owner/repo string in two + /// communities must resolve to different pointer cells. If the community + /// segment is dropped from `pointer_key`, B overwrites A and A observes B's + /// manifest pointer (wrong answer, not absence). + #[test] + fn same_owner_repo_pointers_do_not_bleed_between_communities() { + use std::collections::HashMap; + + let a = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + let b = CommunityId::from_uuid(uuid::Uuid::from_u128(2)); + let mut pointers = HashMap::new(); + + pointers.insert(pointer_key(a, "alice", "repo"), "manifest-a"); + pointers.insert(pointer_key(b, "alice", "repo"), "manifest-b"); + + assert_eq!(pointers[&pointer_key(a, "alice", "repo")], "manifest-a"); + assert_eq!(pointers[&pointer_key(b, "alice", "repo")], "manifest-b"); } #[test] diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 5f122faac..7526d6fca 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -56,6 +56,9 @@ pub struct HookCallbackRequest { pub repo_id: String, /// Hex-encoded repo owner pubkey (from URL path, verified against kind:30617). pub repo_owner: String, + /// Server-resolved community id from the git HTTP request that spawned the hook. + /// Internal-only: set by relay env and HMAC-bound by the hook callback. + pub community_id: String, /// Hex-encoded pusher pubkey. pub pusher_pubkey: String, /// Ref updates from git stdin (old_oid, new_oid, ref_name, is_ancestor). @@ -112,7 +115,7 @@ impl From for DenialResponse { /// /// Format (length-prefixed, `|`-separated, structurally unambiguous): /// ```text -/// len(repo_id):repo_id | repo_owner(64) | pusher(64) | sorted_refs | timestamp +/// len(repo_id):repo_id | repo_owner(64) | community_id(36) | pusher(64) | sorted_refs | timestamp /// ``` /// where each ref is: `old_oid(40) + new_oid(40) + len(ref_name):ref_name + is_ancestor("1"/"0")` /// @@ -129,6 +132,8 @@ fn compute_hmac(secret: &[u8], req: &HookCallbackRequest) -> Vec { mac.update(b"|"); mac.update(req.repo_owner.as_bytes()); // Fixed 64 chars, no ambiguity. mac.update(b"|"); + mac.update(req.community_id.as_bytes()); // Fixed UUID string from server-resolved tenant. + mac.update(b"|"); mac.update(req.pusher_pubkey.as_bytes()); // Fixed 64 chars, no ambiguity. mac.update(b"|"); // Deterministic ref update representation: sorted by ref_name. @@ -189,6 +194,11 @@ pub async fn hook_policy_check( { return (StatusCode::FORBIDDEN, "invalid pusher_pubkey").into_response(); } + let community_uuid = match Uuid::parse_str(&req.community_id) { + Ok(id) => id, + Err(_) => return (StatusCode::FORBIDDEN, "invalid community_id").into_response(), + }; + let community = buzz_core::CommunityId::from_uuid(community_uuid); if req.ref_updates.is_empty() || req.ref_updates.len() > 500 { return (StatusCode::FORBIDDEN, "invalid ref_updates count").into_response(); } @@ -231,32 +241,22 @@ pub async fn hook_policy_check( } // 4. Validate and resolve kind:30617 for this repo. - // Query by (kind=30617, pubkey=owner, d_tag=repo_id) to prevent spoofing. + // Query by (community_id, kind=30617, pubkey=owner, d_tag=repo_id) to + // prevent spoofing and keep the localhost hook callback on the same + // server-resolved tenant as the git HTTP request that spawned it. let owner_bytes = match hex::decode(&req.repo_owner) { Ok(b) if b.len() == 32 => b, _ => { return (StatusCode::FORBIDDEN, "invalid repo owner").into_response(); } }; - // Resolve the deployment's own community from the configured relay host — - // the localhost hook callback has no inbound `Host` to bind. Fail closed if - // the host isn't mapped (never a default tenant). - let tenant = match crate::tenant::bind_deployment_community(&state.db, &state.config.relay_url) - .await - { - Ok(ctx) => ctx, - Err(e) => { - error!(repo = %req.repo_id, error = ?e, "hook callback: relay host not mapped to a community"); - return (StatusCode::FORBIDDEN, "internal error").into_response(); - } - }; let query = EventQuery { kinds: Some(vec![30617]), pubkey: Some(owner_bytes), d_tag: Some(req.repo_id.clone()), global_only: true, limit: Some(1), - ..EventQuery::for_community(tenant.community()) + ..EventQuery::for_community(community) }; let repo_event = match state.db.query_events(&query).await { Ok(mut events) => { @@ -304,7 +304,7 @@ pub async fn hook_policy_check( .and_then(|id| Uuid::parse_str(id).ok()); if let Some(ch_id) = channel_id { - match state.db.get_channel(tenant.community(), ch_id).await { + match state.db.get_channel(community, ch_id).await { Ok(ch) if ch.archived_at.is_some() => { return (StatusCode::FORBIDDEN, "channel is archived (read-only)").into_response(); } @@ -335,7 +335,7 @@ pub async fn hook_policy_check( }; match state .db - .get_member_role(tenant.community(), ch_id, &pusher_bytes) + .get_member_role(community, ch_id, &pusher_bytes) .await { Ok(Some(role_str)) => match role_str.parse::() { @@ -400,6 +400,7 @@ pub fn generate_hook_hmac( secret: &[u8], repo_id: &str, repo_owner: &str, + community_id: &str, pusher_pubkey: &str, ref_updates: &[HookRefUpdate], timestamp: u64, @@ -407,6 +408,7 @@ pub fn generate_hook_hmac( let req = HookCallbackRequest { repo_id: repo_id.to_string(), repo_owner: repo_owner.to_string(), + community_id: community_id.to_string(), pusher_pubkey: pusher_pubkey.to_string(), ref_updates: ref_updates.to_vec(), timestamp, @@ -424,6 +426,7 @@ mod tests { HookCallbackRequest { repo_id: "test-repo".to_string(), repo_owner: "a".repeat(64), + community_id: uuid::Uuid::from_u128(1).to_string(), pusher_pubkey: "b".repeat(64), ref_updates: vec![HookRefUpdate { old_oid: "1".repeat(40), @@ -521,6 +524,18 @@ mod tests { assert!(!verify_hmac(secret, &req)); } + /// Tampering the server-resolved community changes the HMAC input, so a + /// hook callback cannot be replayed across communities even though the + /// localhost policy endpoint itself has no inbound Host header. + #[test] + fn hmac_tampered_community_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.community_id = uuid::Uuid::from_u128(2).to_string(); + assert!(!verify_hmac(secret, &req)); + } + #[test] fn hmac_deterministic_across_ref_order() { let secret = b"test-secret"; @@ -547,6 +562,7 @@ mod tests { secret, &req.repo_id, &req.repo_owner, + &req.community_id, &req.pusher_pubkey, &req.ref_updates, req.timestamp, @@ -567,6 +583,7 @@ mod tests { let repo_id = "my-project"; let repo_owner = "ab".repeat(32); // 64 hex chars let pusher = "cd".repeat(32); // 64 hex chars + let community_id = uuid::Uuid::from_u128(1).to_string(); let timestamp: u64 = 1700000000; // Two refs, intentionally out of sorted order to test sorting. @@ -590,6 +607,7 @@ mod tests { secret.as_bytes(), repo_id, &repo_owner, + &community_id, &pusher, &ref_updates, timestamp, @@ -603,6 +621,7 @@ mod tests { export LC_ALL=C BUZZ_REPO_ID="{repo_id}" BUZZ_REPO_OWNER="{repo_owner}" +BUZZ_COMMUNITY_ID="{community_id}" BUZZ_PUSHER_PUBKEY="{pusher}" BUZZ_HOOK_SECRET="{secret}" TIMESTAMP="{timestamp}" @@ -618,7 +637,7 @@ echo "refs/heads/feature {old2} {new2} 0" >> "$HMAC_FILE" # Build HMAC input — exact logic from hook script REPO_ID_LEN=${{#BUZZ_REPO_ID}} -HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|${{BUZZ_REPO_OWNER}}|${{BUZZ_PUSHER_PUBKEY}}|" +HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|${{BUZZ_REPO_OWNER}}|${{BUZZ_COMMUNITY_ID}}|${{BUZZ_PUSHER_PUBKEY}}|" sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do REF_LEN=${{#ref_name}} printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc" @@ -630,6 +649,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$BUZZ_HOOK_SECRET" -hex "#, repo_id = repo_id, repo_owner = repo_owner, + community_id = community_id, pusher = pusher, secret = secret, timestamp = timestamp, @@ -667,6 +687,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$BUZZ_HOOK_SECRET" -hex let repo_id = "test-repo"; let repo_owner = "a".repeat(64); let pusher = "b".repeat(64); + let community_id = uuid::Uuid::from_u128(1).to_string(); let timestamp: u64 = 1700000001; let ref_updates = vec![HookRefUpdate { @@ -680,6 +701,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$BUZZ_HOOK_SECRET" -hex secret.as_bytes(), repo_id, &repo_owner, + &community_id, &pusher, &ref_updates, timestamp, @@ -694,7 +716,7 @@ HMAC_FILE="$WORK_DIR/hmac" echo "refs/heads/main {old} {new} 1" >> "$HMAC_FILE" BUZZ_REPO_ID="{repo_id}" REPO_ID_LEN=${{#BUZZ_REPO_ID}} -HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|{owner}|{pusher}|" +HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|{owner}|{community_id}|{pusher}|" sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do REF_LEN=${{#ref_name}} printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc" @@ -706,6 +728,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu new = "2".repeat(40), repo_id = repo_id, owner = repo_owner, + community_id = community_id, pusher = pusher, timestamp = timestamp, secret = secret, diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 0942e0001..7d61569a9 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -33,6 +33,7 @@ use super::hydrate::{ }; 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); @@ -50,6 +51,8 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300 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> for GitAuth { @@ -94,17 +97,29 @@ impl axum::extract::FromRequestParts> for GitAuth { let event_json = String::from_utf8(event_bytes) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; - // Use configured relay_url as canonical base (don't trust forwarded headers). - let relay_url = &state.config.relay_url; - let base_url = relay_url - .replace("ws://", "http://") - .replace("wss://", "https://"); - let base_url = base_url.trim_end_matches('/'); - let path_and_query = parts - .uri - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or(parts.uri.path()); + // Row zero for Git HTTP: bind the request Host to a server-resolved + // tenant before URL verification. We still do not trust forwarded + // headers; the signed `u` tag is checked against the host that resolved + // through the authoritative communities table, not a deployment-global + // `config.relay_url` and not any client-supplied community value. + let raw_host = parts + .headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "repository not found").into_response())?; + let expected_url = git_expected_url( + &state.config.relay_url, + &tenant, + parts + .uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or(parts.uri.path()), + ) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response())?; // Repo-root URL verification. // @@ -120,16 +135,6 @@ impl axum::extract::FromRequestParts> for GitAuth { // - HTTPS in production (prevents token theft) // - Pre-receive hook for push authorization (role + protection rules) // - Endpoint routing (clone/push are different HTTP paths) - 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 if let Some(prefix) = path_and_query.strip_suffix("/git-receive-pack") { - prefix - } else { - return Err((StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response()); - }; - let expected_url = format!("{base_url}{repo_path}"); // Skip HTTP method check for git routes. // @@ -185,10 +190,37 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } - Ok(GitAuth { pubkey }) + Ok(GitAuth { pubkey, tenant }) } } +/// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. +/// +/// The host is always the server-resolved tenant host. `config_relay_url` only +/// contributes the deployment scheme (`wss://` => `https://`, otherwise +/// `http://`) so a request to community B cannot authenticate with a token +/// signed for community A's URL just because the deployment has one global +/// `relay_url`. +fn git_expected_url( + config_relay_url: &str, + tenant: &TenantContext, + path_and_query: &str, +) -> Option { + 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). /// @@ -461,7 +493,7 @@ fn build_upload_pack_advertisement(manifest: &super::manifest::Manifest) -> Vec< /// error" behavior is gone — A1 detectability holds on the read side too. pub async fn info_refs( State(state): State>, - _auth: GitAuth, + auth: GitAuth, AxumPath(params): AxumPath, Query(query): Query, ) -> Result { @@ -479,7 +511,9 @@ pub async fn info_refs( 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, ¶ms.owner, ¶ms.repo).await { + 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() @@ -504,7 +538,7 @@ pub async fn info_refs( // Subprocess path: receive-pack advertisement, or upload-pack for a // tagged repo. Acquires a permit and hydrates — today's behavior. - info_refs_subprocess(&state, service, ¶ms).await + info_refs_subprocess(&state, &auth.tenant, service, ¶ms).await } /// Subprocess-backed `info/refs` advertisement: hydrate the published state @@ -516,12 +550,13 @@ pub async fn info_refs( /// lose the clean timeout/error mapping that buffering gives us. async fn info_refs_subprocess( state: &Arc, + tenant: &TenantContext, service: &str, params: &GitRepoParams, ) -> Result { let _permit = acquire_git_permit(state)?; - let repo = match hydrate_for_read(&state.git_store, ¶ms.owner, ¶ms.repo).await { + let repo = match hydrate_for_read(&state.git_store, tenant, ¶ms.owner, ¶ms.repo).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)), @@ -597,18 +632,19 @@ async fn info_refs_subprocess( /// the tempdir lives only for the duration of this request. pub async fn upload_pack( State(state): State>, - _auth: GitAuth, + auth: GitAuth, AxumPath(params): AxumPath, body: Body, ) -> Result { let _ = validate_repo_id(¶ms.owner, ¶ms.repo)?; let _permit = acquire_git_permit(&state)?; - let repo = match hydrate_for_read(&state.git_store, ¶ms.owner, ¶ms.repo).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)), - }; + let repo = + match hydrate_for_read(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo).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 @@ -682,9 +718,10 @@ pub async fn receive_pack( // 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, ¶ms.owner, ¶ms.repo) - .await - .map_err(|e| hydrate_error_to_response(¶ms.owner, ¶ms.repo, e))?; + let (repo, parent_state) = + hydrate_for_write(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo) + .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, @@ -708,6 +745,10 @@ pub async fn receive_pack( ), ("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()), // Override any repo-local core.hooksPath setting; defense in // depth even though the hydrated workspace has no inherited @@ -729,6 +770,7 @@ pub async fn receive_pack( 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) @@ -986,6 +1028,9 @@ pub(crate) struct PushContext { /// `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. @@ -1007,6 +1052,7 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { // between hydrate and CAS. let success = match cas_publish( &state.git_store, + &ctx.tenant, ctx.repo_handle.path(), &ctx.owner, &ctx.repo, @@ -1096,64 +1142,44 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { }; match build_ref_state_event(&inputs, &state.relay_keypair) { Ok(event) => { - // Relay-signed kind:30618 belongs to the deployment's own - // community, resolved fail-closed from the configured relay - // host (the git transport has no inbound `Host` to bind). - let tenant = match crate::tenant::bind_deployment_community( - &state.db, - &state.config.relay_url, - ) - .await + // Relay-signed kind:30618 belongs to the same server-resolved + // tenant as the git request that committed the pointer. + match state + .db + .insert_event(ctx.tenant.community(), &event, None) + .await { - Ok(ctx) => Some(ctx), - Err(e) => { - warn!( - error = ?e, + 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, - "kind:30618 publish skipped: relay host not mapped to a community" + manifest = %success.manifest_key, + "kind:30618 published (derived after CAS)" ); - None } - }; - if let Some(tenant) = tenant { - match state - .db - .insert_event(tenant.community(), &event, None) - .await - { - Ok((stored, true)) => { - // Routed through the guarded send path for uniformity; - // the access gate no-ops for this globally-scoped - // (channel_id = None) ref-state event. - crate::handlers::event::fan_out_event_to_local_subscribers( - state, - tenant.community(), - &stored, - ) - .await; - info!( - owner = %ctx.owner, - repo = %ctx.repo_id, - manifest = %success.manifest_key, - "kind:30618 published (derived after CAS)" - ); - } - Ok((_, false)) => { - info!( - owner = %ctx.owner, - repo = %ctx.repo_id, - "kind:30618 deduplicated by relay db" - ); - } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 insert failed; push remains durable in object store" - ); - } + Ok((_, false)) => { + info!( + owner = %ctx.owner, + repo = %ctx.repo_id, + "kind:30618 deduplicated by relay db" + ); + } + Err(e) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo_id, + error = %e, + "kind:30618 insert failed; push remains durable in object store" + ); } } } @@ -1195,6 +1221,8 @@ pub fn git_router(state: Arc) -> Router { mod track_c_tests { use super::*; use crate::api::git::manifest::Manifest; + use buzz_core::CommunityId; + use nostr::{EventBuilder, Keys, Kind, Tag}; use std::collections::BTreeMap; fn oid_sha1() -> String { @@ -1214,6 +1242,97 @@ mod track_c_tests { } } + 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") + } + + #[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> { diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index d5370b6c7..c3e86b238 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -166,6 +166,7 @@ pub async fn upload_blob( buzz_media::process_video_upload( &state.media_storage, &state.config.media, + &auth.tenant, &auth.auth_event, body.into_data_stream(), content_length, @@ -195,6 +196,7 @@ pub async fn upload_blob( buzz_media::process_upload( &state.media_storage, &state.config.media, + &auth.tenant, &auth.auth_event, bytes, ) @@ -203,6 +205,7 @@ pub async fn upload_blob( buzz_media::process_file_upload( &state.media_storage, &state.config.media, + &auth.tenant, &auth.auth_event, bytes, ) @@ -243,6 +246,19 @@ pub async fn upload_blob( Ok(Json(descriptor)) } +async fn bind_media_read_tenant( + state: &AppState, + headers: &HeaderMap, +) -> Result { + let raw_host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| MediaError::NotFound) +} + /// Whether a path-segment extension is a safe token. /// /// The sidecar's `ext` field is the *authoritative* extension — the serve and @@ -328,13 +344,14 @@ pub async fn get_blob( req_headers: HeaderMap, ) -> Result { validate_media_path(&sha256_ext)?; + let tenant = bind_media_read_tenant(&state, &req_headers).await?; // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. let content_type = if sha256_ext.ends_with(".thumb.jpg") { let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext); let _ = state .media_storage - .read_sidecar_mime(parent_hash) + .read_sidecar_mime(&tenant, parent_hash) .await .ok_or(MediaError::NotFound)?; "image/jpeg".to_string() @@ -343,14 +360,14 @@ pub async fn get_blob( // the sidecar's canonical extension — sidecar is authoritative. let sidecar_mime = state .media_storage - .read_sidecar_mime(&sha256_ext) + .read_sidecar_mime(&tenant, &sha256_ext) .await .ok_or(MediaError::NotFound)?; if sha256_ext.contains('.') { let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); let sidecar = state .media_storage - .get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext)) + .get_sidecar(&tenant, sha256_ext.split('.').next().unwrap_or(&sha256_ext)) .await .map_err(|_| MediaError::NotFound)?; if requested_ext != sidecar.ext { @@ -370,7 +387,7 @@ pub async fn get_blob( "attachment" }; - let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?; + let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?; // Parse optional Range header. let range_header = req_headers @@ -506,30 +523,32 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> { /// is missing, we return 404 rather than fall back to untrusted metadata. pub async fn head_blob( State(state): State>, + headers: HeaderMap, Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; + let tenant = bind_media_read_tenant(&state, &headers).await?; // Sidecar gate FIRST — reject before any blob I/O. let content_type = if sha256_ext.ends_with(".thumb.jpg") { let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext); let _ = state .media_storage - .read_sidecar_mime(parent_hash) + .read_sidecar_mime(&tenant, parent_hash) .await .ok_or(MediaError::NotFound)?; "image/jpeg".to_string() } else { let sidecar_mime = state .media_storage - .read_sidecar_mime(&sha256_ext) + .read_sidecar_mime(&tenant, &sha256_ext) .await .ok_or(MediaError::NotFound)?; if sha256_ext.contains('.') { let requested_ext = sha256_ext.rsplit('.').next().unwrap_or(""); let sidecar = state .media_storage - .get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext)) + .get_sidecar(&tenant, sha256_ext.split('.').next().unwrap_or(&sha256_ext)) .await .map_err(|_| MediaError::NotFound)?; if requested_ext != sidecar.ext { @@ -539,7 +558,7 @@ pub async fn head_blob( sidecar_mime }; - let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?; + let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?; match state.media_storage.head_with_metadata(&key).await? { Some(meta) => { let size_str = meta.size.to_string(); @@ -567,13 +586,14 @@ pub async fn head_blob( /// object-key confusion if sidecar data is ever tampered with. async fn resolve_s3_key( storage: &buzz_media::MediaStorage, + tenant: &TenantContext, sha256_ext: &str, ) -> Result { if sha256_ext.contains('.') { Ok(sha256_ext.to_string()) } else { let sidecar = storage - .get_sidecar(sha256_ext) + .get_sidecar(tenant, sha256_ext) .await .map_err(|_| MediaError::NotFound)?; // Validate sidecar ext — never trust storage as authoritative for path construction diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index ca0d2486b..9824a1663 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -146,13 +146,30 @@ pub struct Config { pub web_dir: Option, } +fn parse_bind_addr(raw: &str) -> Result { + raw.parse::() + .map_err(|e| ConfigError::InvalidBindAddr(e.to_string())) +} + +fn ensure_git_repo_path( + raw: impl Into, +) -> Result { + let git_repo_path = raw.into(); + if let Err(e) = std::fs::create_dir_all(&git_repo_path) { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_GIT_REPO_PATH={} could not be created: {e}", + git_repo_path.display() + ))); + } + Ok(git_repo_path) +} + impl Config { /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { - let bind_addr = std::env::var("BUZZ_BIND_ADDR") - .unwrap_or_else(|_| "0.0.0.0:3000".to_string()) - .parse::() - .map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))?; + let bind_addr_raw = + std::env::var("BUZZ_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".to_string()); + let bind_addr = parse_bind_addr(&bind_addr_raw)?; let database_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); @@ -331,22 +348,9 @@ impl Config { } // Git server config - let git_repo_path: std::path::PathBuf = std::env::var("BUZZ_GIT_REPO_PATH") - .unwrap_or_else(|_| "./repos".to_string()) - .into(); - // Ensure the git repo root exists. The smart-HTTP transport and the - // kind:30617 side-effect handler both canonicalize this path; if it's - // missing, all git operations 500 with "git service misconfigured" and - // repo announcements silently fail to create their bare repo on disk. - // Bootstrapping here makes the relay self-provision its own data dir - // (matches how we treat other relay-owned paths) rather than requiring - // ops to mkdir it out of band. - if let Err(e) = std::fs::create_dir_all(&git_repo_path) { - return Err(ConfigError::InvalidValue(format!( - "BUZZ_GIT_REPO_PATH={} could not be created: {e}", - git_repo_path.display() - ))); - } + let git_repo_path = ensure_git_repo_path( + std::env::var("BUZZ_GIT_REPO_PATH").unwrap_or_else(|_| "./repos".to_string()), + )?; let git_max_pack_bytes: u64 = std::env::var("BUZZ_GIT_MAX_PACK_BYTES") .ok() .and_then(|v| v.parse().ok()) @@ -484,11 +488,10 @@ mod tests { #[test] fn invalid_bind_addr_returns_error() { - let _guard = ENV_MUTEX.lock().unwrap(); - std::env::set_var("BUZZ_BIND_ADDR", "not-an-addr"); - let result = Config::from_env(); - std::env::remove_var("BUZZ_BIND_ADDR"); - assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_)))); + assert!(matches!( + parse_bind_addr("not-an-addr"), + Err(ConfigError::InvalidBindAddr(_)) + )); } #[test] @@ -560,13 +563,10 @@ mod tests { #[test] #[cfg(unix)] fn git_repo_path_unwritable_returns_error() { - let _guard = ENV_MUTEX.lock().unwrap(); // Try to create a path under a regular file — must fail. // Using /dev/null as the parent guarantees create_dir_all fails on unix. let bogus = std::path::PathBuf::from("/dev/null/cannot-create-here"); - std::env::set_var("BUZZ_GIT_REPO_PATH", &bogus); - let result = Config::from_env(); - std::env::remove_var("BUZZ_GIT_REPO_PATH"); + let result = ensure_git_repo_path(&bogus); assert!( matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_GIT_REPO_PATH")), "expected InvalidValue mentioning BUZZ_GIT_REPO_PATH, got {result:?}" diff --git a/crates/buzz-relay/src/handlers/imeta.rs b/crates/buzz-relay/src/handlers/imeta.rs index 4f7174e8d..6cd07ab2c 100644 --- a/crates/buzz-relay/src/handlers/imeta.rs +++ b/crates/buzz-relay/src/handlers/imeta.rs @@ -1,5 +1,6 @@ //! imeta tag validation helpers — shared between ingest pipeline and bridge. +use buzz_core::tenant::TenantContext; use buzz_media::validation::mime_to_ext; /// Validate imeta tags for correctness and safety. @@ -206,6 +207,7 @@ pub fn validate_imeta_tags(tags: &[Vec], media_base_url: &str) -> Result /// Verify that every imeta tag references a blob that actually exists in storage /// and that the claimed metadata (size, MIME) matches the sidecar. pub async fn verify_imeta_blobs( + ctx: &TenantContext, tags: &[Vec], storage: &buzz_media::MediaStorage, ) -> Result<(), String> { @@ -238,7 +240,7 @@ pub async fn verify_imeta_blobs( // 1. Sidecar must exist let sidecar = storage - .get_sidecar(&x_value) + .get_sidecar(ctx, &x_value) .await .map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?; @@ -293,7 +295,7 @@ pub async fn verify_imeta_blobs( .ok_or_else(|| format!("imeta image URL has no extractable hash: {image_value}"))?; let img_sidecar = storage - .get_sidecar(img_hash) + .get_sidecar(ctx, img_hash) .await .map_err(|_| format!("imeta image references nonexistent poster: {img_hash}"))?; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 2e400cf32..f20065623 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1737,7 +1737,7 @@ async fn ingest_event_inner( if !imeta_tags.is_empty() { crate::api::validate_imeta_tags(&imeta_tags, &state.config.media.public_base_url) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; - crate::api::verify_imeta_blobs(&imeta_tags, &state.media_storage) + crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage) .await .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 9e2e82cc3..fb0f92394 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2167,7 +2167,7 @@ async fn handle_git_repo_announcement( // on pointer-absent meaning never-announced (not just no-pushes-yet), // keeping `info_refs`'s fail-closed `Ok(None) → 404` unambiguous. // First push CASes the seeded pointer normally — no special-case branch. - seed_manifest_pointer(state, &owner_hex, &repo_id) + seed_manifest_pointer(state, tenant, &owner_hex, &repo_id) .await .map_err(|e| { // A reserved name without a clone-able pointer is exactly the @@ -2221,6 +2221,7 @@ const DEFAULT_HEAD: &str = "refs/heads/main"; /// succeeding — that would mask a real misconfiguration. async fn seed_manifest_pointer( state: &Arc, + tenant: &TenantContext, owner_hex: &str, repo_id: &str, ) -> anyhow::Result<()> { @@ -2253,7 +2254,7 @@ async fn seed_manifest_pointer( .strip_prefix("manifests/") .ok_or_else(|| anyhow::anyhow!("put_manifest returned non-standard key: {manifest_key}"))?; - let pkey = pointer_key(owner_hex, repo_id); + let pkey = pointer_key(tenant.community(), owner_hex, repo_id); let outcome = state .git_store .put_pointer(&pkey, digest.as_bytes(), Precond::IfNoneMatchStar)