//! Object keys, and the only code allowed to mint one. use super::client::S3Client; use super::file_type::FileType; use crate::constants; use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId}; use crate::error::{AppError, Result}; /// A storage object key. There are exactly two ways to obtain one, and an /// ad-hoc `format!("...")` is neither: /// /// 1. A `S3Client::generate_*` constructor, the single, reviewed home for key /// *layout*. Multi-instance kinds (versions, gallery, media) take their /// uniqueness segment (a table PK or a fresh uuid) as a required argument, so /// a collidable key cannot be built; singleton kinds (audio/cover/video, OTA /// artifacts) are one-per-parent and correctly overwrite-on-replace. /// 2. [`S3Key::from_stored`], the named trust boundary for a key that already /// exists in our storage (read back from a DB row). The caller asserts it was /// minted by a generator at write time; this is how delete/download/re-presign /// paths address objects without re-deriving their layout. /// /// Because every write/presign/delete on [`StorageBackend`] takes `&S3Key`, a /// hand-built string can never reach S3, the OTA-style inline `format!` key /// (which bypassed the generators) is now uncompilable. #[derive(Debug, Clone, PartialEq, Eq, Hash, sqlx::Type)] #[sqlx(transparent)] pub struct S3Key(String); impl S3Key { /// Wrap a key read back from durable storage (a DB row). Names the trust /// boundary: the caller asserts this key was minted by a `generate_*` /// constructor when the object was written, not freshly invented here. pub fn from_stored(key: impl AsRef) -> Self { S3Key(key.as_ref().to_string()) } pub fn as_str(&self) -> &str { &self.0 } pub fn into_string(self) -> String { self.0 } } impl std::ops::Deref for S3Key { type Target = str; fn deref(&self) -> &str { &self.0 } } impl AsRef for S3Key { fn as_ref(&self) -> &str { &self.0 } } impl std::fmt::Display for S3Key { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl PartialEq<&str> for S3Key { fn eq(&self, other: &&str) -> bool { self.0 == *other } } /// Extensions the artifact store serves. /// /// An RPM repository wants the package itself, the `createrepo_c` metadata under /// `repodata/` (XML, in whatever compression the generator chose, or the sqlite /// variants), and the detached signature and public key beside `repomd.xml`. The /// base image mirror wants `.tar`, because a mirrored image is an archive and /// not a registry. Anything else is a publish mistake: nothing in a client's /// fetch path asks for it, so serving it is pure surface. const ARTIFACT_EXTENSIONS: &[&str] = &[ "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml", "tar", ]; impl S3Client { /// Generate a consistent S3 key for an object /// Format: {user_id}/{item_id}/{file_type}/{filename} pub fn generate_key( user_id: UserId, item_id: ItemId, file_type: FileType, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "{}/{}/{}/{}", user_id, item_id, file_type.as_str(), safe_filename )) } /// Staging key for scan-then-promote: browser uploads presign to this key, /// which is NEVER served. After a Clean scan the worker copies the object to /// its content-addressed [`content_key`](Self::content_key) and deletes the /// staging object. The upload's extension is carried in the staging key so /// the worker can build the content key without re-reading the entity row. /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment /// means a replayed presigned PUT can only re-write the (unserved, /// post-scan-deleted) staging object, never the served content key, and two /// uploads of the same filename never collide. /// The original filename is preserved after the uuid so a confirm can recover /// it (e.g. a version download's suggested name), `sanitize_filename` strips /// any `/`, so the name can't add path segments or escape the `staging/` /// prefix. The extension still rides along for the content key. pub fn generate_staging_key(filename: &str) -> S3Key { S3Key(format!( "staging/{}/{}", uuid::Uuid::new_v4(), sanitize_filename(filename) )) } /// Key for an object in the artifact store, from the relative path the /// publisher names (e.g. `hotfix/f43/x86_64/repodata/repomd.xml`, or /// `base/fedora-bootc-43-amd64.tar`). /// /// The odd one out among the generators, and deliberately so: every other /// key layout here is derived from ids we hold, but a yum repository *is* a /// path layout that `createrepo_c` writes and `dnf` re-derives from /// `repomd.xml`. The server cannot invent it without reimplementing /// createrepo, so the caller supplies it. That makes this the one generator /// whose whole job is refusing bad input, and it returns `Result` for that /// reason. Which layout a prefix actually uses is its own business, not this /// function's, hence no structure is imposed beyond a segment count. /// /// Every object here is a file with an extension, because the store serves /// archives and repository files rather than a registry. That is what keeps /// this check as tight as it is: a registry would have forced extensionless /// digest names and a colon in the alphabet, and the mirror ships tarballs /// instead. See wiki `mnw-package-hosting`. /// /// Refused: absolute paths, empty segments (so `//` and a trailing `/`), /// `.` and `..` in any position, a segment starting `.` or `-`, anything /// outside `[A-Za-z0-9._+~-]`, and a final segment whose extension is not /// one the store serves. Together those make traversal /// unrepresentable rather than merely unlikely, and keep a presigned PUT /// from writing an object the Caddy block would then serve as something it /// is not. pub fn generate_artifact_key(path: &str) -> Result { let bad = |msg: &str| AppError::BadRequest(format!("invalid artifact path: {msg}")); if path.is_empty() { return Err(bad("empty")); } if path.len() > constants::ARTIFACT_MAX_KEY_BYTES { return Err(bad(&format!( "longer than {} bytes", constants::ARTIFACT_MAX_KEY_BYTES ))); } if path.starts_with('/') { return Err(bad("must be relative, not absolute")); } let segments: Vec<&str> = path.split('/').collect(); if segments.len() > constants::ARTIFACT_MAX_KEY_SEGMENTS { return Err(bad(&format!( "more than {} path segments", constants::ARTIFACT_MAX_KEY_SEGMENTS ))); } for segment in &segments { if segment.is_empty() { return Err(bad("empty path segment")); } if *segment == "." || *segment == ".." { return Err(bad("`.` and `..` are not path segments")); } if segment.starts_with('.') || segment.starts_with('-') { return Err(bad("a path segment may not start with `.` or `-`")); } if !segment .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-')) { return Err(bad( "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`", )); } } // Unwrap: `split` on a non-empty string always yields at least one // segment, and every segment was proven non-empty above. let filename = segments.last().copied().unwrap_or_default(); let ext = filename .rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) .ok_or_else(|| bad("the final path segment needs a file extension"))?; if !ARTIFACT_EXTENSIONS.contains(&ext.as_str()) { return Err(bad(&format!( "`.{ext}` is not served from the artifact store. Allowed: {}", ARTIFACT_EXTENSIONS.join(", ") ))); } Ok(S3Key(path.to_string())) } /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's /// name *is* its content hash, so the served bytes are provably the bytes /// that were scanned, a swapped object would hash to a different key. The /// key is per-owner (`user_id`) namespaced, so identical bytes uploaded by /// different creators do NOT collapse to one shared object (no cross-tenant /// existence oracle). The `c` marker segment cannot collide with the legacy /// `{user_id}/{item_id}/...` layout because `c` is not a UUID. pub fn content_key(user_id: UserId, sha256: &str, ext: &str) -> S3Key { S3Key(format!("{user_id}/c/{sha256}.{ext}")) } /// Generate an S3 key for a version download file. The version's own id is /// woven into the path so two versions of the same item that share a /// filename (e.g. a creator who ships every release as `plugin.zip`) never /// resolve to the same object, mirrors the per-entity-uuid segment the /// gallery keys use, except the version id is the table's primary key, so /// uniqueness is guaranteed by construction rather than by a fresh uuid. /// Format: {user_id}/{item_id}/download/{version_id}/{filename} pub fn generate_version_key( user_id: UserId, item_id: ItemId, version_id: VersionId, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "{}/{}/{}/{}/{}", user_id, item_id, FileType::Download.as_str(), version_id, safe_filename )) } /// Generate an S3 key for a reusable insertion clip (not tied to any item). /// Format: {user_id}/insertions/{filename} pub fn generate_insertion_key(user_id: UserId, filename: &str) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!("{user_id}/insertions/{safe_filename}")) } /// Generate an S3 key for a media library file. /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder). pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> S3Key { let safe_filename = sanitize_filename(filename); let safe_folder = sanitize_folder(folder); if safe_folder.is_empty() { S3Key(format!("{user_id}/media/{safe_filename}")) } else { S3Key(format!("{user_id}/media/{safe_folder}/{safe_filename}")) } } /// Generate an S3 key for a project image (logo/avatar). /// Format: projects/{project_id}/image/{sanitized_filename} pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!("projects/{project_id}/image/{safe_filename}")) } /// Generate an S3 key for an OTA release artifact. Singleton per /// (app, version, target, arch), the release row already enforces /// `UNIQUE(app_id, version)` and the artifact row `UNIQUE(release_id, target, /// arch)`, so re-uploading the same artifact correctly overwrites in place. /// Centralized here so OTA keys are no longer hand-built at the call site. /// Format: ota/{app_id}/{version}/{target}/{arch}/artifact pub fn generate_ota_artifact_key( app_id: SyncAppId, version: &str, target: &str, arch: &str, ) -> S3Key { S3Key(format!("ota/{app_id}/{version}/{target}/{arch}/artifact")) } /// Generate an S3 key for a SyncKit content-addressed blob. The hash is the /// uniqueness segment (and `UNIQUE(app_id, user_id, hash)` backs it), so two /// uploads of identical bytes resolve to one object by design. /// Format: {app_id}/{user_id}/{hash} pub fn generate_synckit_blob_key(app_id: SyncAppId, user_id: UserId, hash: &str) -> S3Key { S3Key(format!("{app_id}/{user_id}/{hash}")) } /// Generate an S3 key for a generated content-export archive. Ephemeral /// (presigned, then reaped); the timestamp keeps repeat exports distinct. /// Format: {user_id}/exports/content-{timestamp}.zip pub fn generate_content_export_key(user_id: UserId, timestamp: &str) -> S3Key { S3Key(format!("{user_id}/exports/content-{timestamp}.zip")) } /// Generate an S3 key for an item gallery image. A per-image uuid segment /// keeps multiple gallery uploads from colliding (unlike the single cover, /// which has a fixed `cover/` path). /// Format: {user_id}/{item_id}/gallery/{image_uuid}/{sanitized_filename} pub fn generate_item_gallery_key( user_id: UserId, item_id: ItemId, image_uuid: uuid::Uuid, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "{user_id}/{item_id}/gallery/{image_uuid}/{safe_filename}" )) } /// Generate an S3 key for a project gallery image. /// Format: projects/{project_id}/gallery/{image_uuid}/{sanitized_filename} pub fn generate_project_gallery_key( project_id: ProjectId, image_uuid: uuid::Uuid, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "projects/{project_id}/gallery/{image_uuid}/{safe_filename}" )) } } /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores. /// Prevents path traversal, shell injection, and S3 key encoding issues. /// Falls back to "file" if the sanitized result has no basename (only extension or empty). /// /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else, /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"`, preserved as a /// literal filename, not as a directory traversal. The unit test pins this /// behavior: we don't reject names containing `..`, we just guarantee the /// output has no path separators. S3 keys are namespaced by user/item ID /// upstream, so a flat literal here can't escape the user's prefix. /// /// `pub(crate)` so confirm handlers store a filename that matches the tail of /// the key `generate_media_key` produced, rather than re-deriving a weaker /// filter that drops the empty-basename fallback. /// The lowercased ASCII-alphanumeric file extension for a staging/content key, /// or `"bin"` when the filename has none. Bounded to 16 chars so a crafted /// filename can't bloat the key. Content keys carry an extension purely so /// CDN-served objects keep a sensible suffix (content-type sniffing, browser /// "save as"); the hash is the identity, the extension is cosmetic. // Retained as a tested key-extension utility; `generate_staging_key` now embeds // the full sanitized filename (which carries the extension) instead of calling // this, so it has no production caller today. #[allow(dead_code)] pub(crate) fn extension_for(filename: &str) -> String { let ext: String = std::path::Path::new(filename) .extension() .and_then(|s| s.to_str()) .unwrap_or("") .chars() .filter(char::is_ascii_alphanumeric) .map(|c| c.to_ascii_lowercase()) .take(16) .collect(); if ext.is_empty() { "bin".to_string() } else { ext } } /// The extension segment of a key's basename (the text after the last `.`), or /// `"bin"`. Lets the scan worker's promote step carry a staging object's /// extension onto its content key without re-reading the entity row. pub(crate) fn key_extension(key: &str) -> &str { key.rsplit('/') .next() .and_then(|base| base.rsplit_once('.').map(|(_, ext)| ext)) .filter(|ext| !ext.is_empty()) .unwrap_or("bin") } pub(crate) fn sanitize_filename(filename: &str) -> String { let sanitized: String = filename .chars() .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_') .collect(); // Ensure the result has a non-empty basename (not just ".ext" or empty) let stem = std::path::Path::new(&sanitized) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(""); if stem.is_empty() { let ext = std::path::Path::new(&sanitized) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); if ext.is_empty() { "file".to_string() } else { format!("file.{ext}") } } else { sanitized } } /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores. /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder. pub fn sanitize_folder(folder: &str) -> String { let trimmed = folder.trim(); if trimmed.is_empty() { return String::new(); } // Reject any path traversal if trimmed.contains("..") { return String::new(); } trimmed .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect() } #[cfg(test)] mod artifact_key_tests { use super::*; #[test] fn the_mirror_and_the_hotfix_repo_are_both_signable() { for path in [ "base/fedora-bootc-43-amd64.tar", "base/fedora-bootc-43-arm64.tar", "hotfix/f43/x86_64/alloy-1.0.0-1.fc43.x86_64.rpm", "hotfix/f43/x86_64/repodata/abc-primary.xml.zst", "hotfix/f43/x86_64/repodata/repomd.xml.asc", ] { assert!( S3Client::generate_artifact_key(path).is_ok(), "{path} is store content and was refused" ); } } /// The store holds archives and repository files, both of which carry an /// extension. Nothing here needs the registry alphabet: no colons, and no /// extensionless digest names. #[test] fn registry_shaped_keys_are_refused() { for path in [ "v2/alloy/base/manifests/43", "v2/alloy/base/blobs/sha256:3f786850e387550fdab836ed7e6dc881de23001b", "base/blobs/sha256/3f786850e387550fdab836ed7e6dc881de23001b", "base/oci-layout", ] { assert!( S3Client::generate_artifact_key(path).is_err(), "{path} is registry shape and the store does not serve one" ); } } #[test] fn an_extension_the_store_does_not_serve_is_refused() { assert!(S3Client::generate_artifact_key("base/payload.sh").is_err()); assert!(S3Client::generate_artifact_key("base/index.html").is_err()); } }