diff --git a/crates/buzz-media/src/keys.rs b/crates/buzz-media/src/keys.rs new file mode 100644 index 000000000..695d818ce --- /dev/null +++ b/crates/buzz-media/src/keys.rs @@ -0,0 +1,190 @@ +//! Deterministic object-key derivation for media payloads. +//! +//! Public Blossom URLs stay flat (`/media/.`), while S3 payloads use +//! hash-leading shards so aggregate request traffic is distributed before the +//! community segment. Legacy keys remain read candidates during migration. + +use buzz_core::tenant::{CommunityId, TenantContext}; + +/// Invalid data supplied to media object-key construction. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MediaKeyError { + /// SHA-256 must be exactly 64 lowercase hexadecimal characters. + #[error("invalid SHA-256 digest")] + InvalidSha256, + /// Extensions are canonical lowercase alphanumeric tokens of 1-8 bytes. + #[error("invalid media extension")] + InvalidExtension, + /// Only `.` and `.thumb.jpg` payload names are accepted. + #[error("invalid media payload name")] + InvalidPayloadName, +} + +/// Ordered object keys for compatibility reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaReadCandidates { + /// Hash-sharded, community-scoped key tried first. + pub sharded: String, + /// Flat pre-migration key tried only when `sharded` is not found. + pub legacy: String, +} + +fn validate_sha256(sha256: &str) -> Result<(), MediaKeyError> { + if sha256.len() == 64 + && sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + Ok(()) + } else { + Err(MediaKeyError::InvalidSha256) + } +} + +fn validate_extension(ext: &str) -> Result<(), MediaKeyError> { + if (1..=8).contains(&ext.len()) + && ext + .bytes() + .all(|byte| byte.is_ascii_digit() || byte.is_ascii_lowercase()) + { + Ok(()) + } else { + Err(MediaKeyError::InvalidExtension) + } +} + +/// Flat pre-migration blob key: `.`. +pub fn legacy_blob_key(sha256: &str, ext: &str) -> Result { + validate_sha256(sha256)?; + validate_extension(ext)?; + Ok(format!("{sha256}.{ext}")) +} + +/// Hash-leading blob key: `m/<2>/<2>//.`. +pub fn sharded_blob_key( + community: CommunityId, + sha256: &str, + ext: &str, +) -> Result { + let filename = legacy_blob_key(sha256, ext)?; + Ok(format!( + "m/{}/{}/{community}/{filename}", + &sha256[..2], + &sha256[2..4] + )) +} + +/// Flat pre-migration thumbnail key: `.thumb.jpg`. +pub fn legacy_thumb_key(sha256: &str) -> Result { + validate_sha256(sha256)?; + Ok(format!("{sha256}.thumb.jpg")) +} + +/// Hash-leading thumbnail key: `m/<2>/<2>//.thumb.jpg`. +pub fn sharded_thumb_key(community: CommunityId, sha256: &str) -> Result { + let filename = legacy_thumb_key(sha256)?; + Ok(format!( + "m/{}/{}/{community}/{filename}", + &sha256[..2], + &sha256[2..4] + )) +} + +/// Build new-first, legacy-fallback candidates from a validated public payload name. +/// +/// The community always comes from the server-resolved tenant context; callers +/// cannot supply it through a URL, sidecar, or upload record. +pub fn read_candidates( + ctx: &TenantContext, + payload_name: &str, +) -> Result { + if let Some(sha256) = payload_name.strip_suffix(".thumb.jpg") { + return Ok(MediaReadCandidates { + sharded: sharded_thumb_key(ctx.community(), sha256)?, + legacy: legacy_thumb_key(sha256)?, + }); + } + + let (sha256, ext) = payload_name + .split_once('.') + .ok_or(MediaKeyError::InvalidPayloadName)?; + if ext.contains('.') { + return Err(MediaKeyError::InvalidPayloadName); + } + Ok(MediaReadCandidates { + sharded: sharded_blob_key(ctx.community(), sha256, ext)?, + legacy: legacy_blob_key(sha256, ext)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + const SHA: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + + fn tenant(n: u128) -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(n)), "media.example") + } + + #[test] + fn derives_hash_leading_community_scoped_blob_and_thumb_keys() { + let ctx = tenant(1); + let community = ctx.community(); + + assert_eq!( + sharded_blob_key(community, SHA, "jpg").unwrap(), + format!("m/ab/cd/{community}/{SHA}.jpg") + ); + assert_eq!( + sharded_thumb_key(community, SHA).unwrap(), + format!("m/ab/cd/{community}/{SHA}.thumb.jpg") + ); + assert_ne!( + sharded_blob_key(community, SHA, "jpg").unwrap(), + sharded_blob_key(tenant(2).community(), SHA, "jpg").unwrap() + ); + } + + #[test] + fn orders_sharded_before_legacy_for_blobs_and_thumbnails() { + let ctx = tenant(1); + let community = ctx.community(); + + assert_eq!( + read_candidates(&ctx, &format!("{SHA}.png")).unwrap(), + MediaReadCandidates { + sharded: format!("m/ab/cd/{community}/{SHA}.png"), + legacy: format!("{SHA}.png"), + } + ); + assert_eq!( + read_candidates(&ctx, &format!("{SHA}.thumb.jpg")).unwrap(), + MediaReadCandidates { + sharded: format!("m/ab/cd/{community}/{SHA}.thumb.jpg"), + legacy: format!("{SHA}.thumb.jpg"), + } + ); + } + + #[test] + fn rejects_noncanonical_or_ambiguous_inputs() { + for sha in ["abc", &"A".repeat(64), &"g".repeat(64)] { + assert_eq!( + legacy_blob_key(sha, "jpg"), + Err(MediaKeyError::InvalidSha256) + ); + } + for ext in ["", "JPG", "tar.gz", "toolongext", "../jpg"] { + assert_eq!( + legacy_blob_key(SHA, ext), + Err(MediaKeyError::InvalidExtension) + ); + } + assert_eq!( + read_candidates(&tenant(1), SHA), + Err(MediaKeyError::InvalidPayloadName) + ); + } +} diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index 67896d4ef..1a9a25b89 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod bucket_index; pub mod config; pub mod error; +pub mod keys; pub mod storage; pub mod thumbnail; pub mod types; @@ -19,6 +20,10 @@ pub use bucket_index::{ }; pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; +pub use keys::{ + legacy_blob_key, legacy_thumb_key, read_candidates, sharded_blob_key, sharded_thumb_key, + MediaKeyError, MediaReadCandidates, +}; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; pub use upload::{process_file_upload, process_upload, process_video_upload}; diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index cbf980201..18c718b8c 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -177,6 +177,27 @@ impl MediaStorage { } } + /// Resolve a media payload to its new-first, legacy-fallback object key. + /// + /// Only an actual not-found result advances to the legacy candidate. Any + /// authorization, transport, throttling, or service error is returned so + /// the compatibility path cannot mask an unhealthy object store. + pub async fn resolve_read_key( + &self, + ctx: &TenantContext, + payload_name: &str, + ) -> Result { + let candidates = + crate::keys::read_candidates(ctx, payload_name).map_err(|_| MediaError::NotFound)?; + match self.head_with_metadata(&candidates.sharded).await? { + Some(_) => Ok(candidates.sharded), + None => match self.head_with_metadata(&candidates.legacy).await? { + Some(_) => Ok(candidates.legacy), + None => Err(MediaError::NotFound), + }, + } + } + /// Build the community-scoped sidecar key for a given sha256 (bare hash). /// /// Raw media bytes remain shared content-addressed CAS (`{sha}.{ext}`), but diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc2..12e32bcad 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -666,7 +666,13 @@ pub(crate) async fn serve_blob_for_tenant( "attachment" }; - let key = resolve_s3_key(&state.media_storage, tenant, sha256_ext).await?; + let key = state + .media_storage + .resolve_read_key( + tenant, + &resolve_payload_name(&state.media_storage, tenant, sha256_ext).await?, + ) + .await?; // Parse optional Range header. let range_header = req_headers @@ -835,7 +841,13 @@ pub async fn head_blob( sidecar_mime }; - let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?; + let key = state + .media_storage + .resolve_read_key( + &tenant, + &resolve_payload_name(&state.media_storage, &tenant, &sha256_ext).await?, + ) + .await?; match state.media_storage.head_with_metadata(&key).await? { Some(meta) => { let size_str = meta.size.to_string(); @@ -861,7 +873,7 @@ pub async fn head_blob( /// /// Sidecar-derived extensions are validated as safe tokens to prevent /// object-key confusion if sidecar data is ever tampered with. -async fn resolve_s3_key( +async fn resolve_payload_name( storage: &buzz_media::MediaStorage, tenant: &TenantContext, sha256_ext: &str, diff --git a/crates/buzz-relay/src/handlers/imeta.rs b/crates/buzz-relay/src/handlers/imeta.rs index b75060ce6..fcabb73b3 100644 --- a/crates/buzz-relay/src/handlers/imeta.rs +++ b/crates/buzz-relay/src/handlers/imeta.rs @@ -244,15 +244,12 @@ pub async fn verify_imeta_blobs( .await .map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?; - // 2. HEAD the actual blob object - let blob_key = format!("{x_value}.{}", sidecar.ext); - let blob_exists = storage - .head(&blob_key) + // 2. Resolve the actual blob object across sharded and legacy layouts. + let blob_name = format!("{x_value}.{}", sidecar.ext); + storage + .resolve_read_key(ctx, &blob_name) .await .map_err(|e| format!("storage error checking blob {x_value}: {e}"))?; - if !blob_exists { - return Err(format!("imeta blob object missing in storage: {x_value}")); - } // 3. Cross-check claimed metadata against sidecar. if !m_value.is_empty() && sidecar.mime_type != m_value { @@ -275,18 +272,12 @@ pub async fn verify_imeta_blobs( } } - // 4. If thumb is claimed, HEAD the thumbnail object too. + // 4. If thumb is claimed, resolve the thumbnail object too. if !thumb_value.is_empty() { - let thumb_key = format!("{x_value}.thumb.jpg"); - let thumb_exists = storage - .head(&thumb_key) + storage + .resolve_read_key(ctx, &format!("{x_value}.thumb.jpg")) .await .map_err(|e| format!("storage error checking thumbnail: {e}"))?; - if !thumb_exists { - return Err(format!( - "imeta thumb references missing thumbnail: {x_value}" - )); - } } // 5. If image (poster frame) is claimed, verify sidecar + blob. @@ -316,16 +307,11 @@ pub async fn verify_imeta_blobs( } } - let img_key = format!("{img_hash}.{}", img_sidecar.ext); - let img_exists = storage - .head(&img_key) + let img_name = format!("{img_hash}.{}", img_sidecar.ext); + storage + .resolve_read_key(ctx, &img_name) .await .map_err(|e| format!("storage error checking poster image: {e}"))?; - if !img_exists { - return Err(format!( - "imeta image references missing poster frame: {img_hash}" - )); - } } } Ok(())