Skip to main content

max / makenotwork

18.8 KB · 469 lines History Blame Raw
1 //! Object keys, and the only code allowed to mint one.
2
3 use super::client::S3Client;
4 use super::file_type::FileType;
5 use crate::constants;
6
7 use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId};
8 use crate::error::{AppError, Result};
9
10 /// A storage object key. There are exactly two ways to obtain one, and an
11 /// ad-hoc `format!("...")` is neither:
12 ///
13 /// 1. A `S3Client::generate_*` constructor, the single, reviewed home for key
14 /// *layout*. Multi-instance kinds (versions, gallery, media) take their
15 /// uniqueness segment (a table PK or a fresh uuid) as a required argument, so
16 /// a collidable key cannot be built; singleton kinds (audio/cover/video, OTA
17 /// artifacts) are one-per-parent and correctly overwrite-on-replace.
18 /// 2. [`S3Key::from_stored`], the named trust boundary for a key that already
19 /// exists in our storage (read back from a DB row). The caller asserts it was
20 /// minted by a generator at write time; this is how delete/download/re-presign
21 /// paths address objects without re-deriving their layout.
22 ///
23 /// Because every write/presign/delete on [`StorageBackend`] takes `&S3Key`, a
24 /// hand-built string can never reach S3, the OTA-style inline `format!` key
25 /// (which bypassed the generators) is now uncompilable.
26 #[derive(Debug, Clone, PartialEq, Eq, Hash, sqlx::Type)]
27 #[sqlx(transparent)]
28 pub struct S3Key(String);
29
30 impl S3Key {
31 /// Wrap a key read back from durable storage (a DB row). Names the trust
32 /// boundary: the caller asserts this key was minted by a `generate_*`
33 /// constructor when the object was written, not freshly invented here.
34 pub fn from_stored(key: impl AsRef<str>) -> Self {
35 S3Key(key.as_ref().to_string())
36 }
37
38 pub fn as_str(&self) -> &str {
39 &self.0
40 }
41
42 pub fn into_string(self) -> String {
43 self.0
44 }
45 }
46
47 impl std::ops::Deref for S3Key {
48 type Target = str;
49 fn deref(&self) -> &str {
50 &self.0
51 }
52 }
53
54 impl AsRef<str> for S3Key {
55 fn as_ref(&self) -> &str {
56 &self.0
57 }
58 }
59
60 impl std::fmt::Display for S3Key {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.write_str(&self.0)
63 }
64 }
65
66 impl PartialEq<&str> for S3Key {
67 fn eq(&self, other: &&str) -> bool {
68 self.0 == *other
69 }
70 }
71
72 /// Extensions the artifact store serves.
73 ///
74 /// An RPM repository wants the package itself, the `createrepo_c` metadata under
75 /// `repodata/` (XML, in whatever compression the generator chose, or the sqlite
76 /// variants), and the detached signature and public key beside `repomd.xml`. The
77 /// base image mirror wants `.tar`, because a mirrored image is an archive and
78 /// not a registry. Anything else is a publish mistake: nothing in a client's
79 /// fetch path asks for it, so serving it is pure surface.
80 const ARTIFACT_EXTENSIONS: &[&str] = &[
81 "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml", "tar",
82 ];
83
84 impl S3Client {
85 /// Generate a consistent S3 key for an object
86 /// Format: {user_id}/{item_id}/{file_type}/{filename}
87 pub fn generate_key(
88 user_id: UserId,
89 item_id: ItemId,
90 file_type: FileType,
91 filename: &str,
92 ) -> S3Key {
93 let safe_filename = sanitize_filename(filename);
94 S3Key(format!(
95 "{}/{}/{}/{}",
96 user_id,
97 item_id,
98 file_type.as_str(),
99 safe_filename
100 ))
101 }
102
103 /// Staging key for scan-then-promote: browser uploads presign to this key,
104 /// which is NEVER served. After a Clean scan the worker copies the object to
105 /// its content-addressed [`content_key`](Self::content_key) and deletes the
106 /// staging object. The upload's extension is carried in the staging key so
107 /// the worker can build the content key without re-reading the entity row.
108 /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment
109 /// means a replayed presigned PUT can only re-write the (unserved,
110 /// post-scan-deleted) staging object, never the served content key, and two
111 /// uploads of the same filename never collide.
112 /// The original filename is preserved after the uuid so a confirm can recover
113 /// it (e.g. a version download's suggested name), `sanitize_filename` strips
114 /// any `/`, so the name can't add path segments or escape the `staging/`
115 /// prefix. The extension still rides along for the content key.
116 pub fn generate_staging_key(filename: &str) -> S3Key {
117 S3Key(format!(
118 "staging/{}/{}",
119 uuid::Uuid::new_v4(),
120 sanitize_filename(filename)
121 ))
122 }
123
124 /// Key for an object in the artifact store, from the relative path the
125 /// publisher names (e.g. `hotfix/f43/x86_64/repodata/repomd.xml`, or
126 /// `base/fedora-bootc-43-amd64.tar`).
127 ///
128 /// The odd one out among the generators, and deliberately so: every other
129 /// key layout here is derived from ids we hold, but a yum repository *is* a
130 /// path layout that `createrepo_c` writes and `dnf` re-derives from
131 /// `repomd.xml`. The server cannot invent it without reimplementing
132 /// createrepo, so the caller supplies it. That makes this the one generator
133 /// whose whole job is refusing bad input, and it returns `Result` for that
134 /// reason. Which layout a prefix actually uses is its own business, not this
135 /// function's, hence no structure is imposed beyond a segment count.
136 ///
137 /// Every object here is a file with an extension, because the store serves
138 /// archives and repository files rather than a registry. That is what keeps
139 /// this check as tight as it is: a registry would have forced extensionless
140 /// digest names and a colon in the alphabet, and the mirror ships tarballs
141 /// instead. See wiki `mnw-package-hosting`.
142 ///
143 /// Refused: absolute paths, empty segments (so `//` and a trailing `/`),
144 /// `.` and `..` in any position, a segment starting `.` or `-`, anything
145 /// outside `[A-Za-z0-9._+~-]`, and a final segment whose extension is not
146 /// one the store serves. Together those make traversal
147 /// unrepresentable rather than merely unlikely, and keep a presigned PUT
148 /// from writing an object the Caddy block would then serve as something it
149 /// is not.
150 pub fn generate_artifact_key(path: &str) -> Result<S3Key> {
151 let bad = |msg: &str| AppError::BadRequest(format!("invalid artifact path: {msg}"));
152
153 if path.is_empty() {
154 return Err(bad("empty"));
155 }
156 if path.len() > constants::ARTIFACT_MAX_KEY_BYTES {
157 return Err(bad(&format!(
158 "longer than {} bytes",
159 constants::ARTIFACT_MAX_KEY_BYTES
160 )));
161 }
162 if path.starts_with('/') {
163 return Err(bad("must be relative, not absolute"));
164 }
165
166 let segments: Vec<&str> = path.split('/').collect();
167 if segments.len() > constants::ARTIFACT_MAX_KEY_SEGMENTS {
168 return Err(bad(&format!(
169 "more than {} path segments",
170 constants::ARTIFACT_MAX_KEY_SEGMENTS
171 )));
172 }
173
174 for segment in &segments {
175 if segment.is_empty() {
176 return Err(bad("empty path segment"));
177 }
178 if *segment == "." || *segment == ".." {
179 return Err(bad("`.` and `..` are not path segments"));
180 }
181 if segment.starts_with('.') || segment.starts_with('-') {
182 return Err(bad("a path segment may not start with `.` or `-`"));
183 }
184 if !segment
185 .chars()
186 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-'))
187 {
188 return Err(bad(
189 "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`",
190 ));
191 }
192 }
193
194 // Unwrap: `split` on a non-empty string always yields at least one
195 // segment, and every segment was proven non-empty above.
196 let filename = segments.last().copied().unwrap_or_default();
197 let ext = filename
198 .rsplit_once('.')
199 .map(|(_, ext)| ext.to_ascii_lowercase())
200 .ok_or_else(|| bad("the final path segment needs a file extension"))?;
201 if !ARTIFACT_EXTENSIONS.contains(&ext.as_str()) {
202 return Err(bad(&format!(
203 "`.{ext}` is not served from the artifact store. Allowed: {}",
204 ARTIFACT_EXTENSIONS.join(", ")
205 )));
206 }
207
208 Ok(S3Key(path.to_string()))
209 }
210
211 /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's
212 /// name *is* its content hash, so the served bytes are provably the bytes
213 /// that were scanned, a swapped object would hash to a different key. The
214 /// key is per-owner (`user_id`) namespaced, so identical bytes uploaded by
215 /// different creators do NOT collapse to one shared object (no cross-tenant
216 /// existence oracle). The `c` marker segment cannot collide with the legacy
217 /// `{user_id}/{item_id}/...` layout because `c` is not a UUID.
218 pub fn content_key(user_id: UserId, sha256: &str, ext: &str) -> S3Key {
219 S3Key(format!("{user_id}/c/{sha256}.{ext}"))
220 }
221
222 /// Generate an S3 key for a version download file. The version's own id is
223 /// woven into the path so two versions of the same item that share a
224 /// filename (e.g. a creator who ships every release as `plugin.zip`) never
225 /// resolve to the same object, mirrors the per-entity-uuid segment the
226 /// gallery keys use, except the version id is the table's primary key, so
227 /// uniqueness is guaranteed by construction rather than by a fresh uuid.
228 /// Format: {user_id}/{item_id}/download/{version_id}/{filename}
229 pub fn generate_version_key(
230 user_id: UserId,
231 item_id: ItemId,
232 version_id: VersionId,
233 filename: &str,
234 ) -> S3Key {
235 let safe_filename = sanitize_filename(filename);
236 S3Key(format!(
237 "{}/{}/{}/{}/{}",
238 user_id,
239 item_id,
240 FileType::Download.as_str(),
241 version_id,
242 safe_filename
243 ))
244 }
245
246 /// Generate an S3 key for a reusable insertion clip (not tied to any item).
247 /// Format: {user_id}/insertions/{filename}
248 pub fn generate_insertion_key(user_id: UserId, filename: &str) -> S3Key {
249 let safe_filename = sanitize_filename(filename);
250 S3Key(format!("{user_id}/insertions/{safe_filename}"))
251 }
252
253 /// Generate an S3 key for a media library file.
254 /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder).
255 pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> S3Key {
256 let safe_filename = sanitize_filename(filename);
257 let safe_folder = sanitize_folder(folder);
258 if safe_folder.is_empty() {
259 S3Key(format!("{user_id}/media/{safe_filename}"))
260 } else {
261 S3Key(format!("{user_id}/media/{safe_folder}/{safe_filename}"))
262 }
263 }
264
265 /// Generate an S3 key for a project image (logo/avatar).
266 /// Format: projects/{project_id}/image/{sanitized_filename}
267 pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> S3Key {
268 let safe_filename = sanitize_filename(filename);
269 S3Key(format!("projects/{project_id}/image/{safe_filename}"))
270 }
271
272 /// Generate an S3 key for an OTA release artifact. Singleton per
273 /// (app, version, target, arch), the release row already enforces
274 /// `UNIQUE(app_id, version)` and the artifact row `UNIQUE(release_id, target,
275 /// arch)`, so re-uploading the same artifact correctly overwrites in place.
276 /// Centralized here so OTA keys are no longer hand-built at the call site.
277 /// Format: ota/{app_id}/{version}/{target}/{arch}/artifact
278 pub fn generate_ota_artifact_key(
279 app_id: SyncAppId,
280 version: &str,
281 target: &str,
282 arch: &str,
283 ) -> S3Key {
284 S3Key(format!("ota/{app_id}/{version}/{target}/{arch}/artifact"))
285 }
286
287 /// Generate an S3 key for a SyncKit content-addressed blob. The hash is the
288 /// uniqueness segment (and `UNIQUE(app_id, user_id, hash)` backs it), so two
289 /// uploads of identical bytes resolve to one object by design.
290 /// Format: {app_id}/{user_id}/{hash}
291 pub fn generate_synckit_blob_key(app_id: SyncAppId, user_id: UserId, hash: &str) -> S3Key {
292 S3Key(format!("{app_id}/{user_id}/{hash}"))
293 }
294
295 /// Generate an S3 key for a generated content-export archive. Ephemeral
296 /// (presigned, then reaped); the timestamp keeps repeat exports distinct.
297 /// Format: {user_id}/exports/content-{timestamp}.zip
298 pub fn generate_content_export_key(user_id: UserId, timestamp: &str) -> S3Key {
299 S3Key(format!("{user_id}/exports/content-{timestamp}.zip"))
300 }
301
302 /// Generate an S3 key for an item gallery image. A per-image uuid segment
303 /// keeps multiple gallery uploads from colliding (unlike the single cover,
304 /// which has a fixed `cover/` path).
305 /// Format: {user_id}/{item_id}/gallery/{image_uuid}/{sanitized_filename}
306 pub fn generate_item_gallery_key(
307 user_id: UserId,
308 item_id: ItemId,
309 image_uuid: uuid::Uuid,
310 filename: &str,
311 ) -> S3Key {
312 let safe_filename = sanitize_filename(filename);
313 S3Key(format!(
314 "{user_id}/{item_id}/gallery/{image_uuid}/{safe_filename}"
315 ))
316 }
317
318 /// Generate an S3 key for a project gallery image.
319 /// Format: projects/{project_id}/gallery/{image_uuid}/{sanitized_filename}
320 pub fn generate_project_gallery_key(
321 project_id: ProjectId,
322 image_uuid: uuid::Uuid,
323 filename: &str,
324 ) -> S3Key {
325 let safe_filename = sanitize_filename(filename);
326 S3Key(format!(
327 "projects/{project_id}/gallery/{image_uuid}/{safe_filename}"
328 ))
329 }
330 }
331
332 /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores.
333 /// Prevents path traversal, shell injection, and S3 key encoding issues.
334 /// Falls back to "file" if the sanitized result has no basename (only extension or empty).
335 ///
336 /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else,
337 /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"`, preserved as a
338 /// literal filename, not as a directory traversal. The unit test pins this
339 /// behavior: we don't reject names containing `..`, we just guarantee the
340 /// output has no path separators. S3 keys are namespaced by user/item ID
341 /// upstream, so a flat literal here can't escape the user's prefix.
342 ///
343 /// `pub(crate)` so confirm handlers store a filename that matches the tail of
344 /// the key `generate_media_key` produced, rather than re-deriving a weaker
345 /// filter that drops the empty-basename fallback.
346 /// The lowercased ASCII-alphanumeric file extension for a staging/content key,
347 /// or `"bin"` when the filename has none. Bounded to 16 chars so a crafted
348 /// filename can't bloat the key. Content keys carry an extension purely so
349 /// CDN-served objects keep a sensible suffix (content-type sniffing, browser
350 /// "save as"); the hash is the identity, the extension is cosmetic.
351 // Retained as a tested key-extension utility; `generate_staging_key` now embeds
352 // the full sanitized filename (which carries the extension) instead of calling
353 // this, so it has no production caller today.
354 #[allow(dead_code)]
355 pub(crate) fn extension_for(filename: &str) -> String {
356 let ext: String = std::path::Path::new(filename)
357 .extension()
358 .and_then(|s| s.to_str())
359 .unwrap_or("")
360 .chars()
361 .filter(char::is_ascii_alphanumeric)
362 .map(|c| c.to_ascii_lowercase())
363 .take(16)
364 .collect();
365 if ext.is_empty() {
366 "bin".to_string()
367 } else {
368 ext
369 }
370 }
371
372 /// The extension segment of a key's basename (the text after the last `.`), or
373 /// `"bin"`. Lets the scan worker's promote step carry a staging object's
374 /// extension onto its content key without re-reading the entity row.
375 pub(crate) fn key_extension(key: &str) -> &str {
376 key.rsplit('/')
377 .next()
378 .and_then(|base| base.rsplit_once('.').map(|(_, ext)| ext))
379 .filter(|ext| !ext.is_empty())
380 .unwrap_or("bin")
381 }
382
383 pub(crate) fn sanitize_filename(filename: &str) -> String {
384 let sanitized: String = filename
385 .chars()
386 .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
387 .collect();
388 // Ensure the result has a non-empty basename (not just ".ext" or empty)
389 let stem = std::path::Path::new(&sanitized)
390 .file_stem()
391 .and_then(|s| s.to_str())
392 .unwrap_or("");
393 if stem.is_empty() {
394 let ext = std::path::Path::new(&sanitized)
395 .extension()
396 .and_then(|s| s.to_str())
397 .unwrap_or("");
398 if ext.is_empty() {
399 "file".to_string()
400 } else {
401 format!("file.{ext}")
402 }
403 } else {
404 sanitized
405 }
406 }
407
408 /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores.
409 /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder.
410 pub fn sanitize_folder(folder: &str) -> String {
411 let trimmed = folder.trim();
412 if trimmed.is_empty() {
413 return String::new();
414 }
415 // Reject any path traversal
416 if trimmed.contains("..") {
417 return String::new();
418 }
419 trimmed
420 .chars()
421 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
422 .collect()
423 }
424
425 #[cfg(test)]
426 mod artifact_key_tests {
427 use super::*;
428
429 #[test]
430 fn the_mirror_and_the_hotfix_repo_are_both_signable() {
431 for path in [
432 "base/fedora-bootc-43-amd64.tar",
433 "base/fedora-bootc-43-arm64.tar",
434 "hotfix/f43/x86_64/alloy-1.0.0-1.fc43.x86_64.rpm",
435 "hotfix/f43/x86_64/repodata/abc-primary.xml.zst",
436 "hotfix/f43/x86_64/repodata/repomd.xml.asc",
437 ] {
438 assert!(
439 S3Client::generate_artifact_key(path).is_ok(),
440 "{path} is store content and was refused"
441 );
442 }
443 }
444
445 /// The store holds archives and repository files, both of which carry an
446 /// extension. Nothing here needs the registry alphabet: no colons, and no
447 /// extensionless digest names.
448 #[test]
449 fn registry_shaped_keys_are_refused() {
450 for path in [
451 "v2/alloy/base/manifests/43",
452 "v2/alloy/base/blobs/sha256:3f786850e387550fdab836ed7e6dc881de23001b",
453 "base/blobs/sha256/3f786850e387550fdab836ed7e6dc881de23001b",
454 "base/oci-layout",
455 ] {
456 assert!(
457 S3Client::generate_artifact_key(path).is_err(),
458 "{path} is registry shape and the store does not serve one"
459 );
460 }
461 }
462
463 #[test]
464 fn an_extension_the_store_does_not_serve_is_refused() {
465 assert!(S3Client::generate_artifact_key("base/payload.sh").is_err());
466 assert!(S3Client::generate_artifact_key("base/index.html").is_err());
467 }
468 }
469