fix(relay): scope media and git substrate by tenant

Community-scope media metadata sidecars so shared CAS bytes are only readable when the request tenant owns the sidecar.

Community-scope git repo pointer cells while keeping immutable pack and manifest CAS shared, and bind git NIP-98 URL verification to the server-resolved request host.

Also isolate git repo path config validation so concurrent tests do not leak BUZZ_GIT_REPO_PATH mutations into unrelated Config::from_env() callers.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
2026-06-27 14:42:29 -04:00
co-authored by Tyler Longwell
parent 1f08892501
commit f49a7dc18e
15 changed files with 534 additions and 221 deletions
Generated
+1
View File
@@ -943,6 +943,7 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
+1
View File
@@ -33,3 +33,4 @@ futures-core = "0.3"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
uuid = { workspace = true }
+109 -7
View File
@@ -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<BlobMeta, MediaError> {
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<BlobMeta, MediaError> {
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<String> {
/// 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<String> {
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").
+23 -41
View File
@@ -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<V, M, Fut>(
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<BlobDescriptor, MediaError> {
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<BlobDescriptor, MediaError> {
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<Item = Result<Bytes, axum::Error>> + Send + 'static,
content_length: Option<u64>,
@@ -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<BlobMeta, MediaError> {
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)
}
+4 -2
View File
@@ -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<String, CasError> {
///
/// **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<String, CasError> {
/// model proves safe.
pub async fn cas_publish(
store: &GitStore,
ctx: &TenantContext,
repo_path: &Path,
owner: &str,
repo: &str,
parent_state: &ParentState,
) -> Result<CasSuccess, CasError> {
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`
+6 -4
View File
@@ -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" \
+26 -9
View File
@@ -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<Option<HydratedRepo>, 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<Option<Manifest>, 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<Option<(ETag, String, Manifest)>, 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");
+49 -8
View File
@@ -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/<owner>/<repo>/pointer`.
/// The canonical pointer key for a repo: `repos/<community>/<owner>/<repo>/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/<owner>/<repo>/` 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/<community>/<owner>/<repo>/` 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]
+42 -19
View File
@@ -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<Denial> 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<u8> {
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::<MemberRole>() {
@@ -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,
+207 -88
View File
@@ -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<Arc<AppState>> for GitAuth {
@@ -94,17 +97,29 @@ impl axum::extract::FromRequestParts<Arc<AppState>> 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<Arc<AppState>> 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<Arc<AppState>> 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<String> {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"https"
} else {
"http"
};
let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
prefix
} else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
prefix
} else {
path_and_query.strip_suffix("/git-receive-pack")?
};
Some(format!("{scheme}://{}{repo_path}", tenant.host()))
}
/// Validate URL `(owner, repo)` parameters and return the canonical repo
/// id (= `repo` with any `.git` suffix stripped).
///
@@ -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<Arc<AppState>>,
_auth: GitAuth,
auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
Query(query): Query<InfoRefsQuery>,
) -> Result<Response, Response> {
@@ -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, &params.owner, &params.repo).await {
match load_manifest_for_read(&state.git_store, &auth.tenant, &params.owner, &params.repo)
.await
{
Ok(Some(manifest)) if fast_path_eligible(&manifest) => {
let body = build_upload_pack_advertisement(&manifest);
return Ok(Response::builder()
@@ -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, &params).await
info_refs_subprocess(&state, &auth.tenant, service, &params).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<AppState>,
tenant: &TenantContext,
service: &str,
params: &GitRepoParams,
) -> Result<Response, Response> {
let _permit = acquire_git_permit(state)?;
let repo = match hydrate_for_read(&state.git_store, &params.owner, &params.repo).await {
let repo = match hydrate_for_read(&state.git_store, tenant, &params.owner, &params.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(&params.owner, &params.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<Arc<AppState>>,
_auth: GitAuth,
auth: GitAuth,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
let _ = validate_repo_id(&params.owner, &params.repo)?;
let _permit = acquire_git_permit(&state)?;
let repo = match hydrate_for_read(&state.git_store, &params.owner, &params.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(&params.owner, &params.repo, e)),
};
let repo =
match hydrate_for_read(&state.git_store, &auth.tenant, &params.owner, &params.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(&params.owner, &params.repo, e)),
};
// Track A: stream the subprocess stdout straight into the response body
// instead of buffering the whole pack into RAM. `repo` (the hydrated
@@ -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, &params.owner, &params.repo)
.await
.map_err(|e| hydrate_error_to_response(&params.owner, &params.repo, e))?;
let (repo, parent_state) =
hydrate_for_write(&state.git_store, &auth.tenant, &params.owner, &params.repo)
.await
.map_err(|e| hydrate_error_to_response(&params.owner, &params.repo, e))?;
// Install the pre-receive hook into the ephemeral workspace. The
// hook script is fixed per-deployment; per-push state (callback URL,
@@ -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<AppState>, 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<AppState>, 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<AppState>) -> 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<Vec<u8>> {
+29 -9
View File
@@ -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<TenantContext, MediaError> {
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<Response, MediaError> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(sha256_ext): Path<String>,
) -> Result<Response, MediaError> {
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<String, MediaError> {
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
+29 -29
View File
@@ -146,13 +146,30 @@ pub struct Config {
pub web_dir: Option<std::path::PathBuf>,
}
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
raw.parse::<SocketAddr>()
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
}
fn ensure_git_repo_path(
raw: impl Into<std::path::PathBuf>,
) -> Result<std::path::PathBuf, ConfigError> {
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<Self, ConfigError> {
let bind_addr = std::env::var("BUZZ_BIND_ADDR")
.unwrap_or_else(|_| "0.0.0.0:3000".to_string())
.parse::<SocketAddr>()
.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:?}"
+4 -2
View File
@@ -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<String>], 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<String>],
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}"))?;
+1 -1
View File
@@ -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}")))?;
}
@@ -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<AppState>,
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)