Skip to main content

max / makenotwork

Publish the registry tree, not the OCI layout Measured on podman 4.9.3 rather than reasoned about: a hand-built image served as plain files, then pulled. A pull asks for three shapes, all GET, no auth and no write verbs: GET /v2/ GET /v2/<name>/manifests/<tag or sha256:hex> GET /v2/<name>/blobs/sha256:<hex> Tag and digest pulls both succeed, which is the case that matters since the Containerfile pins by digest. So a bucket behind a GET/HEAD front is a sufficient mirror, and no registry daemon needs to run. Two things in the store would have prevented it, and both were mine: - The key validator refused the colon. Registry blobs are blobs/sha256:<hex> and the segment alphabet was letters, digits and . _ + ~ - so the mirror could not have published one object. The colon is admitted only in a segment shaped <algorithm>:<hex>, so the alphabet did not widen generally. - The uploader sent no content type. A manifest has no extension, would have defaulted to octet-stream, and podman then fails the pull with "unsupported schema version 2" — an error that reads as malformed JSON when the JSON is fine. Isolated: correct media type and no Docker-Content-Digest header pulls clean, so the type is required and the header is not. The publisher now reads mediaType out of each manifest's own bytes, which an index and an image manifest differ in. --kind oci becomes --kind registry, because an OCI layout on disk is index.json over blobs/sha256/<hex> and no transport pulls one over HTTP. Whatever produces the mirror converts a layout into this tree rather than uploading the layout. That trap is now named in the type's docs. Wiki mnw-package-hosting; alloy ebf30337 carries the shape answer.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01J1yAFMokhGYCs3UT8UaCZr
Author: Max Johnson <me@maxj.phd> · 2026-09-07 21:08 UTC
Signed with PGP, not checked
Commit: 3f708b5b3306b984200eb8678753b7b1475f7169
Parent: c548eb4
8 files changed, +319 insertions, -105 deletions
M mnw-cli/Cargo.lock +29 -29
@@ -1947,7 +1947,7 @@
1947 1947
1948 1948 [[package]]
1949 1949 name = "mnw-cli"
1950 - version = "0.1.4"
1950 + version = "0.1.5"
1951 1951 dependencies = [
1952 1952 "anyhow",
1953 1953 "bytes",
@@ -4777,10 +4777,38 @@
4777 4777 source = "registry+https://github.com/rust-lang/crates.io-index"
4778 4778 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4779 4779
4780 + [[patch.unused]]
4781 + name = "kberg"
4782 + version = "0.1.0"
4783 +
4784 + [[patch.unused]]
4785 + name = "ops-status"
4786 + version = "0.1.0"
4787 +
4788 + [[patch.unused]]
4789 + name = "painhours"
4790 + version = "0.1.0"
4791 +
4792 + [[patch.unused]]
4793 + name = "tagtree"
4794 + version = "0.4.1"
4795 +
4796 + [[patch.unused]]
4797 + name = "quasi-type"
4798 + version = "0.1.3"
4799 +
4780 4800 [[patch.unused]]
4781 4801 name = "docengine"
4782 4802 version = "0.7.0"
4783 4803
4804 + [[patch.unused]]
4805 + name = "makeover-build"
4806 + version = "0.67.0"
4807 +
4808 + [[patch.unused]]
4809 + name = "makeover-webview"
4810 + version = "0.74.0"
4811 +
4784 4812 [[patch.unused]]
4785 4813 name = "quasi-axum"
4786 4814 version = "0.103.0"
@@ -4821,34 +4849,6 @@
4821 4849 name = "quasi-webview"
4822 4850 version = "0.103.0"
4823 4851
4824 - [[patch.unused]]
4825 - name = "makeover-build"
4826 - version = "0.67.0"
4827 -
4828 - [[patch.unused]]
4829 - name = "makeover-webview"
4830 - version = "0.74.0"
4831 -
4832 - [[patch.unused]]
4833 - name = "quasi-type"
4834 - version = "0.1.3"
4835 -
4836 - [[patch.unused]]
4837 - name = "kberg"
4838 - version = "0.1.0"
4839 -
4840 - [[patch.unused]]
4841 - name = "ops-status"
4842 - version = "0.1.0"
4843 -
4844 - [[patch.unused]]
4845 - name = "painhours"
4846 - version = "0.1.0"
4847 -
4848 - [[patch.unused]]
4849 - name = "tagtree"
4850 - version = "0.4.1"
4851 -
4852 4852 [[patch.unused]]
4853 4853 name = "synckit-config"
4854 4854 version = "0.2.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "mnw-cli"
3 - version = "0.1.5"
3 + version = "0.1.6"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6
M server/Cargo.lock +17 -17
@@ -5270,7 +5270,7 @@
5270 5270
5271 5271 [[package]]
5272 5272 name = "makenotwork"
5273 - version = "0.16.5"
5273 + version = "0.16.6"
5274 5274 dependencies = [
5275 5275 "anyhow",
5276 5276 "apple-codesign",
@@ -10768,20 +10768,12 @@
10768 10768 ]
10769 10769
10770 10770 [[patch.unused]]
10771 - name = "quasi-immediate"
10772 - version = "0.103.0"
10771 + name = "synckit-client"
10772 + version = "0.10.0"
10773 10773
10774 10774 [[patch.unused]]
10775 - name = "quasi-notifs"
10776 - version = "0.103.0"
10777 -
10778 - [[patch.unused]]
10779 - name = "quasi-store"
10780 - version = "0.1.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "quasi-tauri"
10784 - version = "0.103.0"
10775 + name = "synckit-config"
10776 + version = "0.2.0"
10785 10777
10786 10778 [[patch.unused]]
10787 10779 name = "kberg"
@@ -10796,9 +10788,17 @@
10796 10788 version = "0.1.0"
10797 10789
10798 10790 [[patch.unused]]
10799 - name = "synckit-client"
10800 - version = "0.10.0"
10791 + name = "quasi-immediate"
10792 + version = "0.103.0"
10801 10793
10802 10794 [[patch.unused]]
10803 - name = "synckit-config"
10804 - version = "0.2.0"
10795 + name = "quasi-notifs"
10796 + version = "0.103.0"
10797 +
10798 + [[patch.unused]]
10799 + name = "quasi-store"
10800 + version = "0.1.0"
10801 +
10802 + [[patch.unused]]
10803 + name = "quasi-tauri"
10804 + version = "0.103.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.16.5"
3 + version = "0.16.6"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6 # Server binary: never published to a registry. Marks the crate private so
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.16.5"
9 + "version": "0.16.6"
10 10 },
11 11 "paths": {
12 12 "/api/git/{owner}/{repo}/notes": {
@@ -70,7 +70,7 @@
70 70 \x20 --dir Tree root (an RPM repository, or an OCI layout)\n\
71 71 \n\
72 72 Optional:\n\
73 - \x20 --kind rpm (default) or oci. Decides what the index is.\n\
73 + \x20 --kind rpm (default) or registry. Decides what the index is.\n\
74 74 \x20 --prefix Path prefix inside the bucket (e.g. alloy/f43/x86_64)\n\
75 75 \x20 --dry-run List what would be uploaded, in order, and stop\n\
76 76 \n\
@@ -167,6 +167,11 @@
167 167 path: PathBuf,
168 168 object_path: String,
169 169 size: i64,
170 + /// Set only where the server's extension-keyed derivation cannot reach the
171 + /// right answer: a manifest has no extension and must not be served as a
172 + /// byte stream. `None` everywhere else, so there is one derivation rather
173 + /// than two disagreeing ones.
174 + content_type: Option<String>,
170 175 }
171 176
172 177 /// Which stage of the publish an object belongs to. The derived `Ord` is the
@@ -184,22 +189,40 @@
184 189 Index,
185 190 }
186 191
187 - /// Which layout the tree under `--dir` is, which is only ever asked so that
188 - /// [`stage_of`] knows what the index is.
192 + /// Which layout the tree under `--dir` is. Asked so that [`stage_of`] knows
193 + /// what the index is, and so that a manifest is uploaded under the content type
194 + /// its own bytes declare.
189 195 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
190 196 enum Kind {
191 197 /// A `createrepo_c` repository: `repomd.xml` over `repodata/` over packages.
192 198 Rpm,
193 - /// An OCI image layout: `index.json` and `oci-layout` over `blobs/`.
194 - Oci,
199 + /// A registry tree: `v2/<name>/manifests/<ref>` over
200 + /// `v2/<name>/blobs/<algorithm>:<digest>`.
201 + ///
202 + /// **Not an OCI image layout**, and the difference is the whole reason this
203 + /// variant is named for the registry. A layout on disk is `index.json` and
204 + /// `oci-layout` over `blobs/sha256/<hex>`, and there is no transport that
205 + /// pulls one over HTTP: `oci:` wants a local path. Measured against podman
206 + /// 4.9.3, a pull asks for exactly three shapes, all GET, no auth and no
207 + /// write verbs, which is why a static store can answer them:
208 + ///
209 + /// ```text
210 + /// GET /v2/
211 + /// GET /v2/<name>/manifests/<tag or sha256:hex>
212 + /// GET /v2/<name>/blobs/sha256:<hex>
213 + /// ```
214 + ///
215 + /// So the mirror publishes the registry tree, not the layout it was built
216 + /// from. See wiki `mnw-package-hosting`.
217 + Registry,
195 218 }
196 219
197 220 impl Kind {
198 221 fn parse(value: &str) -> Result<Self> {
199 222 match value {
200 223 "rpm" => Ok(Self::Rpm),
201 - "oci" => Ok(Self::Oci),
202 - other => bail!("unknown --kind {other}; expected rpm or oci"),
224 + "registry" => Ok(Self::Registry),
225 + other => bail!("unknown --kind {other}; expected rpm or registry"),
203 226 }
204 227 }
205 228 }
@@ -216,11 +239,16 @@
216 239 Stage::Package
217 240 }
218 241 }
219 - // An OCI layout has no middle tier. Everything under `blobs/` is named
220 - // by its own digest, manifests included, so there is nothing that is
221 - // referenced by the index and also references something else by name.
222 - Kind::Oci => {
223 - if filename == "index.json" || filename == "oci-layout" {
242 + // A registry tree has no middle tier. Blobs are named by their own
243 + // digest, config and layers alike, so nothing under `blobs/` refers to
244 + // anything else by a name that could go stale.
245 + //
246 + // Manifests are the index, including the ones addressed by digest: a
247 + // multi-arch pull reads the index manifest and then fetches a per-arch
248 + // manifest from the same directory, so publishing any of them before
249 + // their blobs is what leaves a client chasing a blob that is not there.
250 + Kind::Registry => {
251 + if object_path.contains("/manifests/") {
224 252 Stage::Index
225 253 } else {
226 254 Stage::Package
@@ -298,9 +326,16 @@
298 326 );
299 327 }
300 328
329 + let content_type = if kind == Kind::Registry && object_path.contains("/manifests/") {
330 + manifest_content_type(&path)
331 + } else {
332 + None
333 + };
334 +
301 335 found.insert(
302 336 (stage_of(kind, &object_path), object_path.clone()),
303 337 Upload {
338 + content_type,
304 339 path,
305 340 object_path,
306 341 size,
@@ -314,6 +349,32 @@
314 349 struct PresignRequest<'a> {
315 350 path: &'a str,
316 351 size: i64,
352 + #[serde(skip_serializing_if = "Option::is_none")]
353 + content_type: Option<String>,
354 + }
355 +
356 + /// The content type a manifest must be served under, read out of its own bytes.
357 + ///
358 + /// MEASURED, podman 4.9.3, and it is not a nicety. A manifest served as
359 + /// `application/octet-stream` fails the pull with `unsupported schema version
360 + /// 2`, which reads as a malformed manifest and sends you to inspect the JSON.
361 + /// The JSON is fine; the header is what was wrong. The same pull succeeds with
362 + /// the media type set and no `Docker-Content-Digest` header at all, so this is
363 + /// the one transport detail that matters.
364 + ///
365 + /// The value comes from the manifest's own `mediaType` rather than from a guess
366 + /// keyed on the path, because that field is what the registry spec says a
367 + /// client will be handed and an index and an image manifest differ in it.
368 + /// Unreadable or absent, this returns `None` and the server derives a type as
369 + /// usual, which fails loudly at pull time rather than silently here.
370 + fn manifest_content_type(path: &Path) -> Option<String> {
371 + let bytes = std::fs::read(path).ok()?;
372 + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
373 + value
374 + .get("mediaType")?
375 + .as_str()
376 + .filter(|media| media.starts_with("application/"))
377 + .map(str::to_string)
317 378 }
318 379
319 380 #[derive(serde::Deserialize)]
@@ -341,10 +402,10 @@
341 402 args.dir.display()
342 403 );
343 404 }
344 - Kind::Oci if !args.dir.join("index.json").is_file() => {
405 + Kind::Registry if !args.dir.join("v2").is_dir() => {
345 406 eprintln!(
346 - "warning: {} has no index.json. An OCI layout without one names nothing, \
347 - so a client will find no images here.",
407 + "warning: {} has no v2/ directory. A registry pull begins at /v2/, so a \
408 + client will find nothing here.",
348 409 args.dir.display()
349 410 );
350 411 }
@@ -417,6 +478,7 @@
417 478 .json(&PresignRequest {
418 479 path: &upload.object_path,
419 480 size: upload.size,
481 + content_type: upload.content_type.clone(),
420 482 })
421 483 .send()
422 484 .await
@@ -543,16 +605,29 @@
543 605 );
544 606 }
545 607
546 - #[test]
547 - fn an_oci_layout_puts_blobs_before_the_index() {
548 - let dir = tempfile::tempdir().unwrap();
549 - let root = dir.path();
550 - std::fs::create_dir_all(root.join("blobs/sha256")).unwrap();
551 - std::fs::write(root.join("blobs/sha256/beef"), b"blob").unwrap();
552 - std::fs::write(root.join("index.json"), b"index").unwrap();
553 - std::fs::write(root.join("oci-layout"), b"layout").unwrap();
608 + /// Writes the tree podman actually requests, measured against 4.9.3:
609 + /// `/v2/<name>/manifests/<ref>` over `/v2/<name>/blobs/sha256:<hex>`.
610 + fn registry_tree(root: &Path) {
611 + std::fs::create_dir_all(root.join("v2/fedora-bootc/manifests")).unwrap();
612 + std::fs::create_dir_all(root.join("v2/fedora-bootc/blobs")).unwrap();
613 + std::fs::write(
614 + root.join("v2/fedora-bootc/blobs/sha256:beef"),
615 + b"a layer's bytes",
616 + )
617 + .unwrap();
618 + std::fs::write(
619 + root.join("v2/fedora-bootc/manifests/43"),
620 + br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}"#,
621 + )
622 + .unwrap();
623 + }
554 624
555 - let got: Vec<String> = collect(Kind::Oci, root, "alloy/base")
625 + #[test]
626 + fn a_registry_tree_puts_blobs_before_manifests() {
627 + let dir = tempfile::tempdir().unwrap();
628 + registry_tree(dir.path());
629 +
630 + let got: Vec<String> = collect(Kind::Registry, dir.path(), "alloy/base")
556 631 .unwrap()
557 632 .into_iter()
558 633 .map(|u| u.object_path)
@@ -561,21 +636,53 @@
561 636 assert_eq!(
562 637 got,
563 638 vec![
564 - "alloy/base/blobs/sha256/beef",
565 - "alloy/base/index.json",
566 - "alloy/base/oci-layout",
639 + "alloy/base/v2/fedora-bootc/blobs/sha256:beef",
640 + "alloy/base/v2/fedora-bootc/manifests/43",
567 641 ]
568 642 );
569 643 }
570 644
645 + #[test]
646 + fn a_manifest_is_published_under_the_media_type_it_declares() {
647 + // The measured failure this prevents: served as octet-stream, podman
648 + // 4.9.3 rejects the pull with "unsupported schema version 2".
649 + let dir = tempfile::tempdir().unwrap();
650 + registry_tree(dir.path());
651 +
652 + let uploads = collect(Kind::Registry, dir.path(), "").unwrap();
653 + let manifest = uploads
654 + .iter()
655 + .find(|u| u.object_path.contains("/manifests/"))
656 + .expect("the tree has a manifest");
657 + assert_eq!(
658 + manifest.content_type.as_deref(),
659 + Some("application/vnd.oci.image.index.v1+json")
660 + );
661 +
662 + let blob = uploads
663 + .iter()
664 + .find(|u| u.object_path.contains("/blobs/"))
665 + .expect("the tree has a blob");
666 + assert_eq!(
667 + blob.content_type, None,
668 + "a blob has no extension either, and octet-stream is the right answer for it"
669 + );
670 + }
671 +
571 672 #[test]
572 673 fn kind_decides_what_the_index_is() {
573 674 // The same path is content under one kind and the index under the
574 675 // other, which is the whole reason --kind is not inferred.
575 - assert_eq!(stage_of(Kind::Oci, "a/index.json"), Stage::Index);
576 - assert_eq!(stage_of(Kind::Rpm, "a/index.json"), Stage::Package);
676 + assert_eq!(
677 + stage_of(Kind::Registry, "a/v2/x/manifests/43"),
678 + Stage::Index
679 + );
680 + assert_eq!(stage_of(Kind::Rpm, "a/v2/x/manifests/43"), Stage::Package);
577 681 assert_eq!(stage_of(Kind::Rpm, "a/repodata/repomd.xml"), Stage::Index);
578 - assert_eq!(stage_of(Kind::Oci, "a/repodata/repomd.xml"), Stage::Package);
682 + assert_eq!(
683 + stage_of(Kind::Registry, "a/repodata/repomd.xml"),
684 + Stage::Package
685 + );
579 686 }
580 687
581 688 #[test]
@@ -84,23 +84,55 @@
84 84 /// The extensionless names an OCI image layout requires at its root.
85 85 const OCI_BARE_NAMES: &[&str] = &["oci-layout"];
86 86
87 - /// Whether a path is an OCI blob: `.../blobs/<algorithm>/<digest>`.
87 + /// Whether a segment is a digest reference: `<algorithm>:<hex>`.
88 88 ///
89 - /// Blobs are the one thing in this store with no extension to check, because
90 - /// their name is the digest of their own contents. That is a stronger guarantee
91 - /// than an extension allowlist gives, not a weaker one, so the shape of the
92 - /// path is what is required instead: the two segments before it must be
93 - /// `blobs/<algorithm>`, and the digest must be hex. A stray extensionless file
94 - /// somewhere else in the tree still fails.
95 - fn is_oci_blob(segments: &[&str]) -> bool {
96 - let [.., blobs, algorithm, digest] = segments else {
89 + /// The only reason `:` is allowed in a key at all. A registry pull addresses
90 + /// blobs and pinned manifests this way — `blobs/sha256:<hex>` — so refusing the
91 + /// colon would refuse the base-image mirror, and allowing it everywhere would
92 + /// widen the key alphabet for one shape's benefit. Measured against podman
93 + /// 4.9.3: those are the literal paths it requests.
94 + fn is_digest_reference(segment: &str) -> bool {
95 + let Some((algorithm, hex)) = segment.split_once(':') else {
97 96 return false;
98 97 };
99 - *blobs == "blobs"
100 - && !algorithm.is_empty()
101 - && algorithm.chars().all(|c| c.is_ascii_alphanumeric())
102 - && digest.len() >= 32
103 - && digest.chars().all(|c| c.is_ascii_hexdigit())
98 + !algorithm.is_empty()
99 + && algorithm
100 + .chars()
101 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
102 + && hex.len() >= 32
103 + && hex.chars().all(|c| c.is_ascii_hexdigit())
104 + }
105 +
106 + /// Whether a path names an object that carries no extension and is still
107 + /// legitimate.
108 + ///
109 + /// Three shapes, and every one of them is content a container client fetches by
110 + /// a name it was given rather than by a name a person typed:
111 + ///
112 + /// - `.../blobs/<algorithm>/<digest>` — an OCI image layout's blob.
113 + /// - `.../blobs/<algorithm>:<digest>` — the same blob as a registry serves it.
114 + /// - `.../manifests/<reference>` — a manifest, by tag or by digest. A tag is an
115 + /// arbitrary string, so this one is admitted by its parent segment rather
116 + /// than by its own shape.
117 + ///
118 + /// A blob's name is the digest of its own contents, which is a stronger
119 + /// guarantee than an extension allowlist gives rather than a weaker one. A
120 + /// stray extensionless file anywhere else in the tree still fails.
121 + fn is_registry_content(segments: &[&str]) -> bool {
122 + match segments {
123 + [.., blobs, algorithm, digest]
124 + if *blobs == "blobs"
125 + && !algorithm.is_empty()
126 + && algorithm.chars().all(|c| c.is_ascii_alphanumeric())
127 + && digest.len() >= 32
128 + && digest.chars().all(|c| c.is_ascii_hexdigit()) =>
129 + {
130 + true
131 + }
132 + [.., blobs, reference] if *blobs == "blobs" => is_digest_reference(reference),
133 + [.., manifests, _] if *manifests == "manifests" => true,
134 + _ => false,
135 + }
104 136 }
105 137
106 138 impl S3Client {
@@ -200,9 +232,11 @@
200 232 if !segment
201 233 .chars()
202 234 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-'))
235 + && !is_digest_reference(segment)
203 236 {
204 237 return Err(bad(
205 - "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`",
238 + "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`, \
239 + or be a digest reference like `sha256:<hex>`",
206 240 ));
207 241 }
208 242 }
@@ -213,14 +247,14 @@
213 247
214 248 // Two shapes carry no extension and are still legitimate, so they are
215 249 // checked before the allowlist rather than exempted from it.
216 - if !is_oci_blob(&segments) && !OCI_BARE_NAMES.contains(&filename) {
250 + if !is_registry_content(&segments) && !OCI_BARE_NAMES.contains(&filename) {
217 251 let ext = filename
218 252 .rsplit_once('.')
219 253 .map(|(_, ext)| ext.to_ascii_lowercase())
220 254 .ok_or_else(|| {
221 255 bad(
222 - "the final path segment needs a file extension, unless it is an OCI \
223 - blob under blobs/<algorithm>/ or the layout's own oci-layout file",
256 + "the final path segment needs a file extension, unless it is a blob, \
257 + a manifest, or the layout's own oci-layout file",
224 258 )
225 259 })?;
226 260 if !ARTIFACT_EXTENSIONS.contains(&ext.as_str()) {
@@ -447,3 +481,42 @@
447 481 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
448 482 .collect()
449 483 }
484 +
485 + #[cfg(test)]
486 + mod artifact_key_tests {
487 + use super::*;
488 +
489 + /// The literal paths podman 4.9.3 requests during a pull, taken from a
490 + /// static tree it pulled from successfully. Every one has to be a key the
491 + /// store will sign, or the mirror cannot hold what a client asks for.
492 + #[test]
493 + fn the_paths_a_pull_requests_are_signable() {
494 + let hex = "2fae60d22615a2a211b043a79296367cd8dd4e085f7e03e225083145013ba652";
495 + for path in [
496 + format!("alloy/base/v2/fedora-bootc/manifests/sha256:{hex}"),
497 + format!("alloy/base/v2/fedora-bootc/blobs/sha256:{hex}"),
498 + "alloy/base/v2/fedora-bootc/manifests/43".to_string(),
499 + // The layout form, for a tree that is copied rather than served.
500 + format!("alloy/base/blobs/sha256/{hex}"),
501 + "alloy/base/oci-layout".to_string(),
502 + ] {
503 + assert!(
504 + S3Client::generate_artifact_key(&path).is_ok(),
505 + "a pull asks for {path} and the store refused to sign it"
506 + );
507 + }
508 + }
509 +
510 + #[test]
511 + fn the_colon_is_admitted_for_digests_and_nothing_else() {
512 + assert!(S3Client::generate_artifact_key("a/blobs/sha256:beef").is_err());
513 + assert!(S3Client::generate_artifact_key("a/v2/we:ird/x.rpm").is_err());
514 + assert!(S3Client::generate_artifact_key("a/v2/n/manifests/tag:with:colons").is_err());
515 + }
516 +
517 + #[test]
518 + fn an_extensionless_file_outside_the_registry_shapes_is_still_refused() {
519 + assert!(S3Client::generate_artifact_key("alloy/hotfix/payload").is_err());
520 + assert!(S3Client::generate_artifact_key("alloy/hotfix/blobs/sha256/nothex").is_err());
521 + }
522 + }
@@ -107,11 +107,23 @@
107 107 path: &str,
108 108 size: i64,
109 109 ) -> crate::harness::client::TestResponse {
110 + presign_typed(h, path, size, None).await
111 + }
112 +
113 + /// The same mint with an explicit content type, which is how a manifest is
114 + /// published: its path carries no extension the server could key on.
115 + async fn presign_typed(
116 + h: &mut TestHarness,
117 + path: &str,
118 + size: i64,
119 + content_type: Option<&str>,
120 + ) -> crate::harness::client::TestResponse {
121 + let mut body = json!({ "path": path, "size": size });
122 + if let Some(content_type) = content_type {
123 + body["content_type"] = json!(content_type);
124 + }
110 125 h.client
111 - .post_json(
112 - "/api/v1/admin/artifacts/uploads",
113 - &json!({ "path": path, "size": size }).to_string(),
114 - )
126 + .post_json("/api/v1/admin/artifacts/uploads", &body.to_string())
115 127 .await
116 128 }
117 129
@@ -251,11 +263,14 @@
251 263 "alloy/hotfix/f43/x86_64/alloy-1.0.0-1.fc43.x86_64.rpm",
252 264 "alloy/hotfix/f43/x86_64/repodata/abc123-primary.xml.zst",
253 265 "alloy/hotfix/f43/x86_64/repodata/repomd.xml.asc",
254 - // The mirror's half of the store. A blob has no extension, so it is
255 - // admitted by the shape of its path rather than by the allowlist.
256 - "alloy/base/index.json",
266 + // The mirror's half of the store, in the shape podman actually
267 + // requests. A blob and a manifest both lack an extension, so they are
268 + // admitted by the shape of their path rather than by the allowlist,
269 + // and a blob's segment carries the colon nothing else may.
270 + "alloy/base/v2/fedora-bootc/manifests/43",
271 + "alloy/base/v2/fedora-bootc/manifests/sha256:3f786850e387550fdab836ed7e6dc881de23001b",
272 + "alloy/base/v2/fedora-bootc/blobs/sha256:3f786850e387550fdab836ed7e6dc881de23001b",
257 273 "alloy/base/oci-layout",
258 - "alloy/base/blobs/sha256/3f786850e387550fdab836ed7e6dc881de23001b",
259 274 ] {
260 275 let resp = presign(&mut h, path, 512).await;
261 276 assert_eq!(
@@ -273,6 +288,9 @@
273 288 "alloy/f43/noextension",
274 289 // Under blobs/, and still not a digest.
275 290 "alloy/base/blobs/sha256/not-hex-at-all",
291 + // A colon outside a digest reference stays refused, so admitting the
292 + // mirror did not widen the key alphabet generally.
293 + "alloy/base/v2/weird:name/thing.rpm",
276 294 ] {
277 295 let resp = presign(&mut h, path, 512).await;
278 296 assert_eq!(
@@ -298,6 +316,22 @@
298 316 // The mirror's entry point, which has JSON in a name with no extension.
299 317 let layout: PresignResponse = presign(&mut h, "alloy/base/oci-layout", 512).await.json();
300 318 assert_eq!(layout.content_type, "application/json");
319 +
320 + // A manifest cannot be derived from its path, so the publisher sends the
321 + // media type its bytes declare and the server signs it verbatim. Serving a
322 + // manifest as a byte stream fails the pull; measured on podman 4.9.3.
323 + let manifest: PresignResponse = presign_typed(
324 + &mut h,
325 + "alloy/base/v2/fedora-bootc/manifests/43",
326 + 512,
327 + Some("application/vnd.oci.image.index.v1+json"),
328 + )
329 + .await
330 + .json();
331 + assert_eq!(
332 + manifest.content_type,
333 + "application/vnd.oci.image.index.v1+json"
334 + );
301 335 }
302 336
303 337 #[tokio::test]