max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01J1yAFMokhGYCs3UT8UaCZr
8 files changed,
+319 insertions,
-105 deletions
| @@ -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 |
| @@ -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] |