From c3634c4521a02683fe4953f24f03f642ec7e92cb Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 8 Mar 2026 23:42:02 +0000 Subject: [PATCH 01/15] Initialize dockdash as standalone OSS crate Rust library for building and pushing OCI container images without Docker. Includes layer builder, image builder, blob caching, registry push with auth support, CI/CD workflows, and crates.io release pipeline. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 62 + .github/workflows/release.yml | 35 + Cargo.lock | 3856 +++++++++++++++++++++++++++++++++ Cargo.toml | 46 + README.md | 182 ++ examples/basic.rs | 47 + src/blobcache.rs | 222 ++ src/error.rs | 108 + src/image.rs | 1554 +++++++++++++ src/layer.rs | 1514 +++++++++++++ src/lib.rs | 52 + src/test_utils.rs | 29 + tests/build_push_tests.rs | 1296 +++++++++++ 13 files changed, 9003 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 examples/basic.rs create mode 100644 src/blobcache.rs create mode 100644 src/error.rs create mode 100644 src/image.rs create mode 100644 src/layer.rs create mode 100644 src/lib.rs create mode 100644 src/test_utils.rs create mode 100644 tests/build_push_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..93d5e7d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-features + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-features -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: "-Dwarnings" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fd683cd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + publish: + name: Publish to crates.io + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + github-release: + name: GitHub Release + runs-on: ubuntu-latest + needs: publish + steps: + - uses: actions/checkout@v4 + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ca23767 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3856 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cacache" +version = "13.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b" +dependencies = [ + "digest", + "either", + "futures", + "hex", + "libc", + "memmap2", + "miette", + "reflink-copy", + "serde", + "serde_derive", + "serde_json", + "sha1", + "sha2", + "ssri", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "walkdir", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "container-registry" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ecf5aef7328c936edc4dcd1dfd2d22c170a13b09bb3a0bfe71ce04f92c724bb" +dependencies = [ + "axum", + "base64 0.21.7", + "constant_time_eq", + "futures", + "hex", + "nom", + "rm", + "sec", + "serde", + "serde_json", + "sha2", + "tempdir", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dockdash" +version = "0.1.0" +dependencies = [ + "async-trait", + "bollard", + "cacache", + "container-registry", + "dirs", + "dotenvy", + "futures-util", + "oci-client", + "oci-spec 0.6.8", + "oci-tar-builder", + "ocipkg", + "rand 0.9.2", + "rand_distr", + "sec", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "tracing-test", + "zstd", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jwt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f" +dependencies = [ + "base64 0.13.1", + "crypto-common", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "miette-derive", + "once_cell", + "thiserror 1.0.69", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oci-client" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b74df13319e08bc386d333d3dc289c774c88cc543cae31f5347db07b5ec2172" +dependencies = [ + "bytes", + "chrono", + "futures-util", + "http", + "http-auth", + "jwt", + "lazy_static", + "oci-spec 0.8.4", + "olpc-cjson", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "unicase", +] + +[[package]] +name = "oci-spec" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f5a3fe998d50101ae009351fec56d88a69f4ed182e11000e711068c2f5abf72" +dependencies = [ + "derive_builder", + "getset", + "once_cell", + "regex", + "serde", + "serde_json", + "strum 0.26.3", + "strum_macros 0.26.4", + "thiserror 1.0.69", +] + +[[package]] +name = "oci-spec" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3da52b83ce3258fbf29f66ac784b279453c2ac3c22c5805371b921ede0d308" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.18", +] + +[[package]] +name = "oci-tar-builder" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77e98f27d003551add924dfb4baccce142023ef4270abfd8da574ad8a65e8e" +dependencies = [ + "anyhow", + "clap", + "indexmap 2.13.0", + "log", + "oci-spec 0.6.8", + "serde", + "serde_json", + "sha256", + "tar", +] + +[[package]] +name = "ocipkg" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2489f5079bd71743b0bfbb4a36d08665996be1205f0d9ecc083b6e4f6dc48e7e" +dependencies = [ + "anyhow", + "base16ct", + "base64 0.22.1", + "chrono", + "directories", + "flate2", + "lazy_static", + "log", + "maplit", + "oci-spec 0.6.8", + "regex", + "serde", + "serde_json", + "sha2", + "tar", + "toml", + "ureq", + "url", + "urlencoding", + "uuid", + "walkdir", +] + +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.2", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "reflink-copy" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rm" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175b1c55c4bee048ce3f9fb578d7094db3bd7d7cae52c775ab4013a0874fdefd" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb15f07e997a0ee2e8919e8a1956416522eaf211b5ef6664354ccf813e3ef1a" +dependencies = [ + "serde", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "time", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha256" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ssri" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" +dependencies = [ + "base64 0.21.7", + "digest", + "hex", + "miette", + "serde", + "sha-1", + "sha2", + "thiserror 1.0.69", + "xxhash-rust", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand 0.4.6", + "remove_dir_all", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0651085 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "dockdash" +version = "0.1.0" +edition = "2021" +authors = ["Alien "] +description = "Build and push OCI container images without Docker" +license = "Apache-2.0" +repository = "https://github.com/alienplatform/dockdash" +homepage = "https://github.com/alienplatform/dockdash" +keywords = ["docker", "oci", "container", "image", "buildkit"] +categories = ["development-tools", "command-line-utilities"] +readme = "README.md" + +[dependencies] +oci-spec = "0.6.7" +tar = "0.4" +sha2 = "0.10" +serde_json = "1.0" +ocipkg = "0.3.9" +tempfile = "3.10" +oci-tar-builder = "0.4" +oci-client = { version = "0.15", default-features = false, features = ["rustls-tls-native-roots"] } +tokio = { version = "1.48", features = ["full"] } +tracing = "0.1" +dirs = "6" +thiserror = "2.0" +cacache = { version = "13.1", default-features = false, features = ["tokio-runtime", "mmap"] } +container-registry = { version = "0.3", features = ["test-support"], optional = true } +async-trait = "0.1" +zstd = "0.13" + +[dev-dependencies] +bollard = "0.18" +futures-util = "0.3" +rand = "0.9" +rand_distr = "0.5" +sec = "1" +container-registry = { version = "0.3", features = ["test-support"] } +dotenvy = "0.15" +tracing-test = "0.2" +tracing-subscriber = "0.3" +tokio = { version = "1.48", features = ["full", "test-util"] } + +[features] +default = [] +test-utils = ["dep:container-registry"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7fe259 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +

+

dockdash

+

+ Build and push OCI container images from Rust — no Docker daemon required. +

+

+ +

+ crates.io + docs.rs + CI + License +

+ +--- + +**Dockdash** is a Rust library for building and pushing OCI-compliant container images — without needing Docker installed. It's fast, lightweight, and works anywhere Rust runs: CI pipelines, serverless functions, CLIs, or embedded tooling. + +## Why Dockdash? + +- **No Docker daemon** — build images in environments where Docker isn't available (serverless, sandboxed CI, etc.) +- **Fast** — native Rust performance with zstd layer compression and content-addressable blob caching +- **Simple API** — intuitive builder pattern to create layers, assemble images, and push to any OCI registry +- **Multi-arch support** — build images for `amd64`, `arm64`, and other architectures +- **Layer caching** — content-addressable local blob cache for fast incremental builds +- **Registry push** — push directly to Docker Hub, ECR, GCR, ACR, GitHub Container Registry, or any OCI-compliant registry +- **Authentication** — supports anonymous, basic auth, and token-based registry authentication + +## Quick Start + +Add to your `Cargo.toml`: + +```toml +[dependencies] +dockdash = "0.1" +tokio = { version = "1", features = ["full"] } +``` + +Build and push an image: + +```rust +use dockdash::{Arch, Image, Layer, PushOptions, Result}; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> Result<()> { + // Create a layer from a local binary + let layer = Layer::builder()? + .file("./target/release/my-app", "./my-app", Some(0o755))? + .build() + .await?; + + // Build the OCI image + let (image, _) = Image::builder() + .from("ubuntu:latest") + .platform("linux", &Arch::ARM64) + .layer(layer) + .entrypoint(vec!["/my-app".to_string()]) + .output_to(PathBuf::from("image.oci.tar")) + .build() + .await?; + + // Push to a registry + image.push("my-registry.com/my-app:latest", &PushOptions::default()).await?; + + Ok(()) +} +``` + +## Features + +### Layer Builder + +Create layers from files or raw data: + +```rust +// From a file on disk +let layer = Layer::builder()? + .file("./my-binary", "./app/my-binary", Some(0o755))? + .build() + .await?; + +// From raw bytes +let layer = Layer::builder()? + .data("./app/config.toml", config_bytes, None)? + .build() + .await?; + +// Multiple files in one layer +let layer = Layer::builder()? + .file("./binary", "./app/binary", Some(0o755))? + .file("./config.toml", "./app/config.toml", None)? + .data("./app/version.txt", b"1.0.0", None)? + .build() + .await?; +``` + +### Image Builder + +Compose images from a base and custom layers: + +```rust +let (image, diagnostics) = Image::builder() + .from("alpine:3.19") + .platform("linux", &Arch::Amd64) + .layer(app_layer) + .layer(config_layer) + .entrypoint(vec!["/app/server".to_string()]) + .working_dir("/app") + .build() + .await?; +``` + +### Blob Caching + +Speed up repeated builds with content-addressable caching: + +```rust +use dockdash::BlobCache; + +// Default location: ~/.dockdash/cache/blobs +let cache = BlobCache::new()?; + +// Or use a custom cache directory (path is used as-is) +let cache = BlobCache::with_path("/my/custom/cache".into())?; + +let layer = Layer::builder()? + .blob_cache(cache.clone()) + .file("./my-binary", "./app/my-binary", Some(0o755))? + .build() + .await?; + +let (image, _) = Image::builder() + .from("alpine:latest") + .blob_cache(cache) + .layer(layer) + .build() + .await?; +``` + +### Registry Authentication + +```rust +use dockdash::{RegistryAuth, PushOptions, ClientProtocol}; + +// Anonymous (e.g., ttl.sh) +let opts = PushOptions::default(); + +// Basic auth +let opts = PushOptions { + auth: RegistryAuth::Basic("user".into(), "pass".into()), + ..Default::default() +}; + +// HTTP (for local registries) +let opts = PushOptions { + protocol: ClientProtocol::Http, + ..Default::default() +}; +``` + +## Use Cases + +- **CI/CD pipelines** — build container images without Docker-in-Docker or privileged containers +- **Serverless functions** — dynamically build and push images from Lambda, Cloud Functions, etc. +- **CLI tools** — embed container image building into your Rust CLI +- **Platform tooling** — build images as part of a deployment platform without requiring Docker on the host +- **Edge computing** — build images on resource-constrained devices + +## Testing + +```bash +# Unit tests +cargo test + +# Integration tests (requires Docker for Bollard-based tests) +cargo test --features test-utils +``` + +## License + +Licensed under the [Apache License, Version 2.0](LICENSE). diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..2df7e61 --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,47 @@ +use dockdash::{Arch, Image, Layer, PushOptions, Result}; +use rand::{distr::Alphanumeric, rng, Rng}; +use std::path::PathBuf; +use tracing::info; + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + // 1. Create a layer with your application binary + let layer = Layer::builder()? + .file("./target/release/my-app", "./my-app", Some(0o755))? + .build() + .await?; + info!(diff_id = %layer.diff_id(), blob_digest = %layer.blob_digest(), "Layer created."); + + // 2. Build the OCI image + info!("Building image..."); + + let (image, _) = Image::builder() + .from("ubuntu:latest") + .platform("linux", &Arch::ARM64) + .layer(layer) + .entrypoint(vec!["/my-app".to_string()]) + .output_to(PathBuf::from("image.oci.tar")) + .build() + .await?; + info!(path = %image.path().display(), config_digest = %image.config_digest(), "Image built."); + + // 3. Push to ttl.sh (anonymous, ephemeral registry — great for testing) + info!("Pushing image to ttl.sh..."); + + let tag: String = rng() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect::() + .to_lowercase(); + let image_name = format!("ttl.sh/dockdash-example-{}:1h", tag); + + let pushed = image.push(&image_name, &PushOptions::default()).await?; + + info!("Image pushed to {} (expires in ~1 hour)", pushed); + Ok(()) +} diff --git a/src/blobcache.rs b/src/blobcache.rs new file mode 100644 index 0000000..f9e9e3e --- /dev/null +++ b/src/blobcache.rs @@ -0,0 +1,222 @@ +use crate::error::{Error, Result}; +use std::path::PathBuf; +use tracing::{debug, info, warn}; + +const DEFAULT_CACHE_SUBDIR: &str = ".dockdash/cache/blobs"; + +/// Manages access to a content-addressable blob cache on disk. +/// +/// The cache is used to avoid re-uploading or re-compressing layers that +/// have already been built. By default, the cache lives at +/// `~/.dockdash/cache/blobs`, but you can point it at any directory with +/// [`BlobCache::with_path`]. +/// +/// # Examples +/// +/// ```no_run +/// use dockdash::BlobCache; +/// +/// // Use the default location (~/.dockdash/cache/blobs) +/// let cache = BlobCache::new().unwrap(); +/// +/// // Use a custom directory +/// let cache = BlobCache::with_path("/tmp/my-cache".into()).unwrap(); +/// ``` +#[derive(Debug, Clone)] +pub struct BlobCache { + cache_path: PathBuf, +} + +impl BlobCache { + /// Creates a new `BlobCache` using the default cache location + /// (`~/.dockdash/cache/blobs`, or `/tmp/.dockdash/cache/blobs` in + /// constrained environments). + pub fn new() -> Result { + let path = if let Some(home) = dirs::home_dir() { + let home_cache_path = home.join(DEFAULT_CACHE_SUBDIR); + if let Some(parent) = home_cache_path.parent() { + match std::fs::create_dir_all(parent) { + Ok(_) => home_cache_path, + Err(e) => { + warn!( + "Home directory is not writable ({}), falling back to /tmp", + e + ); + PathBuf::from("/tmp").join(DEFAULT_CACHE_SUBDIR) + } + } + } else { + PathBuf::from("/tmp").join(DEFAULT_CACHE_SUBDIR) + } + } else { + warn!("Could not determine home directory, using /tmp"); + PathBuf::from("/tmp").join(DEFAULT_CACHE_SUBDIR) + }; + + Self::init(path) + } + + /// Creates a new `BlobCache` at the given directory path. + /// + /// The directory will be created if it does not exist. The path is used + /// as-is — no subdirectory is appended. + /// + /// This is useful when you want full control over the cache location, for + /// example to share a cache across tools or to place it alongside project + /// artifacts. + /// + /// ```no_run + /// # use dockdash::BlobCache; + /// let cache = BlobCache::with_path("/var/cache/my-tool/blobs".into()).unwrap(); + /// ``` + pub fn with_path(path: PathBuf) -> Result { + info!(cache_path = %path.display(), "Initializing BlobCache with custom path."); + Self::init(path) + } + + fn init(path: PathBuf) -> Result { + debug!("Cache directory: {}", path.display()); + if !path.exists() { + info!("Creating cache directory at: {}", path.display()); + std::fs::create_dir_all(&path).map_err(|e| Error::Io { + message: format!("Failed to create cache directory: {}", path.display()), + source: e, + })?; + } + Ok(Self { cache_path: path }) + } + + /// Retrieves a blob from the cache by its digest. + /// + /// Returns `Ok(Some(data))` if the blob is found, `Ok(None)` if not. + pub async fn get_blob(&self, digest: &str) -> Result>> { + debug!(blob_digest = %digest, cache_path = %self.cache_path.display(), "Looking up blob in cache."); + match cacache::read(&self.cache_path, digest).await { + Ok(data) => { + info!(blob_digest = %digest, "Blob found in cache."); + Ok(Some(data)) + } + Err(cacache::Error::EntryNotFound(_, _)) => Ok(None), + Err(e) => { + warn!(blob_digest = %digest, error = %e, "Failed to read blob from cache."); + Err(Error::Cache { + message: format!( + "Failed to read blob '{}' from cache at {}", + digest, + self.cache_path.display() + ), + source: Some(Box::new(e)), + }) + } + } + } + + /// Stores a blob in the cache. + pub async fn put_blob(&self, digest: &str, data: &[u8]) -> Result<()> { + debug!(blob_digest = %digest, size = data.len(), "Storing blob in cache."); + cacache::write(&self.cache_path, digest, data) + .await + .map_err(|e| { + warn!(blob_digest = %digest, error = %e, "Failed to write blob to cache."); + Error::Cache { + message: format!( + "Failed to write blob '{}' to cache at {}", + digest, + self.cache_path.display() + ), + source: Some(Box::new(e)), + } + })?; + Ok(()) + } + + /// Removes a blob from the cache. + /// + /// Returns `Ok(())` even if the blob was not found. + pub async fn remove_blob(&self, digest: &str) -> Result<()> { + debug!(blob_digest = %digest, "Removing blob from cache."); + match cacache::remove(&self.cache_path, digest).await { + Ok(_) => Ok(()), + Err(cacache::Error::EntryNotFound(_, _)) => Ok(()), + Err(e) => { + warn!(blob_digest = %digest, error = %e, "Failed to remove blob from cache."); + Err(Error::Cache { + message: format!( + "Failed to remove blob '{}' from cache at {}", + digest, + self.cache_path.display() + ), + source: Some(Box::new(e)), + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_put_get_blob() -> Result<()> { + let temp_dir = tempdir().map_err(|e| Error::Io { + message: "Failed to create temp dir".to_string(), + source: e, + })?; + let cache = BlobCache::with_path(temp_dir.path().to_path_buf())?; + + let digest = "sha256:testdigest123"; + let data = b"hello cache data"; + + cache.put_blob(digest, data).await?; + + let retrieved_data = cache.get_blob(digest).await?.expect("Blob should be found"); + assert_eq!(retrieved_data, data); + + Ok(()) + } + + #[tokio::test] + async fn test_get_non_existent_blob() -> Result<()> { + let temp_dir = tempdir().map_err(|e| Error::Io { + message: "Failed to create temp dir".to_string(), + source: e, + })?; + let cache = BlobCache::with_path(temp_dir.path().to_path_buf())?; + + let digest = "sha256:nonexistent123"; + let retrieved_data = cache.get_blob(digest).await?; + assert!(retrieved_data.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn test_put_remove_get_blob() -> Result<()> { + let temp_dir = tempdir().map_err(|e| Error::Io { + message: "Failed to create temp dir".to_string(), + source: e, + })?; + let cache = BlobCache::with_path(temp_dir.path().to_path_buf())?; + + let digest = "sha256:toberemoved456"; + let data = b"this will be removed"; + + cache.put_blob(digest, data).await?; + let retrieved_data_before_remove = cache + .get_blob(digest) + .await? + .expect("Blob should be found before remove"); + assert_eq!(retrieved_data_before_remove, data); + + cache.remove_blob(digest).await?; + let retrieved_data_after_remove = cache.get_blob(digest).await?; + assert!(retrieved_data_after_remove.is_none()); + + // Removing again should be fine + cache.remove_blob(digest).await?; + + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..fd0b458 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,108 @@ +use thiserror::Error; + +/// Represents application-specific errors. +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum Error { + /// A fallback error type when no variant matches + #[error("Generic error: {message}{}", source.as_ref().map(|e| format!(": {e}")).unwrap_or_default())] + Generic { + /// The error message + message: String, + /// The wrapped error if available. + source: Option>, + }, + + #[error("I/O error: {message}: {source}")] + Io { + message: String, + #[source] + source: std::io::Error, + }, + + #[error("Task join error: {message}: {source}")] + Join { + message: String, + #[source] + source: tokio::task::JoinError, + }, + + #[error("Image pull error for '{image_ref}': {message}{}", source.as_ref().map(|e| format!(": {}", e)).unwrap_or_default())] + ImagePull { + image_ref: String, + message: String, + #[source] + source: Option>, + }, + + #[error("Image configuration error: {message}{}", source.as_ref().map(|e| format!(": {}", e)).unwrap_or_default())] + ImageConfig { + message: String, + #[source] + source: Option>, + }, + + #[error("OCI archive build error: {message}{}", source.as_ref().map(|e| format!(": {}", e)).unwrap_or_default())] + OciArchive { + message: String, + #[source] + source: Option>, + }, + + #[error("Cache error: {message}{}", source.as_ref().map(|e| format!(": {}", e)).unwrap_or_default())] + Cache { + message: String, + #[source] + source: Option>, + }, + + #[error("Invalid path specified: {message}")] + InvalidPath { message: String }, +} + +pub type Result = std::result::Result; + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Io { + message: err.to_string(), + source: err, + } + } +} + +impl Error { + /// Checks if this error is due to a manifest not being found in the registry. + /// This is used to determine if we should retry with a fallback base image. + pub fn is_manifest_not_found(&self) -> bool { + match self { + Error::ImagePull { source, .. } => { + if let Some(source) = source { + // Try to downcast to OciDistributionError + if let Some(oci_err) = + source.downcast_ref::() + { + return is_oci_manifest_not_found(oci_err); + } + } + false + } + _ => false, + } + } +} + +/// Helper function to check if an OciDistributionError indicates a manifest not found +fn is_oci_manifest_not_found(error: &oci_client::errors::OciDistributionError) -> bool { + match error { + oci_client::errors::OciDistributionError::RegistryError { envelope, .. } => { + // Check if any of the errors in the envelope is ManifestUnknown + envelope + .errors + .iter() + .any(|e| matches!(e.code, oci_client::errors::OciErrorCode::ManifestUnknown)) + } + oci_client::errors::OciDistributionError::ImageManifestNotFoundError(_) => true, + _ => false, + } +} diff --git a/src/image.rs b/src/image.rs new file mode 100644 index 0000000..3a01d92 --- /dev/null +++ b/src/image.rs @@ -0,0 +1,1554 @@ +use crate::blobcache; +use crate::error::{Error, Result}; +use crate::layer::Layer; +use async_trait::async_trait; + +use oci_client::{ + client::{ + Client, ClientConfig, ClientProtocol, Config as OciClientConfig, ImageData as OciImageData, + ImageLayer as OciImageLayer, + }, + errors::OciDistributionError, + manifest::{ImageIndexEntry, OciImageManifest}, + secrets::RegistryAuth, + Reference, RegistryOperation, +}; + +/// OCI media type for zstd-compressed tar layers +const IMAGE_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; +use oci_spec::image::Arch; +use oci_spec::image::{ImageConfiguration, ImageManifest as SpecImageManifest}; +use ocipkg::image::Image as _; +use ocipkg::image::OciArtifact; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::env; +use std::fs as std_fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; +use tracing::{debug, info, instrument, warn}; + +/// Runtime metadata extracted from an OCI image configuration. +#[derive(Debug, Clone)] +pub struct ImageMetadata { + /// Entrypoint from the image config + pub entrypoint: Option>, + /// Cmd from the image config + pub cmd: Option>, + /// Working directory from the image config + pub working_dir: Option, +} + +impl ImageMetadata { + /// Gets the full runtime command (entrypoint + cmd concatenated). + /// This is what should actually be executed. + pub fn runtime_command(&self) -> Vec { + let mut command = Vec::new(); + if let Some(ref entrypoint) = self.entrypoint { + command.extend(entrypoint.iter().cloned()); + } + if let Some(ref cmd) = self.cmd { + command.extend(cmd.iter().cloned()); + } + command + } +} + +/// Progress information for image push operations +#[derive(Debug, Clone)] +pub struct PushProgressInfo { + /// Current operation being performed + pub operation: String, + /// Number of layers uploaded so far + pub layers_uploaded: usize, + /// Total number of layers to upload + pub total_layers: usize, + /// Bytes uploaded so far + pub bytes_uploaded: u64, + /// Total bytes to upload + pub total_bytes: u64, +} + +/// Trait for receiving progress updates during image push operations +#[async_trait] +pub trait PushProgressCallback: Send + Sync { + /// Called when progress is updated + async fn on_progress(&self, progress: PushProgressInfo); +} + +/// Policy for determining whether to use monolithic push. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum MonolithicPushPolicy { + /// Automatically determine based on the registry hostname. + /// Uses monolithic push for registries known to require it (e.g., Google Artifact Registry). + #[default] + Auto, + /// Always use monolithic push. + Always, + /// Never use monolithic push (use chunked upload). + Never, +} + +/// Options for pushing an image. +pub struct PushOptions { + /// The authentication details for the registry. + pub auth: RegistryAuth, + /// The protocol to use for communicating with the registry. + pub protocol: ClientProtocol, + /// The policy for determining whether to use monolithic push. + pub monolithic_push: MonolithicPushPolicy, + /// Optional progress callback + pub progress_callback: Option>, +} + +impl std::fmt::Debug for PushOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PushOptions") + .field("auth", &self.auth) + .field("protocol", &self.protocol) + .field("monolithic_push", &self.monolithic_push) + .field("progress_callback", &self.progress_callback.is_some()) + .finish() + } +} + +impl Clone for PushOptions { + fn clone(&self) -> Self { + Self { + auth: self.auth.clone(), + protocol: self.protocol.clone(), + monolithic_push: self.monolithic_push.clone(), + progress_callback: None, // Can't clone trait objects, so we set to None + } + } +} + +impl Default for PushOptions { + fn default() -> Self { + Self { + auth: RegistryAuth::Anonymous, + protocol: ClientProtocol::Https, + monolithic_push: MonolithicPushPolicy::Auto, + progress_callback: None, + } + } +} + +impl PushOptions { + /// Sets the monolithic push policy to always use monolithic push. + pub fn with_monolithic_push(mut self) -> Self { + self.monolithic_push = MonolithicPushPolicy::Always; + self + } + + /// Sets the monolithic push policy to never use monolithic push (use chunked upload). + pub fn with_chunked_upload(mut self) -> Self { + self.monolithic_push = MonolithicPushPolicy::Never; + self + } + + /// Sets the monolithic push policy to automatically determine based on the registry. + pub fn with_auto_push_mode(mut self) -> Self { + self.monolithic_push = MonolithicPushPolicy::Auto; + self + } + + /// Sets a progress callback to receive push progress updates. + pub fn with_progress_callback(mut self, callback: Box) -> Self { + self.progress_callback = Some(callback); + self + } +} + +/// Defines the policy for pulling an image manifest. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum PullPolicy { + /// Always attempt to pull the image manifest from the registry. + Always, + /// Pull the image manifest only if it's not available in the local cache. + #[default] + Missing, +} + +/// Indicates the source from which an image manifest was obtained during a build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestSource { + /// The manifest was successfully loaded from the local cache. + FromCache, + /// The manifest was pulled from the remote registry. + FromRegistry, + /// Not applicable - building from scratch without a base image. + NotApplicable, +} + +/// Contains diagnostic information about the image build process. +#[derive(Debug, Clone)] +pub struct BuildDiagnostics { + /// How the base image manifest was obtained. + pub manifest_source: ManifestSource, + /// The digest of the resolved manifest that was used for the build. + pub resolved_manifest_digest: String, +} + +/// Options for pulling and extracting an image. +#[derive(Debug, Clone, Default)] +pub struct PullAndExtractOptions { + /// The platform OS to pull (e.g., "linux") + pub platform_os: Option, + /// The platform architecture to pull (e.g., Arch::Amd64) + pub platform_arch: Option, + /// Pull policy for the image manifest + pub pull_policy: PullPolicy, + /// Optional blob cache to use + pub blob_cache: Option, + /// Optional authentication for the registry + pub auth: Option, +} + +/// Represents a built OCI image stored as an OCI layout tarball. +/// The temporary directory holding the tarball is cleaned up when this struct is dropped. +#[derive(Debug)] +pub struct Image { + oci_archive_path: PathBuf, + config_digest: String, + // Holds the temporary directory to ensure it's cleaned up on drop, + // if the image was built into a temporary location. + // If None, the oci_archive_path points to a user-specified persistent location. + _temp_dir_manager: Option, +} + +impl Image { + /// Returns a builder to construct an `Image`. + pub fn builder() -> ImageBuilder { + ImageBuilder::default() + } + + /// Loads an existing OCI tarball from disk. + /// + /// This is useful for pushing pre-built images without rebuilding them. + /// + /// # Arguments + /// * `tarball_path` - Path to the OCI tarball file + /// + /// # Returns + /// A loaded Image instance on success + #[instrument(fields(tarball_path = %tarball_path.as_ref().display()))] + pub fn from_tarball(tarball_path: impl AsRef) -> Result { + let tarball_path = tarball_path.as_ref(); + + if !tarball_path.exists() { + return Err(Error::Generic { + message: format!("OCI tarball not found at {}", tarball_path.display()), + source: None, + }); + } + + info!("Loading OCI image from tarball: {}", tarball_path.display()); + + // Load the OCI artifact to get the config digest + let mut archive = + OciArtifact::from_oci_archive(tarball_path).map_err(|e| Error::OciArchive { + message: format!( + "Failed to load OCI artifact from {}", + tarball_path.display() + ), + source: Some(e.into()), + })?; + + let (config_desc, _config_bytes) = archive.get_config().map_err(|e| Error::OciArchive { + message: "Failed to get config from OCI artifact".to_string(), + source: Some(e.into()), + })?; + + Ok(Self { + oci_archive_path: tarball_path.to_path_buf(), + config_digest: config_desc.digest().to_string(), + _temp_dir_manager: None, // Not owned by us, user manages the tarball + }) + } + + /// Pulls an OCI image from a registry and extracts it to a directory. + /// + /// This is a convenience method that combines pulling and extracting. + /// + /// # Arguments + /// * `image_ref` - Image reference (e.g., "ghcr.io/user/image:tag") + /// * `target_dir` - Directory where the image should be extracted + /// * `options` - Pull and extract options + /// + /// # Returns + /// Tuple of (extracted_path, metadata) on success + #[instrument(skip(options), fields(image_ref = %image_ref, target_dir = %target_dir.as_ref().display()))] + pub async fn pull_and_extract( + image_ref: &str, + target_dir: impl AsRef, + options: PullAndExtractOptions, + ) -> Result<(PathBuf, ImageMetadata)> { + info!("Pulling and extracting image."); + + // Build the image (pulls from registry, uses cache) + let mut builder = Image::builder() + .from(image_ref) + .pull_policy(options.pull_policy); + + if let Some(os) = options.platform_os { + if let Some(arch) = options.platform_arch { + builder = builder.platform(&os, &arch); + } + } + + if let Some(cache) = options.blob_cache { + builder = builder.blob_cache(cache); + } + + if let Some(auth) = options.auth { + builder = builder.auth(auth); + } + + let (image, _diagnostics) = builder.build().await?; + + // Extract the image + image.extract(target_dir).await + } + + /// Returns the path to the OCI archive tarball. + pub fn path(&self) -> &Path { + &self.oci_archive_path + } + + /// Returns the digest of the image configuration. + pub fn config_digest(&self) -> &str { + &self.config_digest + } + + /// Gets runtime metadata from the OCI image config. + /// + /// Extracts the entrypoint, cmd, and working directory from the image configuration. + /// If you're planning to extract the image anyway, use `extract()` instead which + /// returns metadata without an extra tar read. + /// + /// # Returns + /// ImageMetadata containing entrypoint, cmd, and working_dir + pub fn get_metadata(&self) -> Result { + Self::read_metadata_from_oci_archive(self.path()) + } + + /// Internal helper to read metadata from an OCI archive. + fn read_metadata_from_oci_archive(path: &Path) -> Result { + let mut archive = OciArtifact::from_oci_archive(path).map_err(|e| Error::OciArchive { + message: format!("Failed to load OCI artifact from {}", path.display()), + source: Some(e.into()), + })?; + + let (_config_desc, config_bytes) = archive.get_config().map_err(|e| Error::OciArchive { + message: "Failed to get config from OCI artifact".to_string(), + source: Some(e.into()), + })?; + + let config: ImageConfiguration = + serde_json::from_slice(&config_bytes).map_err(|e| Error::ImageConfig { + message: "Failed to parse image configuration".to_string(), + source: Some(e.into()), + })?; + + let entrypoint; + let cmd; + let working_dir; + + if let Some(process_config) = config.config() { + entrypoint = process_config.entrypoint().clone(); + cmd = process_config.cmd().clone(); + working_dir = process_config.working_dir().clone(); + } else { + entrypoint = None; + cmd = None; + working_dir = None; + } + + Ok(ImageMetadata { + entrypoint, + cmd, + working_dir, + }) + } + + /// Extracts the OCI image to a directory and returns runtime metadata. + /// + /// All layers are extracted in order, with later layers overwriting earlier ones. + /// This creates a merged filesystem view of the container image. + /// + /// # Arguments + /// * `target_dir` - Directory where the image should be extracted + /// + /// # Returns + /// Tuple of (extracted_path, metadata) on success + #[instrument(skip(self), fields(image_path = %self.oci_archive_path.display(), target_dir = %target_dir.as_ref().display()))] + pub async fn extract(&self, target_dir: impl AsRef) -> Result<(PathBuf, ImageMetadata)> { + let target_dir = target_dir.as_ref(); + info!("Starting image extraction."); + + // Create target directory if it doesn't exist + std_fs::create_dir_all(target_dir).map_err(|e| { + warn!(error = %e, "Failed to create target directory."); + Error::Io { + message: format!("Failed to create target directory {}", target_dir.display()), + source: e, + } + })?; + + // Load OCI artifact from the archive (single tar read for both metadata and layers) + info!(path = %self.path().display(), "Loading OCI artifact for extraction."); + let mut archive = OciArtifact::from_oci_archive(self.path()).map_err(|e| { + warn!(path = %self.path().display(), error = %e, "Failed to load OCI artifact."); + Error::OciArchive { + message: format!("Failed to load OCI artifact from {}", self.path().display()), + source: Some(e.into()), + } + })?; + + // Extract metadata from config before extracting layers + let (_config_desc, config_bytes) = archive.get_config().map_err(|e| { + warn!(error = %e, "Failed to get config from OCI artifact."); + Error::OciArchive { + message: "Failed to get config from OCI artifact".to_string(), + source: Some(e.into()), + } + })?; + + let config: ImageConfiguration = + serde_json::from_slice(&config_bytes).map_err(|e| Error::ImageConfig { + message: "Failed to parse image configuration".to_string(), + source: Some(e.into()), + })?; + + let metadata = if let Some(process_config) = config.config() { + ImageMetadata { + entrypoint: process_config.entrypoint().clone(), + cmd: process_config.cmd().clone(), + working_dir: process_config.working_dir().clone(), + } + } else { + ImageMetadata { + entrypoint: None, + cmd: None, + working_dir: None, + } + }; + + // Get all layers + let layers = archive.get_layers().map_err(|e| { + warn!(error = %e, "Failed to get layers from OCI artifact."); + Error::OciArchive { + message: "Failed to get layers from OCI artifact".to_string(), + source: Some(e.into()), + } + })?; + + info!( + num_layers = layers.len(), + "Extracting layers to target directory." + ); + + // Extract each layer in order (first layer first, last layer last) + // Later layers overwrite earlier ones, simulating a union filesystem + for (idx, (desc, layer_data)) in layers.iter().enumerate() { + debug!( + layer_idx = idx, + layer_digest = %desc.digest(), + layer_size = layer_data.len(), + "Extracting layer" + ); + + // Clone data for the blocking task + let layer_data_vec = layer_data.to_vec(); + let target_dir_clone = target_dir.to_path_buf(); + let layer_digest = desc.digest().to_string(); + + // Extract in a blocking task since tar extraction is CPU-intensive + tokio::task::spawn_blocking(move || -> Result<()> { + use std::io::Cursor; + use tar::Archive; + + // Decompress zstd + let cursor = Cursor::new(layer_data_vec); + let decoder = zstd::Decoder::new(cursor).map_err(|e| { + warn!( + layer_digest = %layer_digest, + error = %e, + "Failed to create zstd decoder" + ); + Error::Io { + message: format!( + "Failed to create zstd decoder for layer {}", + layer_digest + ), + source: e, + } + })?; + + // Extract tar + let mut tar_archive = Archive::new(decoder); + tar_archive.unpack(&target_dir_clone).map_err(|e| { + warn!( + layer_digest = %layer_digest, + error = %e, + "Failed to extract tar archive" + ); + Error::Io { + message: format!("Failed to extract layer {}", layer_digest), + source: e, + } + })?; + + debug!(layer_digest = %layer_digest, "Layer extracted successfully"); + Ok(()) + }) + .await + .map_err(|e| { + warn!(error = %e, "Task join error during layer extraction."); + Error::Generic { + message: "Task join error during layer extraction".to_string(), + source: Some(Box::new(e)), + } + })??; + } + + info!( + target_dir = %target_dir.display(), + num_layers = layers.len(), + "Image extraction completed successfully" + ); + + Ok((target_dir.to_path_buf(), metadata)) + } + + /// Pushes the OCI image to a remote registry. + /// + /// - `target_image_ref_str`: The full reference of the target image (e.g., "ghcr.io/user/image:tag"). + /// - `options`: Push options including authentication and protocol. + /// + /// Returns the pushed image reference string on success. + #[instrument(skip(self, options), fields(image_path = %self.oci_archive_path.display(), target_image_ref = %target_image_ref_str, protocol = ?options.protocol))] + pub async fn push(&self, target_image_ref_str: &str, options: &PushOptions) -> Result { + info!("Starting image push."); + + // Helper function to report progress + let report_progress = |progress: PushProgressInfo| async { + if let Some(ref callback) = options.progress_callback { + callback.on_progress(progress).await; + } + }; + + // Initial progress report + report_progress(PushProgressInfo { + operation: "Starting push".to_string(), + layers_uploaded: 0, + total_layers: 0, + bytes_uploaded: 0, + total_bytes: 0, + }) + .await; + + // 1. Parse the target reference + let push_ref = Reference::try_from(target_image_ref_str).map_err(|e| { + warn!(error = %e, "Invalid target image reference format."); + Error::Generic { + message: format!( + "Invalid target image reference format '{}': {}", + target_image_ref_str, e + ), + source: Some(Box::new(e)), + } + })?; + debug!(push_reference = %push_ref, "Parsed target image reference."); + + // 2. Determine monolithic push setting based on policy and registry + let use_monolithic_push = + determine_use_monolithic_push(&options.monolithic_push, &push_ref); + + let push_client_config = ClientConfig { + protocol: options.protocol.clone(), + use_monolithic_push, + ..Default::default() + }; + + let oci_client = Client::new(push_client_config); + debug!( + use_monolithic_push = use_monolithic_push, + "OCI client for push created." + ); + + // 3. Load OCI artifact from self.oci_archive_path + info!(path = %self.path().display(), "Loading OCI artifact for push."); + let mut archive = OciArtifact::from_oci_archive(self.path()).map_err(|e| { + warn!(path = %self.path().display(), error = %e, "Failed to load OCI artifact."); + Error::OciArchive { + message: format!("Failed to load OCI artifact from {}", self.path().display()), + source: Some(e.into()), + } + })?; + + // 4. Convert manifest (ocipkg -> oci_spec -> json -> oci_client) + debug!("Converting OCI manifest for client."); + let spec_mani: SpecImageManifest = archive.get_manifest().map_err(|e| { + warn!(error = %e, "Failed to get manifest from OCI artifact."); + Error::OciArchive { + message: "Failed to get manifest from OCI artifact".to_string(), + source: Some(e.into()), + } + })?; + let mani_json_bytes = serde_json::to_vec(&spec_mani).map_err(|e| { + warn!(error = %e, "Failed to serialize spec manifest to JSON."); + Error::ImageConfig { + message: "Failed to serialize spec manifest to JSON".to_string(), + source: Some(e.into()), + } + })?; + let dist_mani: OciImageManifest = + serde_json::from_slice(&mani_json_bytes).map_err(|e| { + warn!(error = %e, "Failed to deserialize OCI client manifest from JSON."); + Error::ImageConfig { + message: "Failed to deserialize OCI client manifest from JSON".to_string(), + source: Some(e.into()), + } + })?; + debug!("OCI manifest converted."); + debug!( + num_dist_mani_layers = dist_mani.layers.len(), + dist_mani_layers_digests = ?dist_mani.layers.iter().map(|l| l.digest.as_str()).collect::>(), + "Details of dist_mani (the manifest to be pushed)" + ); + + // 5. Prepare config for oci_client + debug!("Preparing image config for OCI client."); + let (cfg_desc, cfg_bytes) = archive.get_config().map_err(|e| { + warn!(error = %e, "Failed to get config from OCI artifact."); + Error::OciArchive { + message: "Failed to get config from OCI artifact".to_string(), + source: Some(e.into()), + } + })?; + let cfg_for_push = OciClientConfig { + data: cfg_bytes, + media_type: cfg_desc.media_type().to_string(), + annotations: cfg_desc + .annotations() + .clone() + .map(|h| h.into_iter().collect()), + }; + debug!(config_media_type = %cfg_for_push.media_type, "Image config prepared."); + + // 6. Authenticate + info!(target_registry = %push_ref.registry(), "Authenticating with registry."); + oci_client + .auth(&push_ref, &options.auth, RegistryOperation::Push) + .await + .map_err(|e| { + warn!(registry = %push_ref.registry(), error = %e, "Authentication failed for push."); + Error::Generic { + message: format!("Authentication failed for push to {}: {}", push_ref, e), + source: Some(Box::new(e)), + } + })?; + info!("Authentication successful."); + + // 7. Retrieve artifact layers and try to mount them + debug!("Retrieving artifact layers for mounting check."); + let artifact_layers_result = archive.get_layers().map_err(|e| { + warn!(error = %e, "Failed to get layers from OCI artifact."); + Error::OciArchive { + message: "Failed to get layers from OCI artifact".to_string(), + source: Some(e.into()), + } + }); + let artifact_layers = artifact_layers_result?; + debug!( + num_artifact_layers = artifact_layers.len(), + artifact_layers_digests = ?artifact_layers.iter().map(|(d, _)| d.digest()).collect::>(), + "Details of artifact_layers (layers to be processed for mount/upload)" + ); + + let mut mounted_digests = HashSet::new(); + info!("Attempting to mount or verify existing layers to skip upload."); + + for (desc, _layer_data_from_artifact) in &artifact_layers { + let digest_str = desc.digest().to_string(); + let mut should_skip_upload = false; + + debug!(layer_digest = %digest_str, "Attempting to mount layer."); + match oci_client + .mount_blob(&push_ref, &push_ref, &digest_str) + .await + { + Ok(_) => { + info!(layer_digest = %digest_str, "Layer successfully mounted (OCI 201)."); + should_skip_upload = true; + } + Err(e) => { + debug!(layer_digest = %digest_str, error = %e, "Layer mount failed. Will upload."); + } + } + + if should_skip_upload { + mounted_digests.insert(digest_str.clone()); + } + } + info!( + num_layers_skipped = mounted_digests.len(), + "Finished attempting to mount/verify layers." + ); + + // 8. Prepare layers for push (filter out mounted ones) + let layers_to_push: Vec = artifact_layers + .into_iter() // Consumes artifact_layers + .filter(|(d, _)| !mounted_digests.contains(d.digest())) + .map(|(d, data)| OciImageLayer { + data: data.to_vec(), // ocipkg returns bytes::Bytes, oci_client expects Vec + media_type: d.media_type().to_string(), + annotations: d.annotations().clone().map(|h| h.into_iter().collect()), + }) + .collect(); + + let total_push_size_bytes: usize = layers_to_push.iter().map(|l| l.data.len()).sum(); + info!( + num_layers_to_push = layers_to_push.len(), + num_total_layers = mounted_digests.len() + layers_to_push.len(), + total_push_size_mb = total_push_size_bytes / (1024 * 1024), + "Preparing to push layers." + ); + + // Report progress with total bytes and layers + let total_layers = mounted_digests.len() + layers_to_push.len(); + report_progress(PushProgressInfo { + operation: "Uploading layers".to_string(), + layers_uploaded: mounted_digests.len(), + total_layers, + bytes_uploaded: 0, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + // Skip push when everything was mounted + if layers_to_push.is_empty() { + info!("All layers already exist in the registry. Pushing config and manifest."); + + // Report progress for config and manifest upload + report_progress(PushProgressInfo { + operation: "Uploading config and manifest".to_string(), + layers_uploaded: total_layers, + total_layers, + bytes_uploaded: total_push_size_bytes as u64, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + // Use the standard push method with empty layers to ensure config blob is uploaded + oci_client + .push( + &push_ref, + &Vec::new(), // Empty layers since they're all already mounted + cfg_for_push, + &options.auth, + Some(dist_mani), + ) + .await + .map_err(|e| { + warn!(error = %e, "OCI client push failed."); + Error::Generic { + message: format!("OCI client push to {} failed: {}", push_ref, e), + source: Some(Box::new(e)), + } + })?; + + // Final progress report + report_progress(PushProgressInfo { + operation: "Push completed".to_string(), + layers_uploaded: total_layers, + total_layers, + bytes_uploaded: total_push_size_bytes as u64, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + info!(image_ref = %target_image_ref_str, "Image push successful (config and manifest only)."); + return Ok(target_image_ref_str.to_string()); + } + + // 9. Push layers individually with progress reporting + info!("Pushing image (layers, config, manifest)."); + + // Report that we're about to start the actual upload + let operation_text = if layers_to_push.is_empty() { + "All layers cached".to_string() + } else if total_push_size_bytes > 10 * 1024 * 1024 { + format!( + "Uploading {:.1} MB in {} layers", + total_push_size_bytes as f64 / (1024.0 * 1024.0), + layers_to_push.len() + ) + } else { + format!("Uploading {} layers", layers_to_push.len()) + }; + + report_progress(PushProgressInfo { + operation: operation_text, + layers_uploaded: mounted_digests.len(), + total_layers, + bytes_uploaded: 0, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + let mut uploaded_bytes = 0u64; + let mut uploaded_layers = mounted_digests.len(); + + // Upload each layer individually with progress reporting + for (i, layer) in layers_to_push.iter().enumerate() { + let digest = format!("sha256:{:x}", sha2::Sha256::digest(&layer.data)); + info!( + "Uploading layer {}/{}: {}", + i + 1, + layers_to_push.len(), + digest + ); + + oci_client + .push_blob(&push_ref, &layer.data, &digest) + .await + .map_err(|e| { + warn!(error = %e, "Failed to push layer {}", digest); + Error::Generic { + message: format!("Failed to push layer {}: {}", digest, e), + source: Some(Box::new(e)), + } + })?; + + uploaded_bytes += layer.data.len() as u64; + uploaded_layers += 1; + + report_progress(PushProgressInfo { + operation: String::new(), + layers_uploaded: uploaded_layers, + total_layers, + bytes_uploaded: uploaded_bytes, + total_bytes: total_push_size_bytes as u64, + }) + .await; + } + + // Upload config blob + info!("Uploading config blob"); + report_progress(PushProgressInfo { + operation: "Uploading config".to_string(), + layers_uploaded: uploaded_layers, + total_layers, + bytes_uploaded: uploaded_bytes, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + oci_client + .push_blob(&push_ref, &cfg_for_push.data, &dist_mani.config.digest) + .await + .map_err(|e| { + warn!(error = %e, "Failed to push config blob"); + Error::Generic { + message: format!("Failed to push config blob: {}", e), + source: Some(Box::new(e)), + } + })?; + + // Upload manifest + info!("Uploading manifest"); + report_progress(PushProgressInfo { + operation: "Uploading manifest".to_string(), + layers_uploaded: uploaded_layers, + total_layers, + bytes_uploaded: uploaded_bytes, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + oci_client + .push_manifest(&push_ref, &dist_mani.into()) + .await + .map_err(|e| { + warn!(error = %e, "Failed to push manifest"); + Error::Generic { + message: format!("Failed to push manifest: {}", e), + source: Some(Box::new(e)), + } + })?; + + // Final progress report + report_progress(PushProgressInfo { + operation: "Push completed".to_string(), + layers_uploaded: total_layers, + total_layers, + bytes_uploaded: total_push_size_bytes as u64, + total_bytes: total_push_size_bytes as u64, + }) + .await; + + // 10. Return success + info!(image_ref = %target_image_ref_str, "Image push successful."); + Ok(target_image_ref_str.to_string()) + } +} + +/// Builder for creating `Image` instances. +#[derive(Default)] +pub struct ImageBuilder { + base_image_ref: Option, + platform_os: Option, + platform_arch: Option, + layers: Vec, + entrypoint: Option>, + cmd: Option>, + working_dir: Option, + output_path: Option, + blob_cache: Option, + output_image_name_and_tag: Option, + pull_policy: Option, + auth: Option, +} + +impl ImageBuilder { + /// Sets the base image reference (e.g., "marketplace.gcr.io/google/ubuntu2404:latest", "ghcr.io/user/image:tag"). + pub fn from(mut self, base_image_ref: &str) -> Self { + self.base_image_ref = Some(base_image_ref.to_string()); + self + } + + /// Sets the target platform for the image. + pub fn platform(mut self, os: &str, arch: &Arch) -> Self { + self.platform_os = Some(os.to_string()); + self.platform_arch = Some(arch.clone()); + self + } + + /// Adds a `Layer` to be included in the image. Layers are applied in the order they are added. + pub fn layer(mut self, layer: Layer) -> Self { + self.layers.push(layer); + self + } + + /// Sets the entrypoint for the image. Overrides the entrypoint from the base image. + pub fn entrypoint(mut self, entrypoint: Vec) -> Self { + self.entrypoint = Some(entrypoint); + self + } + + /// Sets the command (Cmd) for the image. Overrides the command from the base image. + pub fn cmd(mut self, cmd: Vec) -> Self { + self.cmd = Some(cmd); + self + } + + /// Sets the working directory for the image. Overrides the working directory from the base image. + pub fn working_dir(mut self, working_dir: &str) -> Self { + self.working_dir = Some(working_dir.to_string()); + self + } + + /// Specifies the final path where the OCI archive tarball should be saved. + /// If not set, the archive will be created in a temporary directory. + pub fn output_to(mut self, path: PathBuf) -> Self { + self.output_path = Some(path); + self + } + + /// Sets a specific BlobCache instance to be used by the ImageBuilder. + /// If not called, a default BlobCache will be created when `build()` is invoked. + pub fn blob_cache(mut self, cache: blobcache::BlobCache) -> Self { + self.blob_cache = Some(cache); + self + } + + /// Sets the name and tag to be used for the image configuration within the OCI archive. + /// If not set, it defaults to ":latest". + pub fn output_name_and_tag(mut self, name_and_tag: &str) -> Self { + self.output_image_name_and_tag = Some(name_and_tag.to_string()); + self + } + + /// Sets the pull policy for the base image manifest. + /// Defaults to `PullPolicy::Missing`. + pub fn pull_policy(mut self, policy: PullPolicy) -> Self { + self.pull_policy = Some(policy); + self + } + + /// Sets the authentication for pulling the base image. + /// If not set, authentication is determined from environment variables (DOCKER_USERNAME/DOCKER_PASSWORD). + pub fn auth(mut self, auth: RegistryAuth) -> Self { + self.auth = Some(auth); + self + } + + /// Builds the image. + /// This involves potentially using a cache for the base image, pulling it if necessary, + /// applying new layers and configuration, + /// and creating an OCI tarball layout in a temporary directory. + #[instrument(skip_all, fields( + base_image_ref = ?self.base_image_ref, + platform_os = ?self.platform_os, + platform_arch = ?self.platform_arch, + num_layers_to_add = self.layers.len(), + output_path = ?self.output_path + ))] + pub async fn build(mut self) -> Result<(Image, BuildDiagnostics)> { + info!("Starting image build."); + + let target_os_for_build = self + .platform_os + .clone() + .unwrap_or_else(|| "linux".to_string()); + let target_arch_for_build = self.platform_arch.clone().unwrap_or(Arch::Amd64); + + // Conditionally pull base image if specified + let base_image_data: Option; + let manifest_source: ManifestSource; + let resolved_manifest_digest_str: String; + let default_image_name: String; + + if let Some(base_image_ref_str) = &self.base_image_ref { + // Pull base image + info!("Building from base image: {}", base_image_ref_str); + + let base_ref = + Reference::try_from(base_image_ref_str.as_str()).map_err(|e| Error::ImagePull { + image_ref: base_image_ref_str.to_string(), + message: format!("Invalid base image reference format: {}", e), + source: Some(Box::new(e)), + })?; + + // Determine authentication for pulling base image + // Use provided auth if available, otherwise determine from environment variables + let pull_auth = self + .auth + .take() + .unwrap_or_else(|| determine_registry_auth(&base_ref)); + let pull_policy = self.pull_policy.take().unwrap_or_default(); // Get policy or default + + // Initialize cache instance ONCE. + // If self.blob_cache is None, ImageBuilder creates its own. + // If it's Some, it means ImageBuilder was configured with an external cache. + let cache = match self.blob_cache.take() { + // Take ownership from self + Some(c) => c, + None => { + debug!("No BlobCache provided to ImageBuilder, creating a default one."); + blobcache::BlobCache::new()? + } + }; + + let mut client_cfg = ClientConfig::default(); + + if let (Some(os_filter_val), Some(arch_filter_val)) = + (&self.platform_os, &self.platform_arch) + { + let os_filter_cloned = os_filter_val.clone(); + let arch_filter_cloned = arch_filter_val.to_string(); + client_cfg.platform_resolver = Some(Box::new( + move |index_entries: &[ImageIndexEntry]| { + info!(target_os = %os_filter_cloned, target_arch = %arch_filter_cloned, num_index_entries = index_entries.len(), "Platform resolver: Attempting to find match."); + for entry in index_entries { + if let Some(p) = entry.platform.as_ref() { + if p.os == os_filter_cloned && p.architecture == arch_filter_cloned + { + info!(resolver_selected_digest = %entry.digest, "Platform resolver: Found a match."); + return Some(entry.digest.clone()); + } + } + } + warn!(target_os = %os_filter_cloned, target_arch = %arch_filter_cloned, "Platform resolver: No match found."); + None + }, + )); + } else { + info!("Platform resolver not configured as os/arch were not explicitly provided to ImageBuilder. Default oci-client resolver will be used if necessary."); + // If no platform is specified by the user, oci-client's default resolver (current_platform_resolver) will be used. + // We don't need to explicitly set it to None here, as ClientConfig::default() already sets a default resolver. + } + + let oci_client = Client::new(client_cfg); + + info!(base_image_ref = %base_image_ref_str, pull_policy = ?pull_policy, "Attempting to pull/load resolved base image manifest."); + + let pull_err_mapper = |e: OciDistributionError| { + warn!(base_image_ref = %base_image_ref_str, error = %e, "Failed to pull and resolve base image manifest."); + Error::ImagePull { + image_ref: base_image_ref_str.to_string(), + message: format!( + "Failed to pull/resolve base image manifest ({}): {}", + base_image_ref_str, e + ), + source: Some(Box::new(e)), + } + }; + + let manifest_cache_key = format!("manifest-v1:{}", base_ref.whole()); + let manifest_source_temp: ManifestSource; + + let (base_image_manifest_resolved, resolved_manifest_digest_str_temp) = if pull_policy + == PullPolicy::Missing + { + debug!(key = %manifest_cache_key, "PullPolicy::Missing. Attempting to load manifest from cache."); + match cache.get_blob(&manifest_cache_key).await { + Ok(Some(cached_data)) => { + match serde_json::from_slice::<(OciImageManifest, String)>(&cached_data) { + Ok((manifest, digest)) => { + info!(key = %manifest_cache_key, resolved_digest = %digest, "Manifest cache hit and deserialized successfully."); + manifest_source_temp = ManifestSource::FromCache; + (manifest, digest) + } + Err(e) => { + warn!(key = %manifest_cache_key, error = %e, "Failed to deserialize cached manifest. Will pull from registry."); + // Fallback: Pull and cache + manifest_source_temp = ManifestSource::FromRegistry; + let (pulled_manifest, pulled_digest) = oci_client + .pull_image_manifest(&base_ref, &pull_auth) + .await + .map_err(pull_err_mapper)?; + match serde_json::to_vec(&( + pulled_manifest.clone(), + pulled_digest.clone(), + )) { + Ok(data_to_cache) => { + if let Err(cache_err) = cache + .put_blob(&manifest_cache_key, &data_to_cache) + .await + { + warn!(key = %manifest_cache_key, error = %cache_err, "Failed to cache manifest after pull."); + } + } + Err(ser_err) => { + warn!(key = %manifest_cache_key, error = %ser_err, "Failed to serialize manifest for caching after pull."); + } + } + (pulled_manifest, pulled_digest) + } + } + } + Ok(None) => { + // Cache miss + info!(key = %manifest_cache_key, "Manifest cache miss (no entry found). Will pull from registry."); + manifest_source_temp = ManifestSource::FromRegistry; + let (pulled_manifest, pulled_digest) = oci_client + .pull_image_manifest(&base_ref, &pull_auth) + .await + .map_err(pull_err_mapper)?; + match serde_json::to_vec(&(pulled_manifest.clone(), pulled_digest.clone())) + { + Ok(data_to_cache) => { + if let Err(cache_err) = + cache.put_blob(&manifest_cache_key, &data_to_cache).await + { + warn!(key = %manifest_cache_key, error = %cache_err, "Failed to cache manifest after pull."); + } + } + Err(ser_err) => { + warn!(key = %manifest_cache_key, error = %ser_err, "Failed to serialize manifest for caching after pull."); + } + } + (pulled_manifest, pulled_digest) + } + Err(e) => { + // Cache error + warn!(key = %manifest_cache_key, error = %e, "Error reading manifest from cache. Will pull from registry."); + manifest_source_temp = ManifestSource::FromRegistry; + let (pulled_manifest, pulled_digest) = oci_client + .pull_image_manifest(&base_ref, &pull_auth) + .await + .map_err(pull_err_mapper)?; + match serde_json::to_vec(&(pulled_manifest.clone(), pulled_digest.clone())) + { + Ok(data_to_cache) => { + if let Err(cache_err) = + cache.put_blob(&manifest_cache_key, &data_to_cache).await + { + warn!(key = %manifest_cache_key, error = %cache_err, "Failed to cache manifest after pull."); + } + } + Err(ser_err) => { + warn!(key = %manifest_cache_key, error = %ser_err, "Failed to serialize manifest for caching after pull."); + } + } + (pulled_manifest, pulled_digest) + } + } + } else { + // PullPolicy::Always + info!(key = %manifest_cache_key, "PullPolicy::Always. Pulling manifest from registry."); + manifest_source_temp = ManifestSource::FromRegistry; + let (pulled_manifest, pulled_digest) = oci_client + .pull_image_manifest(&base_ref, &pull_auth) + .await + .map_err(pull_err_mapper)?; + + match serde_json::to_vec(&(pulled_manifest.clone(), pulled_digest.clone())) { + Ok(data_to_cache) => { + if let Err(cache_err) = + cache.put_blob(&manifest_cache_key, &data_to_cache).await + { + warn!(key = %manifest_cache_key, error = %cache_err, "Failed to cache manifest after pull."); + } else { + debug!(key = %manifest_cache_key, "Successfully cached manifest after pull."); + } + } + Err(ser_err) => { + warn!(key = %manifest_cache_key, error = %ser_err, "Failed to serialize manifest for caching after pull."); + } + } + (pulled_manifest, pulled_digest) + }; + + info!(manifest_digest = %resolved_manifest_digest_str_temp, "Successfully obtained base ImageManifest (source: {:?}).", manifest_source_temp); + + // Fetch config blob using the resolved manifest + let config_descriptor = &base_image_manifest_resolved.config; + info!(config_digest = %config_descriptor.digest, "Fetching base image config blob."); + let config_data = match cache.get_blob(&config_descriptor.digest).await? { + Some(data) => { + info!(config_digest = %config_descriptor.digest, "Base image config blob found in cache."); + data + } + None => { + info!(config_digest = %config_descriptor.digest, "Base image config blob not in cache, pulling."); + let mut pulled_data = Vec::new(); + oci_client + .pull_blob(&base_ref, config_descriptor, &mut pulled_data) + .await + .map_err(|e| Error::ImagePull { + image_ref: base_image_ref_str.to_string(), + message: format!( + "Failed to pull config blob {}", + config_descriptor.digest + ), + source: Some(Box::new(e)), + })?; + cache + .put_blob(&config_descriptor.digest, &pulled_data) + .await?; + pulled_data + } + }; + + let oci_client_config_for_imagedata = OciClientConfig { + data: config_data, + media_type: config_descriptor.media_type.clone(), + annotations: config_descriptor.annotations.clone(), + }; + + info!( + num_base_layers = base_image_manifest_resolved.layers.len(), + "Fetching base image layer blobs." + ); + let mut oci_client_layers = Vec::new(); + for (idx, layer_descriptor) in base_image_manifest_resolved.layers.iter().enumerate() { + let layer_data = match cache.get_blob(&layer_descriptor.digest).await? { + Some(data) => { + info!(layer_idx = idx, layer_digest = %layer_descriptor.digest, "Base layer blob found in cache."); + data + } + None => { + info!(layer_idx = idx, layer_digest = %layer_descriptor.digest, "Base layer blob not in cache, pulling."); + let mut pulled_data = Vec::new(); + oci_client + .pull_blob(&base_ref, layer_descriptor, &mut pulled_data) + .await + .map_err(|e| Error::ImagePull { + image_ref: base_image_ref_str.to_string(), + message: format!( + "Failed to pull layer blob {}", + layer_descriptor.digest + ), + source: Some(Box::new(e)), + })?; + cache + .put_blob(&layer_descriptor.digest, &pulled_data) + .await?; + pulled_data + } + }; + oci_client_layers.push(OciImageLayer { + data: layer_data, + media_type: layer_descriptor.media_type.clone(), + annotations: layer_descriptor.annotations.clone(), + }); + } + + // Assign to outer scope variables + manifest_source = manifest_source_temp; + resolved_manifest_digest_str = resolved_manifest_digest_str_temp; + + base_image_data = Some(OciImageData { + layers: oci_client_layers, + digest: Some(resolved_manifest_digest_str.clone()), + config: oci_client_config_for_imagedata, + manifest: Some(base_image_manifest_resolved.clone()), + }); + default_image_name = format!("{}:latest", base_ref.repository()); + } else { + // Building from scratch (no base image) + info!("Building from scratch (no base image)"); + base_image_data = None; + manifest_source = ManifestSource::NotApplicable; + resolved_manifest_digest_str = String::new(); + default_image_name = "scratch:latest".to_string(); + } + + let build_artifacts_dir = tempfile::tempdir().map_err(|e| Error::Io { + message: "Failed to create temporary directory for image build artifacts".to_string(), + source: e, + })?; + + // Build image configuration (either from base or from scratch) + let current_config: ImageConfiguration = if let Some(ref base_data) = base_image_data { + // Start from base image config + let mut config: ImageConfiguration = serde_json::from_slice(&base_data.config.data) + .map_err(|e| Error::ImageConfig { + message: "Failed to parse base image configuration".to_string(), + source: Some(Box::new(e)), + })?; + + // Add new layers to diff_ids + let mut all_diff_ids: Vec = config.rootfs().diff_ids().clone(); + for new_layer in &self.layers { + all_diff_ids.push(new_layer.diff_id().to_string()); + } + *config.rootfs_mut().diff_ids_mut() = all_diff_ids; + + // Update process config + let mut proc_config = config.config().clone().unwrap_or_default(); + if let Some(entrypoint) = self.entrypoint { + proc_config.set_entrypoint(Some(entrypoint)); + } + if let Some(cmd) = self.cmd { + proc_config.set_cmd(Some(cmd)); + } else { + proc_config.set_cmd(Some(vec![])); + } + if let Some(working_dir) = self.working_dir { + proc_config.set_working_dir(Some(working_dir)); + } + config.set_os(target_os_for_build.as_str().into()); + config.set_architecture(target_arch_for_build.to_string().as_str().into()); + config.set_config(Some(proc_config)); + config + } else { + // Build from scratch - create minimal config + use oci_spec::image::{ConfigBuilder, ImageConfigurationBuilder, RootFsBuilder}; + + let diff_ids: Vec = self + .layers + .iter() + .map(|layer| layer.diff_id().to_string()) + .collect(); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(diff_ids) + .build() + .map_err(|e| Error::ImageConfig { + message: format!("Failed to build rootfs: {}", e), + source: Some(Box::new(e)), + })?; + + let mut config_builder = ConfigBuilder::default(); + if let Some(entrypoint) = self.entrypoint { + config_builder = config_builder.entrypoint(entrypoint); + } + if let Some(cmd) = self.cmd { + config_builder = config_builder.cmd(cmd); + } else { + config_builder = config_builder.cmd(Vec::::new()); + } + if let Some(working_dir) = self.working_dir { + config_builder = config_builder.working_dir(working_dir); + } + + let proc_config = config_builder.build().map_err(|e| Error::ImageConfig { + message: format!("Failed to build config: {}", e), + source: Some(Box::new(e)), + })?; + + ImageConfigurationBuilder::default() + .os(target_os_for_build.as_str()) + .architecture(target_arch_for_build.to_string().as_str()) + .rootfs(rootfs) + .config(proc_config) + .build() + .map_err(|e| Error::ImageConfig { + message: format!("Failed to build image configuration: {}", e), + source: Some(Box::new(e)), + })? + }; + + let config_json_bytes = + serde_json::to_vec(¤t_config).map_err(|e| Error::ImageConfig { + message: "Failed to serialize new image configuration".to_string(), + source: Some(Box::new(e)), + })?; + let config_digest_sha256 = { + let mut hasher = Sha256::new(); + hasher.update(&config_json_bytes); + format!("sha256:{:x}", hasher.finalize()) + }; + + let mut oci_tar_builder = oci_tar_builder::Builder::default(); + + // Add base layers if we have them + if let Some(ref base_data) = base_image_data { + for (idx, base_layer_oci) in base_data.layers.iter().enumerate() { + let temp_layer_path = build_artifacts_dir + .path() + .join(format!("base_layer_{}.blob", idx)); + std_fs::write(&temp_layer_path, &base_layer_oci.data).map_err(|e| Error::Io { + message: format!("Failed to write base layer {} to temp file", idx), + source: e, + })?; + oci_tar_builder + .add_layer_with_media_type(&temp_layer_path, base_layer_oci.media_type.clone()); + } + } + + // Add new layers + for new_layer in &self.layers { + oci_tar_builder.add_layer_with_media_type( + &new_layer.path().to_path_buf(), + IMAGE_LAYER_ZSTD_MEDIA_TYPE.to_string(), + ); + } + + let image_name_and_tag_for_config = self + .output_image_name_and_tag + .clone() + .unwrap_or(default_image_name); + oci_tar_builder.add_config(current_config.clone(), image_name_and_tag_for_config); + + let (oci_archive_final_path, temp_dir_manager_for_image_struct) = if let Some(output_p) = + self.output_path + { + if let Some(parent_dir) = output_p.parent() { + if !parent_dir.exists() { + std_fs::create_dir_all(parent_dir).map_err(|e| Error::Io { + message: format!( + "Failed to create parent directory for output OCI archive: {}", + parent_dir.display() + ), + source: e, + })?; + } + } + (output_p, None) + } else { + let final_oci_temp_dir = tempfile::tempdir().map_err(|e| Error::Io { + message: "Failed to create temporary directory for final OCI archive".to_string(), + source: e, + })?; + ( + final_oci_temp_dir.path().join("image.oci.tar"), + Some(final_oci_temp_dir), + ) + }; + + let oci_archive_file = + std_fs::File::create(&oci_archive_final_path).map_err(|e| Error::OciArchive { + message: format!( + "Failed to create OCI archive file at {}", + oci_archive_final_path.display() + ), + source: Some(Box::new(e)), + })?; + + oci_tar_builder + .build(oci_archive_file) + .map_err(|e| Error::OciArchive { + message: format!("OCI tar builder failed: {}", e), + source: Some(e.into()), + })?; + + let diagnostics = BuildDiagnostics { + manifest_source, + resolved_manifest_digest: resolved_manifest_digest_str.clone(), + }; + + Ok(( + Image { + oci_archive_path: oci_archive_final_path, + config_digest: config_digest_sha256, + _temp_dir_manager: temp_dir_manager_for_image_struct, + }, + diagnostics, + )) + } +} + +/// Determines the RegistryAuth by trying environment variables and falling back to Anonymous. +fn determine_registry_auth(reference: &Reference) -> RegistryAuth { + let host_for_logging = reference.resolve_registry(); // Still useful for logging + + let auth = match (env::var("DOCKER_USERNAME"), env::var("DOCKER_PASSWORD")) { + (Ok(username), Ok(password)) if !username.is_empty() && !password.is_empty() => { + info!( + "Using Docker credentials from DOCKER_USERNAME/PASSWORD env vars for {}", + host_for_logging + ); + RegistryAuth::Basic(username, password) + } + _ => { + info!( + "DOCKER_USERNAME and/or DOCKER_PASSWORD not set or empty. Falling back to anonymous auth for {}.", + host_for_logging + ); + RegistryAuth::Anonymous + } + }; + + auth +} + +/// Determines whether to use monolithic push based on the policy and registry hostname. +fn determine_use_monolithic_push(policy: &MonolithicPushPolicy, reference: &Reference) -> bool { + match policy { + MonolithicPushPolicy::Always => { + debug!("MonolithicPushPolicy::Always - using monolithic push"); + true + } + MonolithicPushPolicy::Never => { + debug!("MonolithicPushPolicy::Never - using chunked upload"); + false + } + MonolithicPushPolicy::Auto => { + let registry_host = reference.resolve_registry(); + let use_monolithic = is_registry_requiring_monolithic_push(registry_host); + + if use_monolithic { + info!( + "Registry {} requires monolithic push - enabling monolithic push mode", + registry_host + ); + } else { + debug!( + "Registry {} supports chunked upload - using chunked upload mode", + registry_host + ); + } + + use_monolithic + } + } +} + +/// Checks if a registry requires monolithic push based on its hostname. +fn is_registry_requiring_monolithic_push(registry_host: &str) -> bool { + // Google Artifact Registry and Container Registry require monolithic push + registry_host.ends_with("-docker.pkg.dev") // Google Artifact Registry + || registry_host == "gcr.io" + || registry_host.ends_with(".gcr.io") // Google Container Registry + || registry_host == "us.gcr.io" + || registry_host == "eu.gcr.io" + || registry_host == "asia.gcr.io" +} diff --git a/src/layer.rs b/src/layer.rs new file mode 100644 index 0000000..6cf4b3f --- /dev/null +++ b/src/layer.rs @@ -0,0 +1,1514 @@ +use crate::blobcache; +use crate::error::{Error, Result}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashSet, + fs::File, + io::{self}, + path::{Path, PathBuf}, +}; +use tempfile::NamedTempFile; +use tokio::task; +use tracing::{debug, info, instrument, warn}; + +/// OCI media type for zstd-compressed tar layers +const IMAGE_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; + +/// Represents a single layer in an OCI image. +/// +/// A layer consists of a zstd-compressed tarball of files and a corresponding diff_id. +/// The temporary file holding the compressed tarball is automatically deleted when +/// the `Layer` instance is dropped. +pub struct Layer { + diff_id: String, // Digest of the uncompressed tar (for image config diffIds) + blob_digest: String, // Digest of the compressed tar (for OCI blob storage/cache key) + media_type: String, // Media type of the blob, typically IMAGE_LAYER_ZSTD_MEDIA_TYPE + compressed_layer_file: NamedTempFile, // The temporary file for this layer instance +} + +impl Layer { + /// Creates a new `LayerBuilder` to construct a layer. + pub fn builder() -> Result { + LayerBuilder::new() + } + + /// Returns the diff_id of the layer (e.g., "sha256:deadbeef..."), hash of uncompressed tar. + pub fn diff_id(&self) -> &str { + &self.diff_id + } + + /// Returns the blob_digest of the layer (e.g., "sha256:abc123..."), hash of compressed tar. + pub fn blob_digest(&self) -> &str { + &self.blob_digest + } + + /// Returns the media type of the layer's blob. + pub fn media_type(&self) -> &str { + &self.media_type + } + + /// Returns the path to the temporary file containing the compressed tarball for this layer. + pub fn path(&self) -> &Path { + self.compressed_layer_file.path() + } +} + +/// Metadata about a file added to the layer, used for input-based cache key calculation. +#[derive(Debug, Clone)] +struct FileMetadata { + /// Path in the archive + archive_path: PathBuf, + /// Size of the file + size: u64, + /// Modification time (seconds since epoch) + mtime: u64, + /// File mode (permissions) + mode: u32, + /// Whether this is a directory + is_dir: bool, +} + +/// Builder for creating `Layer` instances. +/// +/// Allows for incrementally adding content (directories, files, in-memory data) +/// to an uncompressed tar archive stored in a temporary file. The `build` method +/// finalizes this tar archive, computes its diff_id, zstd-compresses it into another +/// temporary file, and returns a `Layer`. +pub struct LayerBuilder { + /// Kept alive so the temp file isn't deleted while the tar builder writes to it. + _uncompressed_tar_tmpfile: NamedTempFile, + tar_writer: tar::Builder, + uncompressed_tar_path: PathBuf, + blob_cache: Option, + created_archive_dirs: HashSet, + /// Metadata about files added to this layer, used for input-based cache key. + file_metadata: Vec, +} + +impl LayerBuilder { + #[instrument(level = "info", skip_all, fields(uncompressed_tar_path))] + fn new() -> Result { + info!("Creating new LayerBuilder."); + let uncompressed_tar_tmpfile = NamedTempFile::new().map_err(|e| { + warn!(error = %e, "Failed to create temporary file for uncompressed tar."); + Error::Io { + source: e, + message: "Failed to create temporary file for uncompressed tar".to_string(), + } + })?; + let file_for_builder = uncompressed_tar_tmpfile.reopen().map_err(|e| { + warn!(error = %e, "Failed to reopen temporary file for tar builder."); + Error::Io { + source: e, + message: "Failed to reopen temporary file for tar builder".to_string(), + } + })?; + let mut tar_writer = tar::Builder::new(file_for_builder); + tar_writer.follow_symlinks(false); + let uncompressed_tar_path = uncompressed_tar_tmpfile.path().to_path_buf(); + tracing::Span::current().record( + "uncompressed_tar_path", + uncompressed_tar_path.display().to_string(), + ); + debug!(path = %uncompressed_tar_path.display(), "LayerBuilder initialized with temporary tar file."); + Ok(Self { + _uncompressed_tar_tmpfile: uncompressed_tar_tmpfile, + tar_writer, + uncompressed_tar_path, + blob_cache: None, + created_archive_dirs: HashSet::new(), + file_metadata: Vec::new(), + }) + } + + /// Sets a specific BlobCache instance to be used by the LayerBuilder. + /// If not called, a default BlobCache will be created when `build()` is invoked. + pub fn blob_cache(mut self, cache: blobcache::BlobCache) -> Self { + self.blob_cache = Some(cache); + self + } + + /// Calculate a cache key from input file metadata. + /// This is much faster than hashing the tar content because it only hashes + /// the metadata (paths, sizes, mtimes, modes) rather than file contents. + /// The key is deterministic: same input files → same tar → same key. + fn calculate_input_key(&self) -> String { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + + // Sort metadata by archive path for deterministic ordering + let mut sorted_metadata = self.file_metadata.clone(); + sorted_metadata.sort_by(|a, b| a.archive_path.cmp(&b.archive_path)); + + for meta in &sorted_metadata { + // Hash: path | size | mtime | mode | is_dir + hasher.update(meta.archive_path.to_string_lossy().as_bytes()); + hasher.update(meta.size.to_le_bytes()); + hasher.update(meta.mtime.to_le_bytes()); + hasher.update(meta.mode.to_le_bytes()); + hasher.update([meta.is_dir as u8]); + } + + format!("layer-input-{:x}", hasher.finalize()) + } + + /// Calculate a content-based cache key from a finalized tar file path. + /// This is a fallback when input-based caching doesn't produce a hit. + fn calculate_content_key(tar_path: &Path) -> Result { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + + // Hash all entries in the tar in order + let mut file = File::open(tar_path).map_err(|e| Error::Io { + source: e, + message: format!( + "Failed to open tar file for content key calculation: {}", + tar_path.display() + ), + })?; + + std::io::copy(&mut file, &mut hasher).map_err(|e| Error::Io { + source: e, + message: "Failed to read tar file for content key calculation".to_string(), + })?; + + Ok(format!("layer-content-{:x}", hasher.finalize())) + } + + /// Adds an entire directory (recursively) from the local filesystem to the layer. + /// + /// - `disk_path`: Path to the source directory on the local filesystem. + /// - `archive_path`: Path under which the directory contents will be placed within the layer's tar archive. + #[instrument(level = "info", skip(self), fields(disk_path = %disk_path.as_ref().display(), archive_path = %archive_path.as_ref().display()))] + pub fn directory( + mut self, + disk_path: impl AsRef, + archive_path: impl AsRef, + ) -> Result { + let dp_ref = disk_path.as_ref(); + let ap_ref = archive_path.as_ref(); + info!( + "Adding directory to layer. Original archive path: {}", + ap_ref.display() + ); + + let normalized_ap = normalize_archive_path(ap_ref); + debug!( + "Normalized archive path for directory: {}", + normalized_ap.display() + ); + + self.tar_writer + .append_dir_all(&normalized_ap, dp_ref) + .map_err(|e| { + warn!(error = %e, "Failed to append directory to tar."); + Error::Io { + source: e, + message: format!( + "Failed to append directory {} (as {}) to tar", + dp_ref.display(), + normalized_ap.display() + ), + } + })?; + + self.file_metadata.push(FileMetadata { + archive_path: normalized_ap, + size: 0, + mtime: 0, + mode: 0o755, + is_dir: true, + }); + + debug!("Successfully added directory to layer."); + Ok(self) + } + + /// Adds a single file from the local filesystem to the layer. + /// + /// - `disk_path`: Path to the source file on the local filesystem. + /// - `archive_path`: Path where the file will be placed within the layer's tar archive. + /// If absolute (e.g. "/foo/bar.txt"), it will be treated as relative to the layer root (e.g. "foo/bar.txt"). + /// An archive_path of "/" is invalid for a file. + /// - `mode`: Optional. The file mode (permissions) to set. If `None`, uses the source file's mode. + #[instrument(level = "info", skip(self), fields(disk_path = %disk_path.as_ref().display(), archive_path = %archive_path.as_ref().display(), mode))] + pub fn file( + mut self, + disk_path: impl AsRef, + archive_path: impl AsRef, + mode: Option, + ) -> Result { + let dp_ref = disk_path.as_ref(); + let ap_ref = archive_path.as_ref(); + info!( + "Adding file to layer. Original archive path: {}", + ap_ref.display() + ); + + let normalized_ap = normalize_archive_path(ap_ref); + debug!( + "Normalized archive path for file: {}", + normalized_ap.display() + ); + + if normalized_ap.as_os_str().is_empty() { + warn!("Attempted to add file with an empty archive path (derived from root '/')"); + return Err(Error::InvalidPath { + message: format!( + "Archive path for file '{}' cannot be the root directory ('/') or empty.", + dp_ref.display() + ), + }); + } + + self.ensure_parent_dirs_exist(&normalized_ap)?; + + let mut f = File::open(dp_ref).map_err(|e| Error::Io { + source: e, + message: format!("Failed to open file {} for tar", dp_ref.display()), + })?; + + // Get file metadata for input-based cache key + let file_metadata = f.metadata().map_err(|e| Error::Io { + source: e, + message: format!("Failed to get metadata for file {}", dp_ref.display()), + })?; + + let file_mode = mode.unwrap_or_else(|| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file_metadata.permissions().mode() + } + #[cfg(not(unix))] + { + 0o644 + } + }); + + // Track file metadata for input-based cache key calculation + self.file_metadata.push(FileMetadata { + archive_path: normalized_ap.clone(), + size: file_metadata.len(), + mtime: file_metadata + .modified() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0), + mode: file_mode, + is_dir: false, + }); + + if let Some(new_mode) = mode { + let mut header = tar::Header::new_gnu(); + header.set_metadata(&file_metadata); // Copies size, perms, mtime, etc. + header.set_mode(new_mode); // Override mode + header.set_mtime(0); // Consistent mtime for reproducibility if desired + header.set_uid(0); + header.set_gid(0); + + self.tar_writer + .append_data(&mut header, &normalized_ap, &mut f) + .map_err(|e| Error::Io { + source: e, + message: format!( + "Failed to append file {} (as {}) with custom mode to tar", + dp_ref.display(), + normalized_ap.display() + ), + })?; + } else { + self.tar_writer + .append_file(&normalized_ap, &mut f) + .map_err(|e| Error::Io { + source: e, + message: format!( + "Failed to append file {} (as {}) to tar", + dp_ref.display(), + normalized_ap.display() + ), + })?; + } + Ok(self) + } + + /// Adds data from an in-memory byte slice to the layer as a file. + /// + /// - `archive_path`: Path where the data will be stored within the layer's tar archive. + /// If absolute (e.g. "/foo/bar.txt"), it will be treated as relative to the layer root (e.g. "foo/bar.txt"). + /// An archive_path of "/" is invalid for data. + /// - `content`: The byte slice containing the file content. + /// - `mode`: The file mode (permissions) to set for the data in the tar archive. Defaults to `0o644` if `None`. + #[instrument(level = "info", skip(self, content), fields(archive_path = %archive_path.as_ref().display(), content_len = content.len(), mode))] + pub fn data( + mut self, + archive_path: impl AsRef, + content: &[u8], + mode: Option, + ) -> Result { + let ap_ref = archive_path.as_ref(); + info!( + "Adding data to layer. Original archive path: {}", + ap_ref.display() + ); + + let normalized_ap = normalize_archive_path(ap_ref); + debug!( + "Normalized archive path for data: {}", + normalized_ap.display() + ); + + if normalized_ap.as_os_str().is_empty() { + warn!("Attempted to add data with an empty archive path (derived from root '/')"); + return Err(Error::InvalidPath { + message: "Archive path for data cannot be the root directory ('/') or empty." + .to_string(), + }); + } + + self.ensure_parent_dirs_exist(&normalized_ap)?; + + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mtime(0); + header.set_uid(0); + header.set_gid(0); + header.set_mode(mode.unwrap_or(0o644)); + // `append_data` sets the path in the header and calculates checksum. + self.tar_writer + .append_data(&mut header, &normalized_ap, content) + .map_err(|e| { + warn!(error = %e, "Failed to append data to tar."); + Error::Io { + source: e, + message: format!( + "Failed to append data as {} to tar", + normalized_ap.display() + ), + } + })?; + + self.file_metadata.push(FileMetadata { + archive_path: normalized_ap, + size: content.len() as u64, + mtime: 0, + mode: mode.unwrap_or(0o644), + is_dir: false, + }); + + debug!("Successfully added data to layer."); + Ok(self) + } + + /// Finalizes the layer construction. + /// + /// This method completes the uncompressed tar archive, calculates its diff_id (SHA256 hash), + /// zstd-compresses the archive into a new temporary file, and returns the resulting `Layer`. + /// This is an asynchronous operation due to potentially blocking I/O for hashing and compression. + #[instrument(level = "info", skip_all, fields(uncompressed_tar_path = %self.uncompressed_tar_path.display()))] + pub async fn build(mut self) -> Result { + info!("Building layer."); + + // Get or create cache + let cache = match self.blob_cache.take() { + Some(c) => c, + None => blobcache::BlobCache::new()?, + }; + + // Try input-based cache key FIRST (before finalizing tar) + // This is much faster than hashing tar content for large binaries + let input_key = self.calculate_input_key(); + debug!(input_key = %input_key, "Calculated layer input key from file metadata"); + if let Some(cached_metadata) = cache.get_blob(&input_key).await? { + if let Ok(metadata_str) = std::str::from_utf8(&cached_metadata) { + if let Ok(metadata) = serde_json::from_str::(metadata_str) { + if let (Some(diff_id), Some(blob_digest)) = ( + metadata.get("diff_id").and_then(|v| v.as_str()), + metadata.get("blob_digest").and_then(|v| v.as_str()), + ) { + if let Some(compressed_data) = cache.get_blob(blob_digest).await? { + info!( + input_key = %input_key, + blob_digest = %blob_digest, + "Found layer in cache via input key, skipping tar finalization" + ); + + let compressed_layer_file = + NamedTempFile::new().map_err(|e| Error::Io { + source: e, + message: "Failed to create temporary file for cached layer" + .to_string(), + })?; + + tokio::fs::write(compressed_layer_file.path(), &compressed_data) + .await + .map_err(|e| Error::Io { + source: e, + message: "Failed to write cached layer to temp file" + .to_string(), + })?; + + return Ok(Layer { + diff_id: diff_id.to_string(), + blob_digest: blob_digest.to_string(), + media_type: IMAGE_LAYER_ZSTD_MEDIA_TYPE.to_string(), + compressed_layer_file, + }); + } + } + } + } + } + + // Input-based cache miss - finalize the tar and try content-based cache + eprintln!("[DEBUG] INPUT CACHE MISS for input_key={}", input_key); + debug!("Input-based cache miss, finalizing tar"); + + // Finalize the tar writer, ensuring all data is flushed and the file is closed. + let uncompressed_tar_file_writer = self.tar_writer.into_inner().map_err(|e| { + warn!(error = %e, "Failed to get underlying writer from tar builder."); + Error::Io { + source: e, + message: "Failed to get underlying writer from tar builder".to_string(), + } + })?; + + uncompressed_tar_file_writer.sync_all().map_err(|e| { + warn!(error = %e, "Failed to sync uncompressed tar file to disk."); + Error::Io { + source: e, + message: "Failed to sync uncompressed tar file to disk".to_string(), + } + })?; + drop(uncompressed_tar_file_writer); // Explicitly drop to release file handle before hashing/gzipping + debug!("Uncompressed tar finalized and synced."); + + // Calculate content-based cache key from the uncompressed tar + let content_key = Self::calculate_content_key(&self.uncompressed_tar_path)?; + debug!(content_key = %content_key, "Calculated layer content key"); + + // Check if we have a cached blob for this content + if let Some(cached_metadata) = cache.get_blob(&content_key).await? { + // Parse the cached metadata (it's a JSON string with diff_id and blob_digest) + if let Ok(metadata_str) = std::str::from_utf8(&cached_metadata) { + if let Ok(metadata) = serde_json::from_str::(metadata_str) { + if let (Some(diff_id), Some(blob_digest)) = ( + metadata.get("diff_id").and_then(|v| v.as_str()), + metadata.get("blob_digest").and_then(|v| v.as_str()), + ) { + // Try to get the actual compressed blob + if let Some(compressed_data) = cache.get_blob(blob_digest).await? { + info!( + content_key = %content_key, + blob_digest = %blob_digest, + "Found layer in cache, skipping compression" + ); + + // Write the cached data to a temp file + let compressed_layer_file = + NamedTempFile::new().map_err(|e| Error::Io { + source: e, + message: "Failed to create temporary file for cached layer" + .to_string(), + })?; + + tokio::fs::write(compressed_layer_file.path(), &compressed_data) + .await + .map_err(|e| Error::Io { + source: e, + message: "Failed to write cached layer to temp file" + .to_string(), + })?; + + return Ok(Layer { + diff_id: diff_id.to_string(), + blob_digest: blob_digest.to_string(), + media_type: IMAGE_LAYER_ZSTD_MEDIA_TYPE.to_string(), + compressed_layer_file, + }); + } + } + } + } + } + + info!("Layer not in cache, building from scratch"); + + // Calculate diff_id (SHA256 of the uncompressed tar file) + let path_for_hashing = self.uncompressed_tar_path.clone(); + info!(path = %path_for_hashing.display(), "Calculating diff_id (SHA256 of uncompressed tar)."); + let diff_id_hex = task::spawn_blocking(move || -> Result { + let mut file = File::open(&path_for_hashing).map_err(|e| { + warn!(path = %path_for_hashing.display(), error = %e, "Hashing: Failed to open uncompressed tar."); + Error::Io { + source: e, + message: format!( + "Hashing: Failed to open uncompressed tar {}", + path_for_hashing.display() + ), + } + })?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher).map_err(|e| { + warn!(path = %path_for_hashing.display(), error = %e, "Hashing: Failed to read uncompressed tar."); + Error::Io { + source: e, + message: format!( + "Hashing: Failed to read uncompressed tar {}", + path_for_hashing.display() + ), + } + })?; + let hash_bytes = hasher.finalize(); + Result::Ok(format!("{:x}", hash_bytes)) // Ensure this Ok is crate::error::Result + }) + .await + .map_err(|e| { + warn!(error = %e, "Task join error during hashing."); + Error::Join { // JoinError + source: e, + message: "Task join error during hashing".to_string(), + } + })??; // Outer Result for JoinError, inner Result for hashing logic + info!(diff_id = %format!("sha256:{}", diff_id_hex), "Calculated diff_id."); + + // Compress the uncompressed tar with zstd into a new temporary file + let compressed_layer_file = NamedTempFile::new().map_err(|e| { + warn!(error = %e, "Failed to create temporary file for compressed layer."); + Error::Io { + source: e, + message: "Failed to create temporary file for compressed layer".to_string(), + } + })?; + debug!(compressed_layer_path = %compressed_layer_file.path().display(), "Created temporary file for compressed layer."); + + let compressed_writer_file_reopened = compressed_layer_file.reopen().map_err(|e| { + warn!(error = %e, "Compression: Failed to reopen temporary file for zstd writer."); + Error::Io { + source: e, + message: "Compression: Failed to reopen temporary file for zstd writer".to_string(), + } + })?; + + let path_for_compressing = self.uncompressed_tar_path.clone(); // path to uncompressed tar + info!(source_path = %path_for_compressing.display(), dest_path = %compressed_layer_file.path().display(), "Compressing uncompressed tar with zstd."); + task::spawn_blocking(move || -> Result<()> { + let mut uncompressed_reader = + File::open(&path_for_compressing).map_err(|e| { + warn!(path = %path_for_compressing.display(), error = %e, "Compression: Failed to open uncompressed tar."); + Error::Io { + source: e, + message: format!( + "Compression: Failed to open uncompressed tar {}", + path_for_compressing.display() + ), + } + })?; + // Use zstd compression level 3 for good balance of speed and compression + let mut zstd_encoder = zstd::Encoder::new(compressed_writer_file_reopened, 3).map_err(|e| { + warn!(error = %e, "Failed to create zstd encoder."); + Error::Io { + source: e, + message: "Failed to create zstd encoder".to_string(), + } + })?; + io::copy(&mut uncompressed_reader, &mut zstd_encoder).map_err(|e| { + warn!(error = %e, "Failed to compress tar data with zstd."); + Error::Io { + source: e, + message: "Failed to compress tar data with zstd".to_string(), + } + })?; + zstd_encoder.finish().map_err(|e| { + warn!(error = %e, "Failed to finalize zstd stream."); + Error::Io { + source: e, + message: "Failed to finalize zstd stream".to_string(), + } + })?; + Result::Ok(()) + }) + .await + .map_err(|e| { + warn!(error = %e, "Task join error during compression."); + Error::Join { // JoinError + source: e, + message: "Task join error during compression".to_string(), + } + })??; + info!("Compression completed."); + + // Read the compressed file to calculate its digest and store in cache + info!(path = %compressed_layer_file.path().display(), "Reading compressed layer file for digest calculation and caching."); + let compressed_data = tokio::fs::read(compressed_layer_file.path()).await.map_err(|e| { + warn!(path = %compressed_layer_file.path().display(), error = %e, "Failed to read compressed layer file for caching."); + Error::Io { + message: format!("Failed to read compressed layer file for caching: {}", compressed_layer_file.path().display()), + source: e, + } + })?; + + let blob_digest_sha256 = { + let mut hasher = Sha256::new(); + hasher.update(&compressed_data); + format!("sha256:{:x}", hasher.finalize()) + }; + info!(blob_digest = %blob_digest_sha256, "Calculated blob digest for compressed layer."); + + // Put into blob cache + info!(blob_digest = %blob_digest_sha256, "Storing compressed layer in blob cache."); + cache + .put_blob(&blob_digest_sha256, &compressed_data) + .await?; + info!(blob_digest = %blob_digest_sha256, "Compressed layer stored in blob cache."); + + // Store metadata mapping from content_key to {diff_id, blob_digest} + // This allows future builds with the same content to skip compression + let metadata = serde_json::json!({ + "diff_id": format!("sha256:{}", diff_id_hex), + "blob_digest": blob_digest_sha256 + }); + let metadata_bytes = serde_json::to_vec(&metadata).map_err(|e| Error::Io { + source: std::io::Error::other(e), + message: "Failed to serialize layer metadata".to_string(), + })?; + cache.put_blob(&content_key, &metadata_bytes).await?; + info!(content_key = %content_key, "Stored layer metadata for future cache hits"); + + // Also store input-based key mapping for faster future cache lookups + // This allows future builds with the same input files to skip tar hashing entirely + cache.put_blob(&input_key, &metadata_bytes).await?; + info!(input_key = %input_key, "Stored layer metadata with input key for faster cache lookups"); + + info!(diff_id = %format!("sha256:{}", diff_id_hex), blob_digest = %blob_digest_sha256, "Layer built successfully."); + Ok(Layer { + diff_id: format!("sha256:{}", diff_id_hex), + blob_digest: blob_digest_sha256, + media_type: IMAGE_LAYER_ZSTD_MEDIA_TYPE.to_string(), + compressed_layer_file, + }) + } + + #[instrument(level = "debug", skip(self), fields(path_in_archive = %path_in_archive.as_ref().display()))] + fn ensure_parent_dirs_exist(&mut self, path_in_archive: impl AsRef) -> Result<()> { + let path_in_archive = path_in_archive.as_ref(); + if let Some(parent) = path_in_archive.parent() { + if parent.as_os_str().is_empty() { + return Ok(()); // No parent directory (e.g., file in root) + } + + let mut paths_to_create = Vec::new(); + let mut current_ancestor = parent; + // Collect all ancestor paths up to the root + loop { + if !current_ancestor.as_os_str().is_empty() { + paths_to_create.push(current_ancestor.to_path_buf()); + } + if let Some(p) = current_ancestor.parent() { + current_ancestor = p; + } else { + break; // Reached the top or an empty path + } + } + + // Create directories from top-level downwards (e.g., "app", then "app/foo") + for p_to_create in paths_to_create.iter().rev() { + if !self.created_archive_dirs.contains(p_to_create) { + debug!( + "Ensuring directory {} exists in archive layer", + p_to_create.display() + ); + let mut header = tar::Header::new_gnu(); + // tar paths should be relative and use '/' + header.set_path(p_to_create).map_err(|e| Error::Io { + message: format!( + "Failed to set path for directory header: {}", + p_to_create.display() + ), + source: e, + })?; + header.set_size(0); // Directories have zero size + header.set_mtime(0); // Consistent mtime, e.g., for reproducibility + header.set_uid(0); + header.set_gid(0); + header.set_mode(0o755); // Standard directory permissions + header.set_entry_type(tar::EntryType::Directory); + // Checksum is handled by append + + // Append the header with an empty data stream for a directory + let empty_data: &[u8] = &[]; + self.tar_writer + .append_data(&mut header, p_to_create, empty_data) + .map_err(|e| Error::Io { + message: format!( + "Failed to append directory header for: {}", + p_to_create.display() + ), + source: e, + })?; + self.created_archive_dirs.insert(p_to_create.clone()); + } + } + } + Ok(()) + } +} + +// Helper function to normalize paths intended for the archive +// Converts absolute paths like "/foo/bar" to relative "foo/bar". +// A path like "/" becomes an empty path, suitable for archive root in some tar operations. +fn normalize_archive_path(path_in_layer: &Path) -> PathBuf { + if path_in_layer.is_absolute() { + // .components() -> RootDir, "foo", "bar" + // .skip(1) -> "foo", "bar" + // .collect() -> "foo/bar" + // If path_in_layer is just "/", components().skip(1) is empty, collect() -> "" + path_in_layer.components().skip(1).collect::() + } else { + path_in_layer.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, io::Read as _}; + use tar::Archive; + use tempfile::tempdir; + + // Helper to extract tar content for verification + fn list_tar_contents(layer_path: &Path) -> Result> { + let compressed_file = File::open(layer_path).map_err(|e| Error::Io { + message: "failed to open layer".into(), + source: e, + })?; + let tar_reader = zstd::Decoder::new(compressed_file).map_err(|e| Error::Io { + message: "failed to create zstd decoder".into(), + source: e, + })?; + let mut archive = Archive::new(tar_reader); + let mut entries_found = std::collections::HashMap::new(); + + for entry_result in archive.entries().map_err(|e| Error::Io { + message: "failed to get entries".into(), + source: e, + })? { + let mut entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".into(), + source: e, + })?; + let mut path_str = entry + .path() + .map_err(|e| Error::Io { + message: "failed to get path".into(), + source: e, + })? + .to_string_lossy() + .into_owned(); + let mut content_str = String::new(); + let is_dir = entry.header().entry_type().is_dir(); + + if is_dir { + if !path_str.ends_with("/") { + path_str.push('/'); + } + } else { + entry + .read_to_string(&mut content_str) + .map_err(|e| Error::Io { + message: "failed to read to string".into(), + source: e, + })?; + } + entries_found.insert(path_str, content_str); + } + Ok(entries_found) + } + + #[tokio::test] + async fn test_layer_creation_empty() -> Result<()> { + let layer = Layer::builder()?.build().await?; + assert!(layer.diff_id().starts_with("sha256:")); + assert!(layer.path().exists()); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_data_absolute_path() -> Result<()> { + let content = b"hello absolute world"; + let layer = Layer::builder()? + .data("/app/hello_abs.txt", content, None)? + .build() + .await?; + + let entries = list_tar_contents(layer.path())?; + assert_eq!( + entries.get("app/hello_abs.txt").unwrap(), + "hello absolute world" + ); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_data_relative_path() -> Result<()> { + let content = b"hello relative world"; + let layer = Layer::builder()? + .data("app/hello_rel.txt", content, None)? + .build() + .await?; + + let entries = list_tar_contents(layer.path())?; + assert_eq!( + entries.get("app/hello_rel.txt").unwrap(), + "hello relative world" + ); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_data_root_path_error() -> Result<()> { + let builder = Layer::builder()?; + let result_of_data_call = builder.data("/", b"root data", None); // No '?' here, check this call's result + + assert!( + result_of_data_call.is_err(), + "Expected .data(\"/\", ...) to fail for root path data" + ); + match result_of_data_call { + Err(Error::InvalidPath { message }) => { + assert!(message.contains("Archive path for data cannot be the root directory")); + } + Err(other_error) => { + panic!( + "Expected Error::InvalidPath from .data(), but got {:?}", + other_error + ); + } + Ok(_) => { + panic!("Expected .data(\"/\", ...) to fail, but it succeeded building a LayerBuilder instance."); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_file_absolute_path() -> Result<()> { + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + let source_file_disk = temp_root.path().join("source_abs.txt"); + fs::write(&source_file_disk, "absolute file content").map_err(|e| Error::Io { + message: "failed to write to source_abs.txt".to_string(), + source: e, + })?; + + let layer = Layer::builder()? + .file(&source_file_disk, "/app/file_abs.txt", None)? + .build() + .await?; + + let entries = list_tar_contents(layer.path())?; + assert_eq!( + entries.get("app/file_abs.txt").unwrap(), + "absolute file content" + ); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_file_root_path_error() -> Result<()> { + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + let source_file_disk = temp_root.path().join("source_root.txt"); + fs::write(&source_file_disk, "root file content").map_err(|e| Error::Io { + message: "failed to write to source_root.txt".to_string(), + source: e, + })?; + + let builder = Layer::builder()?; + let result_of_file_call = builder.file(&source_file_disk, "/", None); // No '?' here, check this call's result + + assert!( + result_of_file_call.is_err(), + "Expected .file(\"/\", ...) to fail for root path file" + ); + match result_of_file_call { + Err(Error::InvalidPath { message }) => { + assert!(message.contains("Archive path for file")); + } + Err(other_error) => { + panic!( + "Expected Error::InvalidPath from .file(), but got {:?}", + other_error + ); + } + Ok(_) => { + panic!("Expected .file(\"/\", ...) to fail, but it succeeded building a LayerBuilder instance."); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_directory_absolute_path() -> Result<()> { + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + let source_dir_disk = temp_root.path().join("my_dir_abs"); + fs::create_dir_all(source_dir_disk.join("sub")).map_err(|e| Error::Io { + message: "failed to create_dir_all".to_string(), + source: e, + })?; + fs::write( + source_dir_disk.join("sub/file_in_dir.txt"), + "dir content abs", + ) + .map_err(|e| Error::Io { + message: "failed to write to file_in_dir.txt".to_string(), + source: e, + })?; + + let layer = Layer::builder()? + .directory(&source_dir_disk, "/archived_dir_abs")? + .build() + .await?; + + let entries = list_tar_contents(layer.path())?; + assert!(entries.contains_key("archived_dir_abs/sub/")); + assert_eq!( + entries.get("archived_dir_abs/sub/file_in_dir.txt").unwrap(), + "dir content abs" + ); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_directory_root_path() -> Result<()> { + // Adding a directory with archive_path "/" means its *contents* go to the layer's root. + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + let source_dir_disk = temp_root.path().join("my_dir_for_root"); + fs::create_dir_all(source_dir_disk.join("sub_at_root")).map_err(|e| Error::Io { + message: "failed to create_dir_all".to_string(), + source: e, + })?; + fs::write(source_dir_disk.join("file_at_root.txt"), "root dir content").map_err(|e| { + Error::Io { + message: "failed to write to file_at_root.txt".to_string(), + source: e, + } + })?; + fs::write( + source_dir_disk.join("sub_at_root/nested.txt"), + "nested root", + ) + .map_err(|e| Error::Io { + message: "failed to write to nested.txt".to_string(), + source: e, + })?; + + let layer = Layer::builder()? + .directory(&source_dir_disk, "/")? // Contents of my_dir_for_root should be at archive root + .build() + .await?; + + let entries = list_tar_contents(layer.path())?; + assert!(entries.contains_key("sub_at_root/")); + assert_eq!(entries.get("file_at_root.txt").unwrap(), "root dir content"); + assert_eq!( + entries.get("sub_at_root/nested.txt").unwrap(), + "nested root" + ); + // The directory "my_dir_for_root" itself should not appear in the path. + assert!(!entries.contains_key(&format!( + "{}/file_at_root.txt", + source_dir_disk.file_name().unwrap().to_string_lossy() + ))); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_data() -> Result<()> { + let content = b"hello world"; + let layer = Layer::builder()? + .data("hello.txt", content, Some(0o755))? + .build() + .await?; + + assert!(layer.diff_id().starts_with("sha256:")); + assert!(layer.path().exists()); + + // Verify tar content + let compressed_file = File::open(layer.path()).map_err(|e| Error::Io { + message: "failed to open layer path".to_string(), + source: e, + })?; + let tar_reader = zstd::Decoder::new(compressed_file).map_err(|e| Error::Io { + message: "failed to create zstd decoder".to_string(), + source: e, + })?; + let mut archive = Archive::new(tar_reader); + let mut found = false; + for entry_result in archive.entries().map_err(|e| Error::Io { + message: "failed to get archive entries".to_string(), + source: e, + })? { + let mut entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".to_string(), + source: e, + })?; + if entry + .path() + .map_err(|e| Error::Io { + message: "failed to get entry path".to_string(), + source: e, + })? + .to_string_lossy() + == "hello.txt" + { + let mut s = String::new(); + entry.read_to_string(&mut s).map_err(|e| Error::Io { + message: "failed to read entry to string".to_string(), + source: e, + })?; + assert_eq!(s, "hello world"); + found = true; + break; + } + } + assert!(found, "File 'hello.txt' not found in layer"); + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_file_and_dir() -> Result<()> { + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + + // Create a directory on disk to add + let source_dir_disk = temp_root.path().join("mydir_on_disk"); + fs::create_dir(&source_dir_disk).map_err(|e| Error::Io { + message: "failed to create source_dir_disk".to_string(), + source: e, + })?; + fs::write(source_dir_disk.join("file1.txt"), "content1").map_err(|e| Error::Io { + message: "failed to write to file1.txt".to_string(), + source: e, + })?; + fs::write(source_dir_disk.join("file2.txt"), "content2").map_err(|e| Error::Io { + message: "failed to write to file2.txt".to_string(), + source: e, + })?; + + // Add a subdirectory with a file to test recursion + let sub_dir_disk = source_dir_disk.join("my_subdir"); + fs::create_dir(&sub_dir_disk).map_err(|e| Error::Io { + message: "failed to create sub_dir_disk".to_string(), + source: e, + })?; + fs::write(sub_dir_disk.join("nested_file.txt"), "nested_content").map_err(|e| { + Error::Io { + message: "failed to write to nested_file.txt".to_string(), + source: e, + } + })?; + + // Create an individual file on disk to add + let source_file_disk = temp_root.path().join("individual_on_disk.txt"); + fs::write(&source_file_disk, "individual_content").map_err(|e| Error::Io { + message: "failed to write to individual_on_disk.txt".to_string(), + source: e, + })?; + + let layer = Layer::builder()? + .directory(&source_dir_disk, "archived_dir")? // Add dir to "archived_dir/" in layer + .file( + &source_file_disk, + "archived_dir/renamed_individual.txt", + None, + )? // Add file into "archived_dir/" + .data("root_file.txt", b"root data", None)? // Add data to root with default mode + .build() + .await?; + + assert!(layer.diff_id().starts_with("sha256:")); + assert!(layer.path().exists()); + + // Verify tar content + let compressed_file = File::open(layer.path()).map_err(|e| Error::Io { + message: "failed to open layer path".to_string(), + source: e, + })?; + let tar_reader = zstd::Decoder::new(compressed_file).map_err(|e| Error::Io { + message: "failed to create zstd decoder".to_string(), + source: e, + })?; + let mut archive = Archive::new(tar_reader); + let mut entries_found = std::collections::HashMap::new(); + + for entry_result in archive.entries().map_err(|e| Error::Io { + message: "failed to get archive entries".to_string(), + source: e, + })? { + let mut entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".to_string(), + source: e, + })?; + let path_str = entry + .path() + .map_err(|e| Error::Io { + message: "failed to get entry path".to_string(), + source: e, + })? + .to_string_lossy() + .into_owned(); + let mut content = String::new(); + if !entry.header().entry_type().is_dir() { + // Only read content for files + entry.read_to_string(&mut content).map_err(|e| Error::Io { + message: "failed to read entry to string".to_string(), + source: e, + })?; + } + entries_found.insert(path_str, content); + } + + assert_eq!( + entries_found.get("archived_dir/file1.txt").unwrap(), + "content1" + ); + assert_eq!( + entries_found.get("archived_dir/file2.txt").unwrap(), + "content2" + ); + assert_eq!( + entries_found + .get("archived_dir/renamed_individual.txt") + .unwrap(), + "individual_content" + ); + assert_eq!(entries_found.get("root_file.txt").unwrap(), "root data"); + assert!(entries_found.contains_key("archived_dir/")); // Check if directory entry itself exists + assert_eq!( + entries_found + .get("archived_dir/my_subdir/nested_file.txt") + .unwrap(), + "nested_content" + ); + + // New assertion for the subdirectory entry + let mut subdir_entry_found_in_archive = false; + // Re-open archive for a fresh iteration if entries() consumes it, or clone if possible. + // For simplicity, let's assume we can re-iterate or the previous loop collected all paths. + // If not, this test needs restructuring to iterate once and check all conditions. + // Given the test structure, it's better to check entries_found map directly. + + let subdir_path1 = "archived_dir/my_subdir"; + let subdir_path2 = "archived_dir/my_subdir/"; // Check with trailing slash as well + + if let Some(content) = entries_found.get(subdir_path1) { + if content.is_empty() { + // Directories usually have empty content string in this map setup + // To be more robust, we'd need to store EntryType in the map or re-iterate the archive. + // For now, assume if the key exists and content is empty, it might be a dir. + // This isn't a perfect check for it being a directory based on entries_found alone. + // The original test was better by iterating the archive directly for this check. + // Let's revert to a direct archive iteration for this specific check for robustness. + let compressed_file_recheck = File::open(layer.path()).map_err(|e| Error::Io { + message: "failed to open layer path".to_string(), + source: e, + })?; + let tar_reader_recheck = + zstd::Decoder::new(compressed_file_recheck).map_err(|e| Error::Io { + message: "failed to create zstd decoder".to_string(), + source: e, + })?; + let mut archive_recheck = Archive::new(tar_reader_recheck); + for entry_result in archive_recheck.entries().map_err(|e| Error::Io { + message: "failed to get archive entries".to_string(), + source: e, + })? { + let entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".to_string(), + source: e, + })?; + let path_cow = entry.path().map_err(|e| Error::Io { + message: "failed to get entry path".to_string(), + source: e, + })?; + if (path_cow == Path::new(subdir_path1) || path_cow == Path::new(subdir_path2)) + && entry.header().entry_type().is_dir() + { + subdir_entry_found_in_archive = true; + break; + } + } + } + } else if let Some(content) = entries_found.get(subdir_path2) { + if content.is_empty() { + let compressed_file_recheck = File::open(layer.path()).map_err(|e| Error::Io { + message: "failed to open layer path".to_string(), + source: e, + })?; + let tar_reader_recheck = + zstd::Decoder::new(compressed_file_recheck).map_err(|e| Error::Io { + message: "failed to create zstd decoder".to_string(), + source: e, + })?; + let mut archive_recheck = Archive::new(tar_reader_recheck); + for entry_result in archive_recheck.entries().map_err(|e| Error::Io { + message: "failed to get archive entries".to_string(), + source: e, + })? { + let entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".to_string(), + source: e, + })?; + let path_cow = entry.path().map_err(|e| Error::Io { + message: "failed to get entry path".to_string(), + source: e, + })?; + if (path_cow == Path::new(subdir_path1) || path_cow == Path::new(subdir_path2)) + && entry.header().entry_type().is_dir() + { + subdir_entry_found_in_archive = true; + break; + } + } + } + } + assert!( + subdir_entry_found_in_archive, + "Directory entry 'archived_dir/my_subdir[/]' not found or not a directory" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_compressed_temp_file_deleted_on_drop() -> Result<()> { + let layer_path_before_drop; + { + let layer = Layer::builder()?.build().await?; + layer_path_before_drop = layer.path().to_path_buf(); + assert!( + layer_path_before_drop.exists(), + "Compressed temp file should exist while Layer is in scope" + ); + } // Layer is dropped here + + assert!( + !layer_path_before_drop.exists(), + "Compressed temp file should be deleted after Layer is dropped" + ); + Ok(()) + } + + #[tokio::test] + async fn test_directory_to_archive_root() -> Result<()> { + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + + // 1. Create a source directory on disk with some files + let source_dir_on_disk = temp_root.path().join("my_app_files"); + fs::create_dir(&source_dir_on_disk).map_err(|e| Error::Io { + message: "failed to create source_dir_on_disk".to_string(), + source: e, + })?; + fs::write(source_dir_on_disk.join("app.js"), "console.log('app');").map_err(|e| { + Error::Io { + message: "failed to write to app.js".to_string(), + source: e, + } + })?; + fs::write( + source_dir_on_disk.join("styles.css"), + "body { color: blue; }", + ) + .map_err(|e| Error::Io { + message: "failed to write to styles.css".to_string(), + source: e, + })?; + + // 2. Build the layer, copying the directory to the archive root "." + let layer = Layer::builder()? + .directory(&source_dir_on_disk, ".")? + .build() + .await?; + + assert!(layer.diff_id().starts_with("sha256:")); + assert!(layer.path().exists()); + + // 3. Verify tar content + let compressed_file = File::open(layer.path()).map_err(|e| Error::Io { + message: "failed to open layer path".to_string(), + source: e, + })?; + let tar_reader = zstd::Decoder::new(compressed_file).map_err(|e| Error::Io { + message: "failed to create zstd decoder".to_string(), + source: e, + })?; + let mut archive = Archive::new(tar_reader); + let mut entries_found = std::collections::HashMap::new(); + + for entry_result in archive.entries().map_err(|e| Error::Io { + message: "failed to get archive entries".to_string(), + source: e, + })? { + let mut entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".to_string(), + source: e, + })?; + let path_str = entry + .path() + .map_err(|e| Error::Io { + message: "failed to get entry path".to_string(), + source: e, + })? + .to_string_lossy() + .into_owned(); + let mut content = String::new(); + if !entry.header().entry_type().is_dir() { + entry.read_to_string(&mut content).map_err(|e| Error::Io { + message: "failed to read entry to string".to_string(), + source: e, + })?; + } + entries_found.insert(path_str, content); + } + + // Check that files are at the root + assert_eq!(entries_found.get("app.js").unwrap(), "console.log('app');"); + assert_eq!( + entries_found.get("styles.css").unwrap(), + "body { color: blue; }" + ); + + // Check that the source directory name itself is NOT part of the path in the archive + assert!( + !entries_found.contains_key("my_app_files/app.js"), + "Path should not include source dir name" + ); + assert!( + !entries_found.contains_key("my_app_files/"), + "Source directory itself should not be an entry when copied to root" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_layer_with_file_mode_override() -> Result<()> { + let temp_root = tempdir().map_err(|e| Error::Io { + message: "failed to create tempdir".to_string(), + source: e, + })?; + let source_file_disk = temp_root.path().join("test_mode.txt"); + fs::write(&source_file_disk, "mode test content").map_err(|e| Error::Io { + message: "failed to write to test_mode.txt".to_string(), + source: e, + })?; + + // Ensure original file has known, non-execute permissions for the test + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&source_file_disk) + .map_err(|e| Error::Io { + message: "failed to get metadata".to_string(), + source: e, + })? + .permissions(); + perms.set_mode(0o644); // rw-r--r-- + fs::set_permissions(&source_file_disk, perms).map_err(|e| Error::Io { + message: "failed to set permissions".to_string(), + source: e, + })?; + } + // On non-unix, we rely on default file creation masks, the key is that it's unlikely to be 0o755 by default. + + let layer = Layer::builder()? + .file(&source_file_disk, "app/executable.txt", Some(0o755))? // Override to rwxr-xr-x + .file(&source_file_disk, "app/normal.txt", None)? // Keep original (0o644) + .build() + .await?; + + let compressed_file = File::open(layer.path()).map_err(|e| Error::Io { + message: "failed to open layer path".to_string(), + source: e, + })?; + let tar_reader = zstd::Decoder::new(compressed_file).map_err(|e| Error::Io { + message: "failed to create zstd decoder".to_string(), + source: e, + })?; + let mut archive = Archive::new(tar_reader); + let mut modes = std::collections::HashMap::new(); + + for entry_result in archive.entries().map_err(|e| Error::Io { + message: "failed to get archive entries".to_string(), + source: e, + })? { + let entry = entry_result.map_err(|e| Error::Io { + message: "failed to get entry".to_string(), + source: e, + })?; + let path_str = entry + .path() + .map_err(|e| Error::Io { + message: "failed to get entry path".to_string(), + source: e, + })? + .to_string_lossy() + .into_owned(); + if path_str == "app/executable.txt" || path_str == "app/normal.txt" { + modes.insert( + path_str, + entry.header().mode().map_err(|e| Error::Io { + message: "failed to get mode".to_string(), + source: e, + })?, + ); + } + } + + assert_eq!( + modes.get("app/executable.txt").unwrap(), + &0o755, + "Mode for executable.txt should be overridden to 0o755" + ); + // We set it to 0o644 explicitly above on Unix. + // On non-Unix, the original permissions might vary, but the key is that it should NOT be 0o755 from the override. + // For consistency and to make the assert more robust, let's check it's not 0o755 if not unix, + // and specifically 0o644 if unix. + if cfg!(unix) { + assert_eq!( + modes.get("app/normal.txt").unwrap() & 0o777, + 0o644, + "Mode for normal.txt should be original 0o644 on unix (permission bits)" + ); + } else { + assert_ne!( + modes.get("app/normal.txt").unwrap(), + &0o755, + "Mode for normal.txt should not be 0o755 on non-unix (was not overridden)" + ); + } + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..14a2843 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,52 @@ +//! # Dockdash +//! +//! Build and push OCI container images from Rust — no Docker daemon required. +//! +//! Dockdash lets you programmatically create OCI-compliant container images, +//! add layers from files or raw bytes, and push them to any OCI registry. +//! It works anywhere Rust runs: CI pipelines, serverless functions, CLIs, or +//! embedded tooling. +//! +//! ## Quick Start +//! +//! ```no_run +//! use dockdash::{Arch, Image, Layer, PushOptions, Result}; +//! use std::path::PathBuf; +//! +//! # async fn example() -> Result<()> { +//! let layer = Layer::builder()? +//! .file("./my-binary", "./app", Some(0o755))? +//! .build() +//! .await?; +//! +//! let (image, _) = Image::builder() +//! .from("ubuntu:latest") +//! .platform("linux", &Arch::ARM64) +//! .layer(layer) +//! .entrypoint(vec!["/app".to_string()]) +//! .build() +//! .await?; +//! +//! image.push("registry.example.com/my-app:latest", &PushOptions::default()).await?; +//! # Ok(()) +//! # } +//! ``` + +mod error; +pub use error::*; + +mod layer; +pub use layer::*; + +mod image; +pub use image::*; + +mod blobcache; +pub use blobcache::*; + +pub use oci_client::client::ClientProtocol; +pub use oci_client::secrets::RegistryAuth; +pub use oci_spec::image::Arch; + +#[cfg(feature = "test-utils")] +pub mod test_utils; diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 0000000..52bb887 --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,29 @@ +use crate::{ClientProtocol, PushOptions, RegistryAuth, Result}; +use container_registry::test_support::RunningRegistry; +use container_registry::ContainerRegistry; +use tracing::info; + +// Helper to set up and run a local container registry +// Returns the running registry guard and the host string (e.g., "localhost:12345") +pub async fn setup_local_registry() -> Result<(RunningRegistry, String)> { + info!("Setting up and running local container registry in background..."); + let running_registry = ContainerRegistry::builder() + .build_for_testing() + .run_in_background(); + let local_registry_addr = running_registry.bound_addr(); + let local_registry_host = format!("localhost:{}", local_registry_addr.port()); + info!( + "Local container registry listening on: {}", + local_registry_host + ); + Ok((running_registry, local_registry_host)) +} + +/// Returns default PushOptions for interacting with the local test registry. +pub fn test_push_options() -> PushOptions { + PushOptions { + auth: RegistryAuth::Anonymous, + protocol: ClientProtocol::Http, + ..Default::default() + } +} diff --git a/tests/build_push_tests.rs b/tests/build_push_tests.rs new file mode 100644 index 0000000..0a32595 --- /dev/null +++ b/tests/build_push_tests.rs @@ -0,0 +1,1296 @@ +use bollard::container::{ + CreateContainerOptions, LogOutput, LogsOptions, RemoveContainerOptions, StartContainerOptions, + WaitContainerOptions, +}; +use bollard::image::CreateImageOptions; +use bollard::Docker; +use container_registry::ContainerRegistry; +use dockdash::{ + Arch, BlobCache, ClientProtocol, Image, Layer, ManifestSource, PullPolicy, PushOptions, + RegistryAuth, +}; +use dockdash::{Error, Result}; +use futures_util::stream::StreamExt; +use futures_util::TryStreamExt; +use rand::distr::Alphanumeric; +use rand::{rng, Rng}; +use sec::Secret; +use std::collections::HashMap; +use std::default::Default; +use std::sync::Arc; +use tempfile::{tempdir, TempDir}; +use tracing::{debug, error, info}; + +use std::sync::Once; + +use dockdash::test_utils; + +static TEST_SETUP: Once = Once::new(); + +// Setup function for test environment +fn setup_test_environment() { + TEST_SETUP.call_once(|| { + let _ = tracing_subscriber::fmt().try_init(); + info!("Test environment setup complete."); + }); +} + +// Helper to set up a temporary blob cache +fn setup_blob_cache() -> Result<(BlobCache, TempDir)> { + let temp_cache_dir = tempdir().map_err(|e| Error::Io { + message: e.to_string(), + source: e, + })?; + info!( + "Using temporary blob cache for test at: {}", + temp_cache_dir.path().display() + ); + let shared_blob_cache = BlobCache::with_path(temp_cache_dir.path().to_path_buf())?; + Ok((shared_blob_cache, temp_cache_dir)) +} + +// Helper to pull an image using Bollard +async fn pull_image_with_bollard( + docker: &Docker, + local_registry_host: &str, + target_image_repo_namespaced: &str, + image_tag: &str, +) -> Result<()> { + info!( + "Pulling image {}/{}_tag:{} using Bollard...", + local_registry_host, target_image_repo_namespaced, image_tag + ); + + let registry_creds = bollard::auth::DockerCredentials { + serveraddress: Some(format!("http://{}", local_registry_host)), + ..Default::default() + }; + + // Construct the correct from_image for Bollard, including the registry host + let bollard_from_image = format!("{}/{}", local_registry_host, target_image_repo_namespaced); + + let mut pull_stream = docker.create_image( + Some(CreateImageOptions { + from_image: bollard_from_image, + tag: image_tag.to_string(), + ..Default::default() + }), + None, + Some(registry_creds), + ); + + while let Some(pull_result) = pull_stream.next().await { + match pull_result { + Ok(info_msg) => { + if let Some(status) = info_msg.status { + debug!("Pull status: {}", status); + } + if let Some(progress) = info_msg.progress { + debug!("Pull progress: {}", progress); + } + } + Err(e) => { + error!("Error during image pull: {:?}", e); + return Err(Error::ImagePull { + image_ref: format!("{}/{}", local_registry_host, target_image_repo_namespaced), + message: format!("Bollard image pull failed for tag {}: {}", image_tag, e), + source: Some(Box::new(e)), + }); + } + } + } + info!( + "Image {}/{}:{} pulled successfully via Bollard.", + local_registry_host, target_image_repo_namespaced, image_tag + ); + Ok(()) +} + +// Guard for managing Bollard container lifecycle +struct BollardContainerGuard<'a> { + docker: &'a Docker, + id: String, + name: String, +} + +impl<'a> BollardContainerGuard<'a> { + async fn new( + docker: &'a Docker, + image_ref: &str, + container_name: String, + platform_arch: &str, + env_param: Option>, + override_entrypoint: Option>, + ) -> Result { + info!( + "Creating container '{}' from image '{}' with env: {:?}, entrypoint override: {:?}", + container_name, image_ref, env_param, override_entrypoint + ); + + let processed_env_for_config: Option> = + env_param.as_ref().map(|actual_env_vec_string| { + actual_env_vec_string + .iter() + .map(|s_string| s_string.as_str()) + .collect::>() + }); + + let processed_entrypoint_for_config: Option> = + override_entrypoint + .as_ref() + .map(|actual_entrypoint_vec_string| { + actual_entrypoint_vec_string + .iter() + .map(|s_string| s_string.as_str()) + .collect::>() + }); + + let container_config = bollard::container::Config { + image: Some(image_ref), + tty: Some(true), + env: processed_env_for_config, + entrypoint: processed_entrypoint_for_config, + ..Default::default() + }; + + let create_options = Some(CreateContainerOptions { + name: container_name.clone(), + platform: Some(platform_arch.to_string()), + }); + + let response = docker + .create_container(create_options, container_config) + .await + .map_err(|e| Error::Generic { + message: format!("Failed to create container '{}': {}", container_name, e), + source: Some(Box::new(e)), + })?; + let container_id_str = response.id; + info!( + "Container '{}' created with ID: {}", + container_name, container_id_str + ); + + info!("Starting container ID: {}...", container_id_str); + docker + .start_container(&container_id_str, None::>) + .await + .map_err(|e| Error::Generic { + message: format!("Failed to start container '{}': {}", container_id_str, e), + source: Some(Box::new(e)), + })?; + info!("Container {} started.", container_id_str); + + Ok(Self { + docker, + id: container_id_str, + name: container_name, + }) + } + + fn id(&self) -> &str { + &self.id + } + + #[allow(dead_code)] // Potentially useful for other tests + fn name(&self) -> &str { + &self.name + } + + async fn logs(&self) -> Result> { + info!("Fetching logs for container {}...", self.id); + let logs_options = Some(LogsOptions:: { + follow: true, // Set to false if you only want logs up to current point + stdout: true, + stderr: true, + timestamps: false, + ..Default::default() + }); + + let mut log_stream = self.docker.logs(&self.id, logs_options); + let mut log_lines: Vec = Vec::new(); + + // If follow is true, this might block indefinitely if the container keeps running. + // For a test that expects termination, this should be fine. + // Or set follow: false and call logs after wait_for_completion. + // For this specific test, the container echoes and exits, so follow: true is okay. + while let Some(log_entry_result) = log_stream.next().await { + match log_entry_result { + Ok(log_entry) => { + let log_message = match log_entry { + LogOutput::StdOut { message } => { + String::from_utf8_lossy(&message).to_string() + } + LogOutput::StdErr { message } => { + String::from_utf8_lossy(&message).to_string() + } + LogOutput::Console { message } => { + String::from_utf8_lossy(&message).to_string() + } + _ => continue, + }; + info!("Log: {}", log_message.trim()); + log_lines.push(log_message.trim().to_string()); + } + Err(e) => { + error!("Error streaming logs: {:?}", e); + // Decide if to return error or just log + } + } + } + info!("Finished fetching logs for container {}.", self.id); + Ok(log_lines) + } + + async fn wait_for_completion(&self) -> Result<()> { + info!("Waiting for container {} to complete...", self.id); + let wait_options = Some(WaitContainerOptions { + condition: "not-running", + }); + let mut wait_stream = self.docker.wait_container(&self.id, wait_options); + let wait_result = wait_stream.try_next().await.map_err(|e| Error::Generic { + message: format!("Failed to wait for container '{}': {}", self.id, e), + source: Some(Box::new(e)), + })?; + + if let Some(response) = wait_result { + info!( + "Container {} exited with status code: {}", + self.id, response.status_code + ); + assert_eq!( + response.status_code, 0, + "Container did not exit cleanly. Logs might provide more details." + ); + if let Some(error) = response.error { + error!("Container exit error: {:?}", error.message); + return Err(Error::Generic { + message: format!( + "Container exited with error: {}", + error.message.unwrap_or_else(|| "Unknown error".to_string()) + ), + source: None, + }); + } + } else { + return Err(Error::Generic { + message: format!("Did not receive container exit status for {}.", self.id), + source: None, + }); + } + info!("Container {} completed.", self.id); + Ok(()) + } + + async fn cleanup(self) -> Result<()> { + info!("Removing container {} (ID: {})...", self.name, self.id); + self.docker + .remove_container( + &self.id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + .map_err(|e| Error::Generic { + message: format!("Failed to remove container '{}': {}", self.id, e), + source: Some(Box::new(e)), + })?; + info!("Container {} (ID: {}) removed.", self.name, self.id); + Ok(()) + } +} + +/// Tests the basic end-to-end flow of building an image with a single layer, +/// pushing it to a local registry, pulling it back using Bollard, running it as a container, +/// and verifying its output logs. +#[tokio::test] +async fn test_build_push_pull_run_image() -> Result<()> { + setup_test_environment(); + + // 1. Setup local container registry using the utility + let (_running_registry, local_registry_host) = test_utils::setup_local_registry().await?; + // _running_registry is kept to ensure the registry runs for the duration of the test + + // Setup image naming + let unique_repo_name = generate_unique_image_name(); + let image_tag = "latest"; + let image_namespace = "testns"; + let target_image_repo_namespaced = format!("{}/{}", image_namespace, unique_repo_name); + let target_image_ref = format!( + "{}/{}:{}", + local_registry_host, target_image_repo_namespaced, image_tag + ); + info!("Target image for test: {}", target_image_ref); + + // Setup custom BlobCache for this test + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + // _temp_cache_dir is kept to ensure the cache directory exists for the duration of the test + + // 2. Create a simple layer + let layer_content = "Hello from Dockdash!"; + let layer_file_path_in_container = "/app/hello.txt"; + + info!("Creating layer with content: '{}'", layer_content); + let layer = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(layer_file_path_in_container, layer_content.as_bytes(), None)? + .build() + .await?; + info!( + "Layer created successfully. Diff ID: {}, Blob Digest: {}", + layer.diff_id(), + layer.blob_digest() + ); + + // 3. Build the image + let base_image = + "ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55"; + let platform_os = "linux"; + let platform_arch = Arch::ARM64; + let entrypoint_cmd = format!( + "echo 'Container says hi!' && cat {} && echo 'Container finished.'", + layer_file_path_in_container + ); + let entrypoint = vec!["/bin/sh".to_string(), "-c".to_string(), entrypoint_cmd]; + + info!( + "Building image from base '{}' with entrypoint: {:?}", + base_image, entrypoint + ); + let (image, _) = Image::builder() + .from(base_image) + .platform(platform_os, &platform_arch) + .layer(layer) + .entrypoint(entrypoint.clone()) + .blob_cache(shared_blob_cache) + .build() + .await?; + info!( + "Image built successfully. OCI Archive Path: {:?}, Config Digest: {}", + image.path(), + image.config_digest() + ); + + // 4. Push the image to local registry + info!("Pushing image to {}...", target_image_ref); + let push_opts = test_utils::test_push_options(); + let pushed_image_ref = image.push(&target_image_ref, &push_opts).await?; + assert_eq!(pushed_image_ref, target_image_ref); + info!("Image pushed successfully to: {}", pushed_image_ref); + + // 5. Bollard: Connect to Docker + info!("Connecting to Docker daemon via Bollard..."); + let docker = Docker::connect_with_local_defaults().map_err(|e| Error::Generic { + message: format!("Failed to connect to Docker: {}", e), + source: Some(Box::new(e)), + })?; + info!( + "Connected to Docker version: {:?}", + docker.version().await.map_err(|e| Error::Generic { + message: format!("Failed to get Docker version: {}", e), + source: Some(Box::new(e)) + })? + ); + + // 6. Bollard: Pull the image + pull_image_with_bollard( + &docker, + &local_registry_host, + &target_image_repo_namespaced, + image_tag, + ) + .await?; + + // 7. Bollard: Create, start, and manage the container + let container_name = format!("{}-container", unique_repo_name); + let container_guard = BollardContainerGuard::new( + &docker, + &target_image_ref, // Use the full image ref for creating container + container_name, + &platform_arch.to_string(), + None, // Add None for env + None, // Add None for override_entrypoint + ) + .await?; + + // 8. Fetch logs & verify + let log_lines = container_guard.logs().await?; + + // 9. Bollard: Wait for container to complete + container_guard.wait_for_completion().await?; + + // 10. Verify logs + let full_logs = log_lines.join("\n"); + info!("Collected logs:\n{}", full_logs); + + assert!( + full_logs.contains("Container says hi!"), + "Log verification failed: 'Container says hi!' not found. Logs: {}", + full_logs + ); + assert!( + full_logs.contains(layer_content), + "Log verification failed: Layer content '{}' not found. Logs: {}", + layer_content, + full_logs + ); + assert!( + full_logs.contains("Container finished."), + "Log verification failed: 'Container finished.' not found. Logs: {}", + full_logs + ); + info!("Log content verified successfully."); + + // 11. Bollard: Clean up - remove container (handled by guard's cleanup) + container_guard.cleanup().await?; + + // _running_registry and _temp_cache_dir will be dropped here, + // shutting down the server and cleaning up temp storage. + info!("Local container registry server will shut down and temp cache dir will be removed as guards are dropped."); + + info!("Integration test completed successfully!"); + Ok(()) +} + +/// Tests a scenario where an image is pushed that shares one layer with a previously pushed image. +/// It verifies that the shared layer is effectively mounted (not re-uploaded) by the registry +/// and that the final image contains the correct combination of shared and new layers. +#[tokio::test] +async fn test_push_with_partial_existing_layers() -> Result<()> { + setup_test_environment(); + info!("Starting test_push_with_partial_existing_layers"); + + let (_running_registry, local_registry_host) = test_utils::setup_local_registry().await?; + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + + let platform_os = "linux"; + let platform_arch = Arch::ARM64; + let image_tag = "latest"; + let image_namespace = "testpartial"; + + // Common Layer + let common_layer_content = "This is a common layer across images."; + let common_layer_path = "/app/common.txt"; + info!("Creating common layer..."); + let common_layer = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(common_layer_path, common_layer_content.as_bytes(), None)? + .build() + .await?; + info!("Common layer created: {}", common_layer.diff_id()); + + // Image A specific layer + let image_a_specific_content = "Content specific to Image A."; + let image_a_specific_path = "/app/image_a_only.txt"; + info!("Creating Image A specific layer..."); + let image_a_layer = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data( + image_a_specific_path, + image_a_specific_content.as_bytes(), + None, + )? + .build() + .await?; + info!( + "Image A specific layer created: {}", + image_a_layer.diff_id() + ); + + // Build Image A + let image_a_repo = generate_unique_image_name(); + let image_a_repo_namespaced = format!("{}/{}", image_namespace, image_a_repo); + let image_a_ref = format!( + "{}/{}:{}", + local_registry_host, image_a_repo_namespaced, image_tag + ); + info!("Building Image A: {}", image_a_ref); + let (image_a, _) = Image::builder() + .from("ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55") + .platform(platform_os, &platform_arch) + .layer(common_layer) + .layer(image_a_layer) + .entrypoint(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("echo \'--- Image A files ---\' && ls -la /app && echo \'--- Image A common content ---\' && cat {} && echo \'--- Image A specific content ---\' && cat {}", common_layer_path, image_a_specific_path), + ]) + .blob_cache(shared_blob_cache.clone()) + .build() + .await?; + info!("Image A built. Pushing to {}", image_a_ref); + image_a + .push(&image_a_ref, &test_utils::test_push_options()) + .await?; + info!("Image A pushed."); + + // Image B specific layer + let image_b_specific_content = "Fresh content for Image B."; + let image_b_specific_path = "/app/image_b_only.txt"; + info!("Creating Image B specific layer..."); + let image_b_layer = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data( + image_b_specific_path, + image_b_specific_content.as_bytes(), + None, + )? + .build() + .await?; + info!( + "Image B specific layer created: {}", + image_b_layer.diff_id() + ); + + // Rebuild common_layer for Image B to ensure identical logical layer + info!("Rebuilding common layer for Image B..."); + let common_layer_for_b = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(common_layer_path, common_layer_content.as_bytes(), None)? + .build() + .await?; + info!( + "Common layer for Image B created: {}", + common_layer_for_b.diff_id() + ); + + // Build Image B (reuses common_layer logic) + let image_b_repo = generate_unique_image_name(); + let image_b_repo_namespaced = format!("{}/{}", image_namespace, image_b_repo); + let image_b_ref = format!( + "{}/{}:{}", + local_registry_host, image_b_repo_namespaced, image_tag + ); + info!("Building Image B: {}", image_b_ref); + let image_b_entrypoint_cmd = format!( + "echo \\\'Image B running\\\' && echo \\\'--- Image B files ---\\\' && ls -la /app && echo \\\'--- Image B common content ---\\\' && cat {} && echo \\\'--- Image B specific content ---\\\' && cat {} && echo \\\'--- End of Image B content ---\\\'", + common_layer_path, + image_b_specific_path + ); + let (image_b, _) = Image::builder() + .from("ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55") + .platform(platform_os, &platform_arch) + .layer(common_layer_for_b) // Use the rebuilt common layer for image_b + .layer(image_b_layer) + .entrypoint(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + image_b_entrypoint_cmd.clone(), + ]) + .blob_cache(shared_blob_cache.clone()) + .build() + .await?; + info!( + "Image B built. Pushing to {} (expecting common layer to be mounted)", + image_b_ref + ); + image_b + .push(&image_b_ref, &test_utils::test_push_options()) + .await?; + info!("Image B pushed."); + + // Verification for Image B + let docker = Docker::connect_with_local_defaults().map_err(|e| Error::Generic { + message: format!("Failed to connect to Docker: {}", e), + source: Some(Box::new(e)), + })?; + pull_image_with_bollard( + &docker, + &local_registry_host, + &image_b_repo_namespaced, + image_tag, + ) + .await?; + + let container_name_b = format!("{}-container", image_b_repo); + let guard_b = BollardContainerGuard::new( + &docker, + &image_b_ref, + container_name_b, + &platform_arch.to_string(), + None, + None, + ) + .await?; + let logs_b = guard_b.logs().await?; + guard_b.wait_for_completion().await?; + guard_b.cleanup().await?; + + let full_logs_b = logs_b.join("\n"); + info!("Image B logs: {}", full_logs_b); + assert!( + full_logs_b.contains(common_layer_content), + "Image B logs should contain common layer content. Logs: {}", + full_logs_b + ); + assert!( + full_logs_b.contains(image_b_specific_content), + "Image B logs should contain its specific content. Logs: {}", + full_logs_b + ); + assert!( + !full_logs_b.contains(image_a_specific_content), + "Image B logs should NOT contain Image A specific content. Logs: {}", + full_logs_b + ); + + info!("test_push_with_partial_existing_layers completed successfully!"); + Ok(()) +} + +/// Tests a scenario where an image is pushed that consists entirely of layers already present +/// in the registry (from a previous identical push). It verifies that this results in a +/// manifest-only push, and the pulled image is correct. +#[tokio::test] +async fn test_push_with_all_existing_layers() -> Result<()> { + setup_test_environment(); + info!("Starting test_push_with_all_existing_layers"); + + let (_running_registry, local_registry_host) = test_utils::setup_local_registry().await?; + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + + let platform_os = "linux"; + let platform_arch = Arch::ARM64; + let image_tag = "latest"; + let image_namespace = "testall"; + + // Layers for the image + let layer_x_content = "Content for Layer X"; + let layer_x_path = "/app/layer_x.txt"; + let layer_x = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(layer_x_path, layer_x_content.as_bytes(), None)? + .build() + .await?; + + let layer_y_content = "Content for Layer Y"; + let layer_y_path = "/app/layer_y.txt"; + let layer_y = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(layer_y_path, layer_y_content.as_bytes(), None)? + .build() + .await?; + + // Build and push Image C + let image_c_repo = generate_unique_image_name(); + let image_c_repo_namespaced = format!("{}/{}", image_namespace, image_c_repo); + let image_c_ref = format!( + "{}/{}:{}", + local_registry_host, image_c_repo_namespaced, image_tag + ); + let image_c_entrypoint_cmd = format!( + "echo \\\'Image C running\\\' && echo \\\'--- Image C files ---\\\' && ls -la /app && echo \\\'--- Layer X content ---\\\' && cat {} && echo \\\'--- Layer Y content ---\\\' && cat {} && echo \\\'--- End of Image C content ---\\\'", + layer_x_path, layer_y_path + ); + + info!("Building Image C: {}", image_c_ref); + let (image_c, _) = Image::builder() + .from("ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55") + .platform(platform_os, &platform_arch) + .layer(layer_x) + .layer(layer_y) + .entrypoint(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + image_c_entrypoint_cmd.clone(), + ]) + .blob_cache(shared_blob_cache.clone()) + .build() + .await?; + info!("Image C built. Pushing to {}", image_c_ref); + image_c + .push(&image_c_ref, &test_utils::test_push_options()) + .await?; + info!("Image C pushed."); + + // Attempt to push Image C again (or an identical one) + info!( + "Building Image C Prime (identical to Image C): {}", + image_c_ref + ); + // Rebuild layers for image_c_prime to ensure they are new Layer objects but logically identical + let layer_x_prime = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(layer_x_path, layer_x_content.as_bytes(), None)? + .build() + .await?; + let layer_y_prime = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(layer_y_path, layer_y_content.as_bytes(), None)? + .build() + .await?; + + let (image_c_prime, _) = Image::builder() // Rebuild to ensure it's a new "build" artifact + .from("ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55") + .platform(platform_os, &platform_arch) + .layer(layer_x_prime) // Use rebuilt layer_x_prime + .layer(layer_y_prime) // Use rebuilt layer_y_prime + .entrypoint(vec!["/bin/sh".to_string(), "-c".to_string(), image_c_entrypoint_cmd.clone()]) + .blob_cache(shared_blob_cache.clone()) // Use same cache + .build() + .await?; + info!("Image C Prime built. Re-pushing to {} (expecting all layers to be mounted, manifest only push)", image_c_ref); + image_c_prime + .push(&image_c_ref, &test_utils::test_push_options()) + .await?; + info!("Image C Prime pushed (re-push)."); + + // Verification + let docker = Docker::connect_with_local_defaults().map_err(|e| Error::Generic { + message: format!("Failed to connect to Docker: {}", e), + source: Some(Box::new(e)), + })?; + pull_image_with_bollard( + &docker, + &local_registry_host, + &image_c_repo_namespaced, + image_tag, + ) + .await?; + + let container_name_c = format!("{}-container", image_c_repo); + let guard_c = BollardContainerGuard::new( + &docker, + &image_c_ref, + container_name_c, + &platform_arch.to_string(), + None, + None, + ) + .await?; + let logs_c = guard_c.logs().await?; + guard_c.wait_for_completion().await?; + guard_c.cleanup().await?; + + let full_logs_c = logs_c.join("\n"); + info!("Image C (after re-push) logs: {}", full_logs_c); + assert!( + full_logs_c.contains(layer_x_content), + "Logs should contain Layer X content. Logs: {}", + full_logs_c + ); + assert!( + full_logs_c.contains(layer_y_content), + "Logs should contain Layer Y content. Logs: {}", + full_logs_c + ); + + info!("test_push_with_all_existing_layers completed successfully!"); + Ok(()) +} + +/// Tests the ability to push an image to a local registry using basic HTTP authentication. +/// It verifies that the push operation succeeds with valid credentials and that the +/// resulting image can be pulled and run, containing the correct content. +#[tokio::test] +async fn test_push_with_basic_authentication() -> Result<()> { + setup_test_environment(); + info!("Starting test_push_with_basic_authentication"); + + // --- Manually set up registry with Basic Auth --- + info!("Setting up and running local container registry with Basic Auth..."); + let mut users = HashMap::new(); + users.insert( + "testuser".to_string(), + Secret::new("testpassword".to_string()), + ); + let auth_provider = Arc::new(users); + + let mut testing_registry = ContainerRegistry::builder() + .auth_provider(auth_provider) + .build_for_testing(); + // Explicitly bind to 127.0.0.1:0 to get a random available port + testing_registry.bind(([127, 0, 0, 1], 0).into()); + let _running_registry = testing_registry.run_in_background(); // Keep the guard + let local_registry_addr = _running_registry.bound_addr(); + info!( + "Local container registry with Basic Auth listening on: {}", + local_registry_addr + ); + // --- End of manual registry setup --- + + let local_registry_host = format!("localhost:{}", local_registry_addr.port()); + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + + let platform_os = "linux"; + let platform_arch = Arch::ARM64; + let image_tag = "auth"; + let image_namespace = "testauth"; + + let layer_auth_content = "Authenticated push content!"; + let layer_auth_path = "/app/auth_content.txt"; + let layer_auth = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data(layer_auth_path, layer_auth_content.as_bytes(), None)? + .build() + .await?; + + let image_d_repo = generate_unique_image_name(); + let image_d_repo_namespaced = format!("{}/{}", image_namespace, image_d_repo); + let image_d_ref = format!( + "{}/{}:{}", + local_registry_host, image_d_repo_namespaced, image_tag + ); + let image_d_entrypoint_cmd = format!("echo \'Auth Image Running\' && echo \'--- Auth Content ---\' && cat {} && echo \'--- End Auth Content ---\'", layer_auth_path); + + info!("Building Image D for auth test: {}", image_d_ref); + let (image_d, _) = Image::builder() + .from("ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55") + .platform(platform_os, &platform_arch) + .layer(layer_auth) + .entrypoint(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + image_d_entrypoint_cmd.clone(), + ]) + .blob_cache(shared_blob_cache.clone()) + .build() + .await?; + + info!( + "Image D built. Pushing with basic authentication to {}", + image_d_ref + ); + let push_opts_auth = PushOptions { + auth: RegistryAuth::Basic("testuser".to_string(), "testpassword".to_string()), + protocol: ClientProtocol::Http, + ..Default::default() + }; + image_d.push(&image_d_ref, &push_opts_auth).await?; + info!("Image D pushed with basic auth."); + + // Verification + let docker = Docker::connect_with_local_defaults().map_err(|e| Error::Generic { + message: format!("Failed to connect to Docker: {}", e), + source: Some(Box::new(e)), + })?; + // Pull requires auth now because the test registry is configured with it. + // Modify pull_image_with_bollard to accept credentials or pull directly here. + + info!( + "Pulling image {}/{}_tag:{} using Bollard with Auth...", + local_registry_host, image_d_repo_namespaced, image_tag + ); + let bollard_registry_creds = bollard::auth::DockerCredentials { + username: Some("testuser".to_string()), + password: Some("testpassword".to_string()), + serveraddress: Some(format!("http://{}", local_registry_host)), + ..Default::default() + }; + let bollard_from_image = format!("{}/{}", local_registry_host, image_d_repo_namespaced); + let mut pull_stream = docker.create_image( + Some(CreateImageOptions { + from_image: bollard_from_image, + tag: image_tag.to_string(), + ..Default::default() + }), + None, + Some(bollard_registry_creds), + ); + while let Some(pull_result) = pull_stream.next().await { + match pull_result { + Ok(info_msg) => { + if let Some(status) = info_msg.status { + debug!("Pull status: {}", status); + } + if let Some(progress) = info_msg.progress { + debug!("Pull progress: {}", progress); + } + } + Err(e) => { + error!("Error during image pull: {:?}", e); + return Err(Error::ImagePull { + image_ref: format!("{}/{}", local_registry_host, image_d_repo_namespaced), + message: format!("Bollard image pull failed for tag {}: {}", image_tag, e), + source: Some(Box::new(e)), + }); + } + } + } + info!( + "Image {}/{}:{} pulled successfully via Bollard with Auth.", + local_registry_host, image_d_repo_namespaced, image_tag + ); + + let container_name_d = format!("{}-container", image_d_repo); + let guard_d = BollardContainerGuard::new( + &docker, + &image_d_ref, + container_name_d, + &platform_arch.to_string(), + None, + None, + ) + .await?; + let logs_d = guard_d.logs().await?; + guard_d.wait_for_completion().await?; + guard_d.cleanup().await?; + + let full_logs_d = logs_d.join("\n"); + info!("Image D (auth test) logs: {}", full_logs_d); + + assert!( + full_logs_d.contains(layer_auth_content), + "Logs should contain auth test content. Logs: {}", + full_logs_d + ); + + info!("test_push_with_basic_authentication completed successfully!"); + Ok(()) +} + +#[tokio::test] +async fn test_executable_permission_error() -> Result<()> { + setup_test_environment(); + info!("Starting test_executable_permission_error"); + + // 1. Setup local registry & blob cache + let (_running_registry, local_registry_host) = test_utils::setup_local_registry().await?; + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + + // Setup image naming + let unique_repo_name = generate_unique_image_name(); + let image_tag = "no-exec-test"; + let image_namespace = "testnoexec"; + let target_image_repo_namespaced = format!("{}/{}", image_namespace, unique_repo_name); + let target_image_ref = format!( + "{}/{}:{}", + local_registry_host, target_image_repo_namespaced, image_tag + ); + info!("Target image for no-exec test: {}", target_image_ref); + + // 2. Create a simple script and a layer without execute permissions + let script_content = "#!/bin/sh\necho \'Script executed!\'"; + let script_path_in_container = "/app/test_script.sh"; + + info!("Creating layer with script, no execute permissions..."); + let layer_script = Layer::builder()? + .blob_cache(shared_blob_cache.clone()) + .data( + script_path_in_container, + script_content.as_bytes(), + Some(0o644), + )? // Explicitly NO execute permission + .build() + .await?; + info!( + "Script layer created. Diff ID: {}, Blob Digest: {}", + layer_script.diff_id(), + layer_script.blob_digest() + ); + + // 3. Build the image + let base_image = + "ubuntu@sha256:736e224eff152057af468d34e0a495e57e7f70d274a012897d10461527f0ca55"; // Using a common base + let platform_os = "linux"; + let platform_arch = Arch::ARM64; // Match other tests, ensure base image compat + let entrypoint = vec![script_path_in_container.to_string()]; + + info!( + "Building image from base '{}' ({}/{}) with entrypoint: {:?}", + base_image, platform_os, platform_arch, entrypoint + ); + let (image, _) = Image::builder() + .from(base_image) + .platform(platform_os, &platform_arch) + .layer(layer_script) + .entrypoint(entrypoint.clone()) + .blob_cache(shared_blob_cache) + .build() + .await?; + info!( + "Image built. OCI Archive Path: {:?}, Config Digest: {}", + image.path(), + image.config_digest() + ); + + // 4. Push the image + info!("Pushing image to {}...", target_image_ref); + let push_opts = test_utils::test_push_options(); + image.push(&target_image_ref, &push_opts).await?; + info!("Image pushed successfully to: {}", target_image_ref); + + // 5. Bollard: Connect and Pull + info!("Connecting to Docker daemon via Bollard..."); + let docker = Docker::connect_with_local_defaults().map_err(|e| Error::Generic { + message: format!("Failed to connect to Docker: {}", e), + source: Some(Box::new(e)), + })?; + pull_image_with_bollard( + &docker, + &local_registry_host, + &target_image_repo_namespaced, + image_tag, + ) + .await?; + + // 6. Bollard: Attempt to run container and expect failure + let container_name = format!("{}-no-exec-container", unique_repo_name); + info!( + "Attempting to create and start container '{}' (expected to fail or error)...", + container_name + ); + + // We expect container creation/start to fail or the container to error out quickly. + // The exact error might come during create_container, start_container, or container exit. + // Let's try to create, then start, and then check logs/status. + + let container_config = bollard::container::Config { + image: Some(target_image_ref.as_str()), + tty: Some(true), + entrypoint: Some(entrypoint.iter().map(|s| s.as_str()).collect()), + ..Default::default() + }; + let create_options = Some(CreateContainerOptions { + name: container_name.clone(), + platform: Some(platform_arch.to_string()), + }); + + let create_response_result = docker + .create_container(create_options.clone(), container_config.clone()) + .await; + + // If create succeeds, we'll try to start and then look for runtime errors. + // If create fails, the error might already indicate the problem. + // Bollard might not always give a permission error on create, rather on start or from runtime. + + let container_id: String; + if let Ok(response) = create_response_result { + container_id = response.id; + info!( + "Container '{}' created with ID: {}", + container_name, container_id + ); + + // Attempt to start the container + let start_result = docker + .start_container(&container_id, None::>) + .await; + if let Err(e) = start_result { + info!("Container start failed as expected: {:?}", e.to_string()); + // Docker might give an error like: + // "OCI runtime create failed: ... container_linux.go:380: starting container process caused: exec: "/app/test_script.sh": permission denied: unknown" + assert!( + e.to_string().to_lowercase().contains("permission denied") + || e.to_string().to_lowercase().contains("exec format error") + ); + // Clean up the created (but not started) container + docker + .remove_container( + &container_id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + .map_err(|e_bollard| Error::Generic { + message: format!( + "Failed to remove container '{}' during cleanup: {}", + container_id, e_bollard + ), + source: Some(Box::new(e_bollard)), + })?; + info!("test_executable_permission_error completed successfully (failure on start)."); + return Ok(()); + } + info!( + "Container {} started (unexpectedly, will check logs/exit code).", + container_id + ); + + // If it somehow starts, wait for it and check exit code and logs. + let guard = BollardContainerGuard { + docker: &docker, + id: container_id.clone(), + name: container_name.clone(), + }; + + // Wait for completion - expect non-zero exit code + info!( + "Waiting for container {} to complete (expecting error)...", + guard.id() + ); + let wait_options = Some(WaitContainerOptions { + condition: "not-running", + }); + let mut wait_stream = guard.docker.wait_container(&guard.id, wait_options); + let wait_result = wait_stream.try_next().await; + + let logs = guard.logs().await.unwrap_or_default(); + let full_logs = logs.join("\n"); + info!("Container logs: {}", full_logs); + + // Process wait_result and logs BEFORE cleaning up the guard + match wait_result { + Ok(Some(response)) => { + info!( + "Container {} exited with status code: {}", + guard.id(), + response.status_code + ); + assert_ne!( + response.status_code, 0, + "Container should have failed (non-zero exit code). Logs: {}", + full_logs + ); + // Check logs for permission denied or similar + // The exact message can vary. "Permission denied" is common. + // Sometimes it can be "exec format error" if the shebang is problematic and it tries to run it directly. + let lower_logs = full_logs.to_lowercase(); + assert!( + lower_logs.contains("permission denied") + || lower_logs.contains("exec format error"), + "Expected 'permission denied' or 'exec format error' in logs. Found: {}", + full_logs + ); + } + Ok(None) => { + panic!("Did not receive container exit status for {}.", guard.id()); + } + Err(e) => { + // This could be an error from the wait call itself, implies container problem. + info!("Error waiting for container: {}. This can be an indication of the execution failure.", e); + assert!( + e.to_string().to_lowercase().contains("permission denied") + || e.to_string().to_lowercase().contains("exec format error") + ); + } + } + + guard.cleanup().await?; // Cleanup guard AFTER all uses + } else if let Err(e) = create_response_result { + // Error during container creation itself + info!("Container creation failed as expected: {:?}", e.to_string()); + // Example error: "container_linux.go:380: starting container process caused: exec: "/app/test_script.sh": permission denied" + // This is less common for Bollard; usually the error is on start or from runtime. + assert!( + e.to_string().to_lowercase().contains("permission denied") + || e.to_string().to_lowercase().contains("exec format error") + ); + } + + info!("test_executable_permission_error completed successfully!"); + Ok(()) +} + +fn generate_unique_image_name() -> String { + let random_string: String = rng() + .sample_iter(&Alphanumeric) + .take(12) + .map(char::from) + .collect(); + format!("dockdash-test-{}", random_string.to_lowercase()) +} + +#[tokio::test] +async fn test_pull_policy_missing_uses_cache() -> Result<()> { + setup_test_environment(); + info!("Starting test_pull_policy_missing_uses_cache"); + + // No local registry needed for this test, we pull from public Docker Hub. + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + + let base_image_for_test = "alpine:latest"; + info!("Base image for test: {}", base_image_for_test); + + // 1. Perform a preliminary build with PullPolicy::Always to ensure the manifest is pulled from the registry and cached. + info!("Performing a preliminary build with PullPolicy::Always to ensure manifest for {} is cached.", base_image_for_test); + let (_pre_cache_image, pre_cache_diagnostics) = Image::builder() + .from(base_image_for_test) + .platform("linux", &Arch::Amd64) // Platform required by builder, choose common ones. + .pull_policy(PullPolicy::Always) + .blob_cache(shared_blob_cache.clone()) + .build() + .await?; + assert_eq!( + pre_cache_diagnostics.manifest_source, + ManifestSource::FromRegistry, + "Preliminary build should pull from registry" + ); + info!( + "Preliminary build complete. Manifest for {} should now be cached.", + base_image_for_test + ); + + // 2. Build the image again with PullPolicy::Missing. This should use the cached manifest. + info!( + "Building image with PullPolicy::Missing, expecting cache hit for manifest: {}", + base_image_for_test + ); + let (image_missing_policy, missing_diagnostics) = Image::builder() + .from(base_image_for_test) + .platform("linux", &Arch::Amd64) + .pull_policy(PullPolicy::Missing) + .blob_cache(shared_blob_cache.clone()) // Use the same cache + .build() + .await?; + info!( + "Image with PullPolicy::Missing built. Config digest: {}", + image_missing_policy.config_digest() + ); + + assert_eq!( + missing_diagnostics.manifest_source, + ManifestSource::FromCache, + "PullPolicy::Missing should use cached manifest" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_pull_policy_always_hits_registry() -> Result<()> { + setup_test_environment(); + info!("Starting test_pull_policy_always_hits_registry"); + + // No local registry needed, we pull from public Docker Hub. + let (shared_blob_cache, _temp_cache_dir) = setup_blob_cache()?; + + let base_image_for_test = "alpine:latest"; + info!("Base image for test: {}", base_image_for_test); + + // 1. Perform a preliminary build to ensure the manifest *could* be in the cache. + info!( + "Performing a preliminary build to ensure manifest for {} could be cached.", + base_image_for_test + ); + let (_pre_cache_image, _pre_cache_diagnostics) = Image::builder() // Diagnostics not asserted here + .from(base_image_for_test) + .platform("linux", &Arch::Amd64) + .pull_policy(PullPolicy::Missing) // Or Always, doesn't matter much for pre-caching + .blob_cache(shared_blob_cache.clone()) + .build() + .await?; + info!( + "Preliminary build complete. Manifest for {} might be cached.", + base_image_for_test + ); + + // 2. Build the image again with PullPolicy::Always. This should ignore the cache and pull from the registry. + info!( + "Building image with PullPolicy::Always, expecting registry pull for manifest: {}", + base_image_for_test + ); + let (image_always_policy, always_diagnostics) = Image::builder() + .from(base_image_for_test) + .platform("linux", &Arch::Amd64) + .pull_policy(PullPolicy::Always) + .blob_cache(shared_blob_cache.clone()) // Use the same cache + .build() + .await?; + info!( + "Image with PullPolicy::Always built. Config digest: {}", + image_always_policy.config_digest() + ); + + assert_eq!( + always_diagnostics.manifest_source, + ManifestSource::FromRegistry, + "PullPolicy::Always should pull from registry" + ); + + Ok(()) +} From 9907b37629d0675c921eba30301d8e93571e8e60 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 8 Mar 2026 23:58:30 +0000 Subject: [PATCH 02/15] Fix CI: run unit tests only, integration tests require Docker The integration tests in build_push_tests.rs need the test-utils feature and a Docker daemon. Run only --lib tests in CI to avoid compilation errors. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d5e7d..8afd45e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test + - run: cargo test --lib docs: name: Docs From bb10da1bfba005d50b2a6120e6e2504da6ced84a Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 9 Mar 2026 00:05:27 +0000 Subject: [PATCH 03/15] Fix layer extraction to handle both gzip and zstd compression The extract method was unconditionally applying zstd decompression to all layers, but base image layers from registries are typically gzip-compressed. Now checks the layer media type and uses the appropriate decompressor. Also removes redundant gcr.io hostname checks in monolithic push detection. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + src/image.rs | 43 ++++++++++++++++++------------------------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca23767..5b0e116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,6 +613,7 @@ dependencies = [ "container-registry", "dirs", "dotenvy", + "flate2", "futures-util", "oci-client", "oci-spec 0.6.8", diff --git a/Cargo.toml b/Cargo.toml index 0651085..42d92b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ cacache = { version = "13.1", default-features = false, features = ["tokio-runti container-registry = { version = "0.3", features = ["test-support"], optional = true } async-trait = "0.1" zstd = "0.13" +flate2 = "1" [dev-dependencies] bollard = "0.18" diff --git a/src/image.rs b/src/image.rs index 3a01d92..14c8a0d 100644 --- a/src/image.rs +++ b/src/image.rs @@ -463,42 +463,38 @@ impl Image { let layer_data_vec = layer_data.to_vec(); let target_dir_clone = target_dir.to_path_buf(); let layer_digest = desc.digest().to_string(); + let media_type = desc.media_type().to_string(); // Extract in a blocking task since tar extraction is CPU-intensive tokio::task::spawn_blocking(move || -> Result<()> { use std::io::Cursor; use tar::Archive; - // Decompress zstd let cursor = Cursor::new(layer_data_vec); - let decoder = zstd::Decoder::new(cursor).map_err(|e| { - warn!( - layer_digest = %layer_digest, - error = %e, - "Failed to create zstd decoder" - ); - Error::Io { + + // Decompress based on media type + if media_type.contains("+zstd") { + let decoder = zstd::Decoder::new(cursor).map_err(|e| Error::Io { message: format!( "Failed to create zstd decoder for layer {}", layer_digest ), source: e, - } - })?; - - // Extract tar - let mut tar_archive = Archive::new(decoder); - tar_archive.unpack(&target_dir_clone).map_err(|e| { - warn!( - layer_digest = %layer_digest, - error = %e, - "Failed to extract tar archive" - ); - Error::Io { + })?; + let mut tar_archive = Archive::new(decoder); + tar_archive.unpack(&target_dir_clone).map_err(|e| Error::Io { message: format!("Failed to extract layer {}", layer_digest), source: e, - } - })?; + })?; + } else { + // Default to gzip (covers tar+gzip and docker legacy media types) + let decoder = flate2::read::GzDecoder::new(cursor); + let mut tar_archive = Archive::new(decoder); + tar_archive.unpack(&target_dir_clone).map_err(|e| Error::Io { + message: format!("Failed to extract layer {}", layer_digest), + source: e, + })?; + } debug!(layer_digest = %layer_digest, "Layer extracted successfully"); Ok(()) @@ -1548,7 +1544,4 @@ fn is_registry_requiring_monolithic_push(registry_host: &str) -> bool { registry_host.ends_with("-docker.pkg.dev") // Google Artifact Registry || registry_host == "gcr.io" || registry_host.ends_with(".gcr.io") // Google Container Registry - || registry_host == "us.gcr.io" - || registry_host == "eu.gcr.io" - || registry_host == "asia.gcr.io" } From b2f509292a5d74060bc2e9d14f4cc18653c44479 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 9 Mar 2026 00:10:24 +0000 Subject: [PATCH 04/15] Fix formatting in image extraction code Co-Authored-By: Claude Opus 4.6 --- src/image.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/image.rs b/src/image.rs index 14c8a0d..bc7f789 100644 --- a/src/image.rs +++ b/src/image.rs @@ -482,18 +482,22 @@ impl Image { source: e, })?; let mut tar_archive = Archive::new(decoder); - tar_archive.unpack(&target_dir_clone).map_err(|e| Error::Io { - message: format!("Failed to extract layer {}", layer_digest), - source: e, - })?; + tar_archive + .unpack(&target_dir_clone) + .map_err(|e| Error::Io { + message: format!("Failed to extract layer {}", layer_digest), + source: e, + })?; } else { // Default to gzip (covers tar+gzip and docker legacy media types) let decoder = flate2::read::GzDecoder::new(cursor); let mut tar_archive = Archive::new(decoder); - tar_archive.unpack(&target_dir_clone).map_err(|e| Error::Io { - message: format!("Failed to extract layer {}", layer_digest), - source: e, - })?; + tar_archive + .unpack(&target_dir_clone) + .map_err(|e| Error::Io { + message: format!("Failed to extract layer {}", layer_digest), + source: e, + })?; } debug!(layer_digest = %layer_digest, "Layer extracted successfully"); From 39fae858e0b77d207f8cd2d884acf62097e1a536 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 9 Mar 2026 00:15:13 +0000 Subject: [PATCH 05/15] Fix PR review issues: CMD preservation, dead branch, uncompressed tar - Preserve base image CMD when only entrypoint is overridden (CMD is only cleared when entrypoint is explicitly set, matching Docker behavior) - Remove dead branch in push code (layers_to_push.is_empty() was unreachable after the early return above) - Handle uncompressed tar layers in extract (media types without +gzip or +zstd are now extracted as plain tar) Co-Authored-By: Claude Opus 4.6 --- src/image.rs | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/image.rs b/src/image.rs index bc7f789..94c87fc 100644 --- a/src/image.rs +++ b/src/image.rs @@ -473,6 +473,11 @@ impl Image { let cursor = Cursor::new(layer_data_vec); // Decompress based on media type + let unpack_err = |e| Error::Io { + message: format!("Failed to extract layer {}", layer_digest), + source: e, + }; + if media_type.contains("+zstd") { let decoder = zstd::Decoder::new(cursor).map_err(|e| Error::Io { message: format!( @@ -481,23 +486,21 @@ impl Image { ), source: e, })?; - let mut tar_archive = Archive::new(decoder); - tar_archive + Archive::new(decoder) .unpack(&target_dir_clone) - .map_err(|e| Error::Io { - message: format!("Failed to extract layer {}", layer_digest), - source: e, - })?; - } else { - // Default to gzip (covers tar+gzip and docker legacy media types) + .map_err(unpack_err)?; + } else if media_type.contains("+gzip") + || media_type.contains("vnd.docker.image.rootfs") + { let decoder = flate2::read::GzDecoder::new(cursor); - let mut tar_archive = Archive::new(decoder); - tar_archive + Archive::new(decoder) .unpack(&target_dir_clone) - .map_err(|e| Error::Io { - message: format!("Failed to extract layer {}", layer_digest), - source: e, - })?; + .map_err(unpack_err)?; + } else { + // Uncompressed tar + Archive::new(cursor) + .unpack(&target_dir_clone) + .map_err(unpack_err)?; } debug!(layer_digest = %layer_digest, "Layer extracted successfully"); @@ -778,9 +781,7 @@ impl Image { info!("Pushing image (layers, config, manifest)."); // Report that we're about to start the actual upload - let operation_text = if layers_to_push.is_empty() { - "All layers cached".to_string() - } else if total_push_size_bytes > 10 * 1024 * 1024 { + let operation_text = if total_push_size_bytes > 10 * 1024 * 1024 { format!( "Uploading {:.1} MB in {} layers", total_push_size_bytes as f64 / (1024.0 * 1024.0), @@ -1324,11 +1325,14 @@ impl ImageBuilder { let mut proc_config = config.config().clone().unwrap_or_default(); if let Some(entrypoint) = self.entrypoint { proc_config.set_entrypoint(Some(entrypoint)); + // Per Docker/OCI convention, setting entrypoint resets cmd + // unless the user also explicitly set cmd + if self.cmd.is_none() { + proc_config.set_cmd(Some(vec![])); + } } if let Some(cmd) = self.cmd { proc_config.set_cmd(Some(cmd)); - } else { - proc_config.set_cmd(Some(vec![])); } if let Some(working_dir) = self.working_dir { proc_config.set_working_dir(Some(working_dir)); @@ -1362,8 +1366,6 @@ impl ImageBuilder { } if let Some(cmd) = self.cmd { config_builder = config_builder.cmd(cmd); - } else { - config_builder = config_builder.cmd(Vec::::new()); } if let Some(working_dir) = self.working_dir { config_builder = config_builder.working_dir(working_dir); From 64f07991952e27df29ffd25581b85eb6dd30e854 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 9 Mar 2026 00:23:02 +0000 Subject: [PATCH 06/15] Fix Docker layer media type detection, CMD reset, and platform warnings - Distinguish gzip vs uncompressed Docker layers by checking for "gzip" in media type instead of matching all rootfs media types as gzip - Reset CMD to None (not empty array) when entrypoint overrides it, matching OCI spec semantics - Warn when only one of platform_os/platform_arch is set in PullAndExtractOptions Co-Authored-By: Claude Opus 4.6 --- src/image.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/image.rs b/src/image.rs index 94c87fc..5598218 100644 --- a/src/image.rs +++ b/src/image.rs @@ -291,10 +291,17 @@ impl Image { .from(image_ref) .pull_policy(options.pull_policy); - if let Some(os) = options.platform_os { - if let Some(arch) = options.platform_arch { + match (options.platform_os, options.platform_arch) { + (Some(os), Some(arch)) => { builder = builder.platform(&os, &arch); } + (Some(_), None) => { + warn!("platform_os set without platform_arch; platform filter will not be applied"); + } + (None, Some(_)) => { + warn!("platform_arch set without platform_os; platform filter will not be applied"); + } + (None, None) => {} } if let Some(cache) = options.blob_cache { @@ -489,9 +496,7 @@ impl Image { Archive::new(decoder) .unpack(&target_dir_clone) .map_err(unpack_err)?; - } else if media_type.contains("+gzip") - || media_type.contains("vnd.docker.image.rootfs") - { + } else if media_type.contains("+gzip") || media_type.contains("gzip") { let decoder = flate2::read::GzDecoder::new(cursor); Archive::new(decoder) .unpack(&target_dir_clone) @@ -1328,7 +1333,7 @@ impl ImageBuilder { // Per Docker/OCI convention, setting entrypoint resets cmd // unless the user also explicitly set cmd if self.cmd.is_none() { - proc_config.set_cmd(Some(vec![])); + proc_config.set_cmd(None); } } if let Some(cmd) = self.cmd { From 480ff86bdaa1129a43c822d545864cf9b88e9d29 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 10 Mar 2026 17:39:48 +0000 Subject: [PATCH 07/15] Fix Greptile review issues: stale dir cache, credential leakage, debug print - Record per-file metadata when adding directories to layers so the input-based cache key reflects actual directory contents (prevents stale cache hits when files inside a directory change) - Scope DOCKER_USERNAME/DOCKER_PASSWORD env vars to Docker Hub only, preventing credential leakage to unrelated registries - Remove leftover eprintln! debug statement in layer cache miss path - Document self-mount blob existence check workaround (oci-client lacks a HEAD blob API) Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + src/image.rs | 41 ++++++++++++++++++++++++++--------- src/layer.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b0e116..db742bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,6 +631,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", + "walkdir", "zstd", ] diff --git a/Cargo.toml b/Cargo.toml index 42d92b4..11d12b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ container-registry = { version = "0.3", features = ["test-support"], optional = async-trait = "0.1" zstd = "0.13" flate2 = "1" +walkdir = "2" [dev-dependencies] bollard = "0.18" diff --git a/src/image.rs b/src/image.rs index 5598218..677edd8 100644 --- a/src/image.rs +++ b/src/image.rs @@ -683,17 +683,23 @@ impl Image { let digest_str = desc.digest().to_string(); let mut should_skip_upload = false; - debug!(layer_digest = %digest_str, "Attempting to mount layer."); + // oci-client does not expose a HEAD /v2//blobs/ API, + // so we use a self-mount (same source and destination repo) as a blob + // existence check. Per the OCI Distribution Spec, registries that + // support cross-repo mount will return 201 if the blob already exists + // in the target repo. If the registry rejects the self-mount, we fall + // through to the upload path. + debug!(layer_digest = %digest_str, "Checking if layer already exists via self-mount."); match oci_client .mount_blob(&push_ref, &push_ref, &digest_str) .await { Ok(_) => { - info!(layer_digest = %digest_str, "Layer successfully mounted (OCI 201)."); + info!(layer_digest = %digest_str, "Layer already exists in registry (mount returned 201)."); should_skip_upload = true; } Err(e) => { - debug!(layer_digest = %digest_str, error = %e, "Layer mount failed. Will upload."); + debug!(layer_digest = %digest_str, error = %e, "Self-mount failed, layer will be uploaded."); } } @@ -1494,27 +1500,42 @@ impl ImageBuilder { } /// Determines the RegistryAuth by trying environment variables and falling back to Anonymous. +/// +/// The `DOCKER_USERNAME`/`DOCKER_PASSWORD` env vars are only applied when the target +/// registry is Docker Hub (`index.docker.io` / `registry-1.docker.io`). For all other +/// registries, anonymous auth is used unless credentials are provided via the +/// `PushOptions::auth` field. fn determine_registry_auth(reference: &Reference) -> RegistryAuth { - let host_for_logging = reference.resolve_registry(); // Still useful for logging + let registry_host = reference.resolve_registry(); - let auth = match (env::var("DOCKER_USERNAME"), env::var("DOCKER_PASSWORD")) { + let is_docker_hub = registry_host == "index.docker.io" + || registry_host == "registry-1.docker.io" + || registry_host == "docker.io"; + + if !is_docker_hub { + info!( + "Registry {} is not Docker Hub. Using anonymous auth (provide explicit auth via PushOptions for authenticated access).", + registry_host + ); + return RegistryAuth::Anonymous; + } + + match (env::var("DOCKER_USERNAME"), env::var("DOCKER_PASSWORD")) { (Ok(username), Ok(password)) if !username.is_empty() && !password.is_empty() => { info!( "Using Docker credentials from DOCKER_USERNAME/PASSWORD env vars for {}", - host_for_logging + registry_host ); RegistryAuth::Basic(username, password) } _ => { info!( "DOCKER_USERNAME and/or DOCKER_PASSWORD not set or empty. Falling back to anonymous auth for {}.", - host_for_logging + registry_host ); RegistryAuth::Anonymous } - }; - - auth + } } /// Determines whether to use monolithic push based on the policy and registry hostname. diff --git a/src/layer.rs b/src/layer.rs index 6cf4b3f..f359e43 100644 --- a/src/layer.rs +++ b/src/layer.rs @@ -214,13 +214,58 @@ impl LayerBuilder { } })?; - self.file_metadata.push(FileMetadata { - archive_path: normalized_ap, - size: 0, - mtime: 0, - mode: 0o755, - is_dir: true, - }); + // Walk the source directory and record metadata for every file, + // so the input-based cache key captures actual directory contents. + for entry in walkdir::WalkDir::new(dp_ref).sort_by_file_name() { + let entry = entry.map_err(|e| Error::Io { + source: e.into(), + message: format!( + "Failed to walk directory {} for cache key metadata", + dp_ref.display() + ), + })?; + let entry_metadata = entry.metadata().map_err(|e| Error::Io { + source: e.into(), + message: format!( + "Failed to get metadata for {}", + entry.path().display() + ), + })?; + let relative = entry + .path() + .strip_prefix(dp_ref) + .unwrap_or(entry.path()); + let archive_entry_path = normalized_ap.join(relative); + + let mtime = entry_metadata + .modified() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0); + + let mode = { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + entry_metadata.permissions().mode() + } + #[cfg(not(unix))] + { + if entry_metadata.is_dir() { 0o755 } else { 0o644 } + } + }; + + self.file_metadata.push(FileMetadata { + archive_path: archive_entry_path, + size: entry_metadata.len(), + mtime, + mode, + is_dir: entry_metadata.is_dir(), + }); + } debug!("Successfully added directory to layer."); Ok(self) @@ -466,7 +511,6 @@ impl LayerBuilder { } // Input-based cache miss - finalize the tar and try content-based cache - eprintln!("[DEBUG] INPUT CACHE MISS for input_key={}", input_key); debug!("Input-based cache miss, finalizing tar"); // Finalize the tar writer, ensuring all data is flushed and the file is closed. From 8a542faaaef888c51a073f4ad1dfbe6993a5b826 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 10 Mar 2026 17:55:50 +0000 Subject: [PATCH 08/15] Fix remaining review issues: whiteout handling, error dedup, constant dedup - Handle OCI/Docker whiteout files (.wh. and .wh..wh..opq) during layer extraction so deleted files from prior layers are properly removed from the merged filesystem - Fix duplicate error message in From impl that produced "I/O error: Permission denied: Permission denied" - Deduplicate IMAGE_LAYER_ZSTD_MEDIA_TYPE constant into lib.rs Co-Authored-By: Claude Opus 4.6 --- src/error.rs | 2 +- src/image.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++-------- src/layer.rs | 3 +- src/lib.rs | 3 ++ 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/error.rs b/src/error.rs index fd0b458..f8652af 100644 --- a/src/error.rs +++ b/src/error.rs @@ -65,7 +65,7 @@ pub type Result = std::result::Result; impl From for Error { fn from(err: std::io::Error) -> Self { Error::Io { - message: err.to_string(), + message: "An I/O error occurred".to_string(), source: err, } } diff --git a/src/image.rs b/src/image.rs index 677edd8..fd52688 100644 --- a/src/image.rs +++ b/src/image.rs @@ -14,8 +14,7 @@ use oci_client::{ Reference, RegistryOperation, }; -/// OCI media type for zstd-compressed tar layers -const IMAGE_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; +use crate::IMAGE_LAYER_ZSTD_MEDIA_TYPE; use oci_spec::image::Arch; use oci_spec::image::{ImageConfiguration, ImageManifest as SpecImageManifest}; use ocipkg::image::Image as _; @@ -479,8 +478,8 @@ impl Image { let cursor = Cursor::new(layer_data_vec); - // Decompress based on media type - let unpack_err = |e| Error::Io { + // Create the appropriate archive reader based on media type + let extract_err = |e: std::io::Error| Error::Io { message: format!("Failed to extract layer {}", layer_digest), source: e, }; @@ -493,19 +492,16 @@ impl Image { ), source: e, })?; - Archive::new(decoder) - .unpack(&target_dir_clone) - .map_err(unpack_err)?; + extract_layer_with_whiteouts(Archive::new(decoder), &target_dir_clone) + .map_err(extract_err)?; } else if media_type.contains("+gzip") || media_type.contains("gzip") { let decoder = flate2::read::GzDecoder::new(cursor); - Archive::new(decoder) - .unpack(&target_dir_clone) - .map_err(unpack_err)?; + extract_layer_with_whiteouts(Archive::new(decoder), &target_dir_clone) + .map_err(extract_err)?; } else { // Uncompressed tar - Archive::new(cursor) - .unpack(&target_dir_clone) - .map_err(unpack_err)?; + extract_layer_with_whiteouts(Archive::new(cursor), &target_dir_clone) + .map_err(extract_err)?; } debug!(layer_digest = %layer_digest, "Layer extracted successfully"); @@ -1570,6 +1566,67 @@ fn determine_use_monolithic_push(policy: &MonolithicPushPolicy, reference: &Refe } } +/// Extracts a tar archive layer while handling OCI/Docker whiteout files. +/// +/// Whiteout files signal deletions from prior layers: +/// - `.wh.` in a directory means `` should be deleted +/// - `.wh..wh..opq` means the containing directory is opaque (all prior contents deleted) +fn extract_layer_with_whiteouts( + mut archive: tar::Archive, + target_dir: &Path, +) -> std::io::Result<()> { + for entry_result in archive.entries()? { + let mut entry = entry_result?; + let entry_path = entry.path()?.into_owned(); + + let file_name = match entry_path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => { + // No file name (e.g., root entry) — just unpack normally + entry.unpack_in(target_dir)?; + continue; + } + }; + + if file_name == ".wh..wh..opq" { + // Opaque whiteout: delete all existing contents in the parent directory + let parent = target_dir.join( + entry_path + .parent() + .unwrap_or_else(|| Path::new("")), + ); + if parent.is_dir() { + for child in std_fs::read_dir(&parent)? { + let child = child?; + let child_path = child.path(); + if child_path.is_dir() { + std_fs::remove_dir_all(&child_path)?; + } else { + std_fs::remove_file(&child_path)?; + } + } + } + } else if let Some(target_name) = file_name.strip_prefix(".wh.") { + // Regular whiteout: delete the specific file/directory + let target_path = target_dir.join( + entry_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(target_name), + ); + if target_path.is_dir() { + let _ = std_fs::remove_dir_all(&target_path); + } else { + let _ = std_fs::remove_file(&target_path); + } + } else { + // Normal entry — extract it + entry.unpack_in(target_dir)?; + } + } + Ok(()) +} + /// Checks if a registry requires monolithic push based on its hostname. fn is_registry_requiring_monolithic_push(registry_host: &str) -> bool { // Google Artifact Registry and Container Registry require monolithic push diff --git a/src/layer.rs b/src/layer.rs index f359e43..f11af1a 100644 --- a/src/layer.rs +++ b/src/layer.rs @@ -11,8 +11,7 @@ use tempfile::NamedTempFile; use tokio::task; use tracing::{debug, info, instrument, warn}; -/// OCI media type for zstd-compressed tar layers -const IMAGE_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; +use crate::IMAGE_LAYER_ZSTD_MEDIA_TYPE; /// Represents a single layer in an OCI image. /// diff --git a/src/lib.rs b/src/lib.rs index 14a2843..519c500 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,9 @@ //! # } //! ``` +/// OCI media type for zstd-compressed tar layers +pub(crate) const IMAGE_LAYER_ZSTD_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar+zstd"; + mod error; pub use error::*; From 4523e38993ba3c80598efe0eb434c68c4cd12233 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 10 Mar 2026 18:11:58 +0000 Subject: [PATCH 09/15] Propagate real IO errors during whiteout deletion, ignore only NotFound Co-Authored-By: Claude Opus 4.6 --- src/image.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/image.rs b/src/image.rs index fd52688..fe48689 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1614,10 +1614,15 @@ fn extract_layer_with_whiteouts( .unwrap_or_else(|| Path::new("")) .join(target_name), ); - if target_path.is_dir() { - let _ = std_fs::remove_dir_all(&target_path); + let remove_result = if target_path.is_dir() { + std_fs::remove_dir_all(&target_path) } else { - let _ = std_fs::remove_file(&target_path); + std_fs::remove_file(&target_path) + }; + if let Err(e) = remove_result { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(e); + } } } else { // Normal entry — extract it From aba55584eba60cb5e300ff909a7911f621ced150 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 10 Mar 2026 18:44:31 +0000 Subject: [PATCH 10/15] Fix manifest cache key missing platform and data() cache key missing content - Include OS and arch in manifest cache key (manifest-v2:{ref}:{os}:{arch}) so multi-platform builds don't serve wrong cached manifests - Hash in-memory content in data() FileMetadata so same-size blobs at the same path produce different input cache keys Co-Authored-By: Claude Opus 4.6 --- src/image.rs | 7 ++++++- src/layer.rs | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/image.rs b/src/image.rs index fe48689..d03de5f 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1096,7 +1096,12 @@ impl ImageBuilder { } }; - let manifest_cache_key = format!("manifest-v1:{}", base_ref.whole()); + let manifest_cache_key = format!( + "manifest-v2:{}:{}:{}", + base_ref.whole(), + target_os_for_build.as_str(), + target_arch_for_build, + ); let manifest_source_temp: ManifestSource; let (base_image_manifest_resolved, resolved_manifest_digest_str_temp) = if pull_policy diff --git a/src/layer.rs b/src/layer.rs index f11af1a..bfa1dd2 100644 --- a/src/layer.rs +++ b/src/layer.rs @@ -65,6 +65,8 @@ struct FileMetadata { mode: u32, /// Whether this is a directory is_dir: bool, + /// Optional content hash for in-memory data (where mtime is meaningless) + content_hash: Option<[u8; 32]>, } /// Builder for creating `Layer` instances. @@ -141,12 +143,15 @@ impl LayerBuilder { sorted_metadata.sort_by(|a, b| a.archive_path.cmp(&b.archive_path)); for meta in &sorted_metadata { - // Hash: path | size | mtime | mode | is_dir + // Hash: path | size | mtime | mode | is_dir | content_hash hasher.update(meta.archive_path.to_string_lossy().as_bytes()); hasher.update(meta.size.to_le_bytes()); hasher.update(meta.mtime.to_le_bytes()); hasher.update(meta.mode.to_le_bytes()); hasher.update([meta.is_dir as u8]); + if let Some(ref hash) = meta.content_hash { + hasher.update(hash); + } } format!("layer-input-{:x}", hasher.finalize()) @@ -263,6 +268,7 @@ impl LayerBuilder { mtime, mode, is_dir: entry_metadata.is_dir(), + content_hash: None, }); } @@ -346,6 +352,7 @@ impl LayerBuilder { .unwrap_or(0), mode: file_mode, is_dir: false, + content_hash: None, }); if let Some(new_mode) = mode { @@ -437,12 +444,20 @@ impl LayerBuilder { } })?; + // Hash in-memory content so the input cache key distinguishes + // different data at the same path/size (mtime is always 0 here). + let content_hash: [u8; 32] = { + use sha2::Digest; + Sha256::digest(content).into() + }; + self.file_metadata.push(FileMetadata { archive_path: normalized_ap, size: content.len() as u64, mtime: 0, mode: mode.unwrap_or(0o644), is_dir: false, + content_hash: Some(content_hash), }); debug!("Successfully added data to layer."); From 86fe43abc276f43dccc3a882264ad5f9f8d0397b Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 10 Mar 2026 18:50:55 +0000 Subject: [PATCH 11/15] Log warning when tar entry path traversal is blocked during extraction Co-Authored-By: Claude Opus 4.6 --- src/image.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/image.rs b/src/image.rs index d03de5f..c969295 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1630,8 +1630,14 @@ fn extract_layer_with_whiteouts( } } } else { - // Normal entry — extract it - entry.unpack_in(target_dir)?; + // Normal entry — extract it. unpack_in returns false if the + // path would escape target_dir (path traversal guard). + if !entry.unpack_in(target_dir)? { + warn!( + path = %entry_path.display(), + "Skipping tar entry: path escapes target directory" + ); + } } } Ok(()) From 9da8b6282eefc9b7dc6f352520b9977190efde35 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Wed, 11 Mar 2026 00:42:44 +0000 Subject: [PATCH 12/15] Revert Docker auth to apply to all registries and remove unused From MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker Hub-only scoping of DOCKER_USERNAME/DOCKER_PASSWORD was a usability regression — CI/CD users commonly set these for any registry. The From impl was unused since all call sites construct Error::Io with contextual messages; removing it enforces that pattern. Also applies rustfmt formatting fixes. Co-Authored-By: Claude Sonnet 4.6 --- src/error.rs | 9 --------- src/image.rs | 24 +++--------------------- src/layer.rs | 16 +++++++--------- 3 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/error.rs b/src/error.rs index f8652af..8d474c2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -62,15 +62,6 @@ pub enum Error { pub type Result = std::result::Result; -impl From for Error { - fn from(err: std::io::Error) -> Self { - Error::Io { - message: "An I/O error occurred".to_string(), - source: err, - } - } -} - impl Error { /// Checks if this error is due to a manifest not being found in the registry. /// This is used to determine if we should retry with a fallback base image. diff --git a/src/image.rs b/src/image.rs index c969295..5a9a78b 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1502,25 +1502,11 @@ impl ImageBuilder { /// Determines the RegistryAuth by trying environment variables and falling back to Anonymous. /// -/// The `DOCKER_USERNAME`/`DOCKER_PASSWORD` env vars are only applied when the target -/// registry is Docker Hub (`index.docker.io` / `registry-1.docker.io`). For all other -/// registries, anonymous auth is used unless credentials are provided via the -/// `PushOptions::auth` field. +/// Note: `DOCKER_USERNAME`/`DOCKER_PASSWORD` env vars are applied to all registries. +/// For per-registry auth control, use the `auth` field on `PushOptions` or `ImageBuilder`. fn determine_registry_auth(reference: &Reference) -> RegistryAuth { let registry_host = reference.resolve_registry(); - let is_docker_hub = registry_host == "index.docker.io" - || registry_host == "registry-1.docker.io" - || registry_host == "docker.io"; - - if !is_docker_hub { - info!( - "Registry {} is not Docker Hub. Using anonymous auth (provide explicit auth via PushOptions for authenticated access).", - registry_host - ); - return RegistryAuth::Anonymous; - } - match (env::var("DOCKER_USERNAME"), env::var("DOCKER_PASSWORD")) { (Ok(username), Ok(password)) if !username.is_empty() && !password.is_empty() => { info!( @@ -1595,11 +1581,7 @@ fn extract_layer_with_whiteouts( if file_name == ".wh..wh..opq" { // Opaque whiteout: delete all existing contents in the parent directory - let parent = target_dir.join( - entry_path - .parent() - .unwrap_or_else(|| Path::new("")), - ); + let parent = target_dir.join(entry_path.parent().unwrap_or_else(|| Path::new(""))); if parent.is_dir() { for child in std_fs::read_dir(&parent)? { let child = child?; diff --git a/src/layer.rs b/src/layer.rs index bfa1dd2..ca27ea6 100644 --- a/src/layer.rs +++ b/src/layer.rs @@ -230,15 +230,9 @@ impl LayerBuilder { })?; let entry_metadata = entry.metadata().map_err(|e| Error::Io { source: e.into(), - message: format!( - "Failed to get metadata for {}", - entry.path().display() - ), + message: format!("Failed to get metadata for {}", entry.path().display()), })?; - let relative = entry - .path() - .strip_prefix(dp_ref) - .unwrap_or(entry.path()); + let relative = entry.path().strip_prefix(dp_ref).unwrap_or(entry.path()); let archive_entry_path = normalized_ap.join(relative); let mtime = entry_metadata @@ -258,7 +252,11 @@ impl LayerBuilder { } #[cfg(not(unix))] { - if entry_metadata.is_dir() { 0o755 } else { 0o644 } + if entry_metadata.is_dir() { + 0o755 + } else { + 0o644 + } } }; From 3ed949b9516791ffc467129651d560521fc0307a Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Thu, 12 Mar 2026 17:25:49 +0000 Subject: [PATCH 13/15] Switch release trigger from tag push to GitHub Release published event Replace push: tags: v* with release: types: [published] so releases require an explicit publish action, allowing draft review before the pipeline fires. Remove the github-release job since the release already exists when the workflow runs. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd683cd..f766c0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,8 @@ name: Release on: - push: - tags: - - "v*" + release: + types: [published] permissions: contents: write @@ -23,13 +22,3 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - github-release: - name: GitHub Release - runs-on: ubuntu-latest - needs: publish - steps: - - uses: actions/checkout@v4 - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true From d7a94b20ea0fd452e294943482c1cafaf60a040d Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Thu, 12 Mar 2026 17:32:25 +0000 Subject: [PATCH 14/15] Downgrade permissions to contents: read The github-release job that wrote back to GitHub was removed, so contents: write is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e20949d..cfede41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: types: [published] permissions: - contents: write + contents: read env: CARGO_TERM_COLOR: always From 47a1e14f4aadef8236ef383189920302ec8ea4e7 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Thu, 12 Mar 2026 21:35:45 +0000 Subject: [PATCH 15/15] Use CRATES_IO_TOKEN org secret for cargo publish Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfede41..6070d20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,4 +20,4 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo publish env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}