mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
1f08892501
commit
f49a7dc18e
Generated
+1
@@ -943,6 +943,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -33,3 +33,4 @@ futures-core = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¶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<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, ¶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<Arc<AppState>>,
|
||||
_auth: GitAuth,
|
||||
auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
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<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>> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user