diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4040ca5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2454 @@ +# 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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[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", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecr" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dirs", + "flate2", + "futures-util", + "indicatif", + "libc", + "nix", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "tar", + "tempfile", + "tokio", + "users", + "xz2", + "zstd", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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-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-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[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 = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "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", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[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.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "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 = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[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-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", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "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", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "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 = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[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.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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 = "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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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 = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +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-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 = "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", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "users" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" +dependencies = [ + "libc", + "log", +] + +[[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 = "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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +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", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +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", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +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-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.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.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_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.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.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.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.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.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.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 = "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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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", + "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", + "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", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +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 index c123592..aae7d0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,48 @@ [package] name = "ecr" version = "0.1.0" -edition = "2024" +edition = "2021" +rust-version = "1.77" +description = "Enter chroot environments with Linux namespaces" +license = "MIT" +authors = ["Valentin Haudiquet"] [dependencies] +# CLI parsing +clap = { version = "4", features = ["derive", "env"] } + +# Config parsing +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" + +# HTTP downloads +reqwest = { version = "0.13", features = ["blocking", "stream"] } + +# Tarball extraction +tar = "0.4" +flate2 = "1" +xz2 = "0.1" +zstd = "0.13" + +# Unix syscall bindings +nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] } + +# Temp directories +tempfile = "3" + +# Error handling +anyhow = "1" + +# Utilities +dirs = "6" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] } +futures-util = "0.3" +indicatif = "0.18" +serde_json = "1" +libc = "0.2" +users = "0.11" + +[profile.release] +strip = true +opt-level = "z" +lto = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..049d935 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# ecr - ephemeral chroot + diff --git a/SPEC.md b/SPEC.md index a561fc5..085fbd2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3,14 +3,14 @@ ## Synopsis ``` -ecr [:] [-a ] [options] [-- command [args...]] +ecr [OPTIONS] -- [COMMAND]... ``` ## CLI Interface ### Positional Arguments -- `` (required): Distribution name +- `` (required): Distribution name or OCI image reference - `` (optional): Distribution version/codename ### Options @@ -18,9 +18,10 @@ ecr [:] [-a ] [options] [-- command [args...]] | Flag | Default | Description | |------|---------|-------------| | `-a, --arch ` | host arch | Target architecture | -| `--bind [path]` | cwd | Directory to overlay-mount | -| `--bind-rw [path]` | none | Read-write bind mount at `/root/` (bypasses overlay) | +| `--bind ` | cwd | Directory to overlay-mount (can be specified multiple times) | +| `--bind-rw ` | none | Read-write bind mount at `/mnt/` (can be specified multiple times, overrides `--bind` for same path) | | `--no-cache` | false | Download fresh tarball, ignore cache | +| `--no-bind` | false | Skip mounting any directory | | `-h, --help` | - | Show help | | `-V, --version` | - | Show version | @@ -31,8 +32,8 @@ ecr [:] [-a ] [options] [-- command [args...]] ``` ~/.cache/ecr/ ├── ubuntu-noble-amd64.tar.gz -├── debian-bookworm-arm64.tar.gz -├── arch-latest-riscv64.tar.gz +├── alpine-latest-x86_64.tar.gz +├── debian-bookworm-amd64.tar.gz └── ... ``` @@ -45,54 +46,82 @@ No metadata files. Tarballs are downloaded once and never redownloaded. Users ca ```yaml dns: - 1.1.1.1 -cache_dir: ~/.cache/ecr ``` ## Distro Sources -| Distro | Version Format | URL Pattern | -|--------|----------------|-------------| -| Ubuntu | noble, jammy, mantic or 26.04, 25.10, 22.04, latest, lts | https://cdimage.ubuntu.com/ubuntu-base/bionic/daily/current/bionic-base-amd64.tar.gz | -| Debian | bookworm, bullseye, sid, latest | https://cloud.debian.org/images/cloud/trixie/latest/debian-13-nocloud-amd64.tar.xz | -| Arch | latest | https://geo.mirror.pkgbuild.com/iso/latest/archlinux-bootstrap-x86_64.tar.zst | -| Alpine | 3.20, 3.19, latest, edge | https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/alpine-minirootfs-3.23.0-x86_64.tar.gz | -| Fedora | 40, 41, rawhide, latest | Extract from `registry.fedoraproject.org/fedora:` | +### Direct Tarball Downloads + +| Distro | Version Format | Source | +|--------|----------------|--------| +| Ubuntu | noble, jammy, mantic or 26.04, 25.10, 22.04, latest, lts | cdimage.ubuntu.com | +| Alpine | 3.20, 3.19, latest, edge | dl-cdn.alpinelinux.org | + +### Docker Hub (OCI Registry) + +All other distributions use Docker Hub images via OCI registry API: + +| Distro | Image Reference | +|--------|-----------------| +| Debian | `library/debian` | +| Arch | `library/archlinux` | +| Fedora | `library/fedora` | +| Gentoo | `gentoo/stage3` | +| Custom | `[:tag]` or `/[:tag]` | + +### Custom Image References + +Users can specify any OCI-compatible image: + +``` +ecr debian:bookworm -- ./build.sh +ecr gentoo/stage3 -- emerge --sync +ecr gcr.io/my-project/my-image:v1.0 -- /app/test +``` ### Architecture Mapping -| ecr | Ubuntu | Debian | Arch | Alpine | Fedora | -|-----|--------|--------|------|--------|--------| -| amd64 | amd64 | amd64 | x86_64 | x86_64 | x86_64 | -| arm64 | arm64 | arm64 | aarch64 | aarch64 | aarch64 | -| armhf | armhf | armhf | - | armv7 | - | -| riscv64 | riscv64 | riscv64 | riscv64 | riscv64 | riscv64 | -| ppc64el | ppc64el | ppc64el | - | ppc64le | ppc64le | -| s390x | s390x | s390x | - | s390x | s390x | +| ecr | Ubuntu | Alpine | Docker Hub | +|-----|--------|--------|------------| +| amd64 | amd64 | x86_64 | amd64 | +| arm64 | arm64 | aarch64 | arm64 | +| armhf | armhf | armv7 | arm/v7 | +| riscv64 | riscv64 | riscv64 | riscv64 | +| ppc64el | ppc64el | ppc64le | ppc64le | +| s390x | s390x | s390x | s390x | -### Fedora Container Extraction +### OCI Image Download -For Fedora, download the container image layer and extract: +For Docker Hub images: -1. Query manifest: `GET https://registry.fedoraproject.org/v2/fedora/manifests/` -2. Parse manifest, get layer digest -3. Download layer blob -4. Extract tarball +1. Get anonymous bearer token from `https://auth.docker.io/token` +2. Query manifest list: `GET https://registry.hub.docker.com/v2//manifests/` +3. Select manifest matching target architecture +4. Download layer blobs with authentication +5. Extract layers to rootfs + +If architecture is not available in manifest list, error with available architectures: + +``` +Error: No manifest found for architecture 'riscv64'. Available: amd64, arm64, ppc64le, s390x +``` ## Execution Flow -1. Parse CLI arguments and config file -2. Resolve distro/version/arch to tarball URL +1. Parse CLI arguments +2. Resolve distro/version/arch to image source 3. Check cache for existing tarball -4. If not cached, download tarball +4. If not cached, download tarball (direct or OCI) 5. Create temp directory for extraction 6. Extract tarball to temp directory 7. Create namespaces: user, pid, mount, uts 8. Set up mounts: /proc, /sys (ro), /dev, /dev/pts 9. Write /etc/resolv.conf with DNS servers -10. Set up overlay mount for workspace directory -11. Set environment variables -12. Exec shell or command in chroot -13. On exit, clean up temp directory +10. Set up overlay mounts for bind paths +11. Set up read-write bind mounts +12. Set environment variables +13. Exec shell or command in chroot +14. On exit, clean up temp directory ## Namespace Setup @@ -125,6 +154,7 @@ This makes the user appear as root inside the chroot while remaining unprivilege | /dev | devtmpfs | nosuid | | /dev/pts | devpts | nosuid,noexec | | /root/ | overlay | lowerdir=, upperdir=, workdir= | +| /mnt/ | bind | rw (for --bind-rw) | | /etc/resolv.conf | file | written with DNS | ## QEMU Integration @@ -165,6 +195,8 @@ Overlay configuration: Changes made inside the chroot are written to upperdir and discarded on exit. The host directory is never modified. +Multiple `--bind` paths can be specified, each creates an overlay at `/root/`. + Example: ``` $ cd ~/projects/myapp @@ -175,7 +207,11 @@ $ ecr ubuntu:noble -- make build ### Read-Write Bind Mount -`--bind-rw ` bypasses overlay and creates a true read-write bind mount at `/mnt/`. This modifies the host filesystem directly. Use with caution. +`--bind-rw ` creates a true read-write bind mount at `/mnt/`. This modifies the host filesystem directly. Use with caution. + +Multiple `--bind-rw` paths can be specified. If a path is specified in both `--bind` and `--bind-rw`, the read-write mount takes precedence. + +If no path is specified, defaults to current working directory. ### No Mount @@ -189,7 +225,7 @@ Default DNS server is 1.1.1.1. Configured via `/etc/resolv.conf` in chroot: nameserver 1.1.1.1 ``` -Override with `--dns` flag or config file: +Override with config file (`~/.config/ecr.yaml`): ```yaml dns: @@ -227,26 +263,3 @@ Enable with: Or check AppArmor profile restrictions. ``` - -### No Root Fallback - -There is no fallback to running as real root. The tool is designed for unprivileged use. - -## Implementation - -### Language - -Rust. - -### Dependencies - -- `nix`: Unix syscall bindings (clone, unshare, mount, chroot, namespaces) -- `serde` + `serde_yaml`: config parsing -- `reqwest`: HTTP downloads -- `tar`: tarball extraction -- `xz2`: xz decompression -- `zstd`: zstd decompression -- `flate2`: gzip decompression -- `clap`: CLI parsing -- `signal-hook`: signal handling -- `tempfile`: temp directories \ No newline at end of file diff --git a/src/chroot.rs b/src/chroot.rs new file mode 100644 index 0000000..654646a --- /dev/null +++ b/src/chroot.rs @@ -0,0 +1,144 @@ +use crate::veprintln; +use anyhow::{anyhow, Context, Result}; +use nix::unistd::{chroot, execve}; +use std::collections::HashMap; +use std::path::Path; + +/// Run a command in the chroot environment +pub fn run_chroot( + rootfs: &Path, + command: Option>, + bind_rw_paths: &[std::path::PathBuf], +) -> Result<()> { + // Get TERM from host before chroot + let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); + + // Set hostname in UTS namespace + if let Err(e) = crate::namespace::set_hostname("chroot") { + eprintln!("Warning: Failed to set hostname: {}", e); + } + + // Change to root directory in chroot + chroot(rootfs).context("Failed to chroot")?; + + // Now we're inside the chroot - set up environment based on chroot filesystem + // Determine shell path (check inside chroot, not host) + let shell = if Path::new("/bin/bash").exists() { + "/bin/bash" + } else if Path::new("/bin/sh").exists() { + "/bin/sh" + } else if Path::new("/usr/bin/bash").exists() { + "/usr/bin/bash" + } else if Path::new("/usr/bin/sh").exists() { + "/usr/bin/sh" + } else { + "/bin/sh" // Will fail with clear error if not present + }; + + // Set up environment variables (after chroot, so paths are correct) + let env = setup_environment(shell, &host_term); + + // Determine the command to run + let (program, args) = match command { + Some(cmd) if !cmd.is_empty() => { + let program = cmd[0].clone(); + let args = cmd + .iter() + .map(|s| { + std::ffi::CString::new(s.as_str()) + .with_context(|| format!("Argument contains a null byte: {:?}", s)) + }) + .collect::>>()?; + (program, args) + } + _ => { + // Run shell (already determined above based on chroot filesystem) + let program = shell.to_string(); + let args = + vec![std::ffi::CString::new(shell).context("Shell path contains a null byte")?]; + (program, args) + } + }; + + // Build an explicit envp from setup_environment so the host environment + // is never inherited. execve takes this array directly; the host process + // environment is not touched at all. + let env_cstrings = env + .iter() + .map(|(k, v)| { + std::ffi::CString::new(format!("{}={}", k, v)) + .with_context(|| format!("Environment variable contains a null byte: {}={}", k, v)) + }) + .collect::>>()?; + + // Change to first bind_rw directory if available, otherwise /root, otherwise / + // bind_rw paths are mounted at /mnt/ (see mount.rs setup_bind_rw) + let working_dir = if let Some(first_bind_rw) = bind_rw_paths.first() { + let dest_dir = Path::new("/mnt").join(first_bind_rw.file_name().unwrap_or_default()); + if dest_dir.exists() { + dest_dir + } else if Path::new("/root").exists() { + Path::new("/root").to_path_buf() + } else { + Path::new("/").to_path_buf() + } + } else if Path::new("/root").exists() { + Path::new("/root").to_path_buf() + } else { + Path::new("/").to_path_buf() + }; + std::env::set_current_dir(&working_dir).context("Failed to change to working directory")?; + + // Print welcome message + veprintln!("Entering chroot at {}", rootfs.display()); + for path in bind_rw_paths { + let basename = path + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + veprintln!("Read-write mount: /mnt/{}", basename); + } + veprintln!("Working directory: {}", working_dir.display()); + + // Check if the program exists + if !Path::new(&program).exists() { + // Try to find it in PATH + let found = env.get("PATH").and_then(|path| { + path.split(':') + .map(|p| std::path::PathBuf::from(p).join(&program)) + .find(|p| p.exists()) + }); + + if found.is_none() { + return Err(anyhow!("Program not found: {}", program)); + } + } + + // Exec the program directly with an explicit, isolated environment. + // execve never returns on success. + let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?; + + let result = execve(&program_cstr, &args, &env_cstrings); + + match result { + Ok(_) => Ok(()), // Never reached + Err(e) => Err(anyhow!("Failed to exec {}: {}", program, e)), + } +} + +/// Setup default environment variables for chroot +/// Must be called AFTER chroot so paths are resolved inside the chroot +fn setup_environment(shell: &str, term: &str) -> HashMap<&'static str, String> { + let mut env = HashMap::new(); + + env.insert("HOME", "/root".to_string()); + env.insert("USER", "root".to_string()); + env.insert("SHELL", shell.to_string()); + env.insert("TERM", term.to_string()); + env.insert( + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), + ); + + env +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..8e7d353 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,43 @@ +use clap::Parser; +use std::path::PathBuf; + +/// Enter chroot environments with Linux namespaces +#[derive(Parser, Debug, Clone)] +#[command(author, version, about, long_about = None, override_usage = "ecr [OPTIONS] -- [COMMAND]...")] +pub struct Args { + /// Distribution name (e.g., ubuntu, debian, arch, alpine, fedora) + #[arg(value_name = "DISTRO[:VERSION]")] + pub distro: String, + + /// Target architecture + #[arg(short, long, value_name = "ARCH")] + pub arch: Option, + + /// Directory to overlay-mount (can be specified multiple times, default: current directory) + #[arg(long, value_name = "PATH")] + pub bind: Vec, + + /// Directory to bind-mount read-write at /mnt/ (overrides regular bind, can be specified multiple times) + #[arg(long, value_name = "PATH")] + pub bind_rw: Vec, + + /// Download fresh tarball, ignore cache + #[arg(long)] + pub no_cache: bool, + + /// Skip mounting any directory + #[arg(long)] + pub no_bind: bool, + + /// Print diagnostic messages (URLs, manifest info, extraction steps, etc.) + #[arg(short = 'v', long)] + pub verbose: bool, + + /// Command to run inside the chroot (default: interactive shell) + #[arg( + trailing_var_arg = true, + allow_hyphen_values = true, + value_name = "COMMAND" + )] + pub command: Vec, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..836ac1f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,41 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub dns: Vec, +} + +impl Config { + pub fn load() -> Result { + let config_path = dirs::config_dir().map(|p| p.join("ecr.yaml")); + + match config_path { + Some(path) if path.exists() => { + let content = + std::fs::read_to_string(&path).context("Failed to read config file")?; + + let config: Config = + serde_yaml::from_str(&content).context("Failed to parse config file")?; + + // Set defaults + let config = Config { + dns: if config.dns.is_empty() { + vec!["1.1.1.1".to_string()] + } else { + config.dns + }, + }; + + Ok(config) + } + _ => { + // No config file, use defaults + Ok(Config { + dns: vec!["1.1.1.1".to_string()], + }) + } + } + } +} diff --git a/src/distro.rs b/src/distro.rs new file mode 100644 index 0000000..3008ac1 --- /dev/null +++ b/src/distro.rs @@ -0,0 +1,520 @@ +use anyhow::{anyhow, Context, Result}; + +/// Known distributions with optimized direct tarball downloads +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Distro { + Ubuntu, + Alpine, +} + +/// Represents the source of the container image +#[derive(Debug, Clone, PartialEq)] +pub enum ImageSource { + /// Direct tarball download from known distro + DirectTarball { + distro: Distro, + version: Option, + }, + /// OCI/Docker registry image + OciImage { + registry: String, + repository: String, + tag: String, + architecture: String, + }, +} + +impl Distro { + pub fn from_name(name: &str) -> Result { + match name.to_lowercase().as_str() { + "ubuntu" => Ok(Distro::Ubuntu), + "alpine" => Ok(Distro::Alpine), + _ => Err(anyhow!("Unknown distribution: {}", name)), + } + } +} + +/// Parse image reference and return the appropriate source +/// Supports: +/// - Simple distro names: ubuntu, debian, alpine +/// - Distro with version: ubuntu:noble, alpine:3.19 +/// - OCI image references: docker://ubuntu:latest, quay.io/centos/centos:stream9 +/// - Docker Hub shorthand: ubuntu:latest (without registry prefix) +pub fn parse_image_ref(input: &str, arch: &str) -> Result { + let input = input.trim(); + + // Check for explicit docker:// or oci:// prefix + if let Some(rest) = input + .strip_prefix("docker://") + .or_else(|| input.strip_prefix("oci://")) + { + return parse_oci_ref(rest, arch); + } + + // Check for registry prefix (contains /) + if input.contains('/') { + return parse_oci_ref(input, arch); + } + + // Try to parse as known distro + let (name, version) = match input.split_once(':') { + Some((n, v)) => (n, Some(v.to_string())), + None => (input, None), + }; + + // Check if it's a known distro with optimized path + match name.to_lowercase().as_str() { + "ubuntu" | "alpine" => { + let distro = Distro::from_name(name)?; + Ok(ImageSource::DirectTarball { distro, version }) + } + // Arch: use Docker image (has mirrors configured, unlike bootstrap tarball) + "arch" => { + let oci_arch = map_oci_arch(arch); + Ok(ImageSource::OciImage { + registry: "docker.io".to_string(), + repository: "library/archlinux".to_string(), + tag: version.unwrap_or_else(|| "latest".to_string()), + architecture: oci_arch, + }) + } + // Special case: gentoo maps to gentoo/stage3 on Docker Hub (full rootfs) + "gentoo" => { + let oci_arch = map_oci_arch(arch); + Ok(ImageSource::OciImage { + registry: "docker.io".to_string(), + repository: "gentoo/stage3".to_string(), + tag: version.unwrap_or_else(|| "latest".to_string()), + architecture: oci_arch, + }) + } + // Default to Docker Hub for unknown distros (debian, fedora, etc.) + _ => parse_oci_ref(input, arch), + } +} + +/// Parse OCI image reference (registry/repo:tag or repo:tag) +fn parse_oci_ref(input: &str, arch: &str) -> Result { + // Split off the tag. A ':' is a tag separator only when it appears after + // the last '/'; any ':' before a '/' is a port number + // (e.g. localhost:5000/image). With no '/' the single ':' is the tag. + let (without_tag, tag) = if let Some(slash_pos) = input.rfind('/') { + let after_slash = &input[slash_pos + 1..]; + if let Some(colon_pos) = after_slash.find(':') { + ( + &input[..slash_pos + 1 + colon_pos], + input[slash_pos + 1 + colon_pos + 1..].to_string(), + ) + } else { + (input, "latest".to_string()) + } + } else { + // No slash: bare "ubuntu" or "ubuntu:latest" + match input.split_once(':') { + Some((name, t)) => (name, t.to_string()), + None => (input, "latest".to_string()), + } + }; + + // Split without_tag into registry + repository. + // The first path component is the registry when it contains '.' or ':' + // (hostname / host:port) or is the literal "localhost". + let (registry, repository) = if let Some((first, rest)) = without_tag.split_once('/') { + if first.contains('.') || first.contains(':') || first == "localhost" { + (first.to_string(), rest.to_string()) + } else { + // Org-qualified Docker Hub shorthand: "myorg/myimage" + ("docker.io".to_string(), without_tag.to_string()) + } + } else { + // Bare image name → Docker Hub library image + ("docker.io".to_string(), format!("library/{}", without_tag)) + }; + + // Map architecture to OCI standard + let oci_arch = map_oci_arch(arch); + + Ok(ImageSource::OciImage { + registry, + repository, + tag, + architecture: oci_arch, + }) +} + +/// Map architecture to OCI standard names +pub fn map_oci_arch(arch: &str) -> String { + match arch { + "amd64" | "x86_64" => "amd64".to_string(), + "arm64" | "aarch64" => "arm64".to_string(), + "armhf" | "armv7" => "arm".to_string(), + "riscv64" => "riscv64".to_string(), + "ppc64el" | "ppc64le" => "ppc64le".to_string(), + "s390x" => "s390x".to_string(), + _ => arch.to_string(), + } +} + +/// Map ecr architecture names to distro-specific names +pub fn map_arch(distro: Distro, arch: &str) -> String { + match distro { + Distro::Ubuntu => match arch { + "amd64" => "amd64".to_string(), + "arm64" => "arm64".to_string(), + "armhf" => "armhf".to_string(), + "riscv64" => "riscv64".to_string(), + "ppc64el" => "ppc64el".to_string(), + "s390x" => "s390x".to_string(), + _ => arch.to_string(), + }, + Distro::Alpine => match arch { + "amd64" => "x86_64".to_string(), + "arm64" => "aarch64".to_string(), + "armhf" => "armv7".to_string(), + "riscv64" => "riscv64".to_string(), + "ppc64el" => "ppc64le".to_string(), + "s390x" => "s390x".to_string(), + _ => arch.to_string(), + }, + } +} + +/// Resolve the download URL for a known distro (optimized path) +pub fn resolve_distro_url(distro: &Distro, version: Option<&str>, arch: &str) -> Result { + match distro { + Distro::Ubuntu => resolve_ubuntu_url(version, arch), + Distro::Alpine => resolve_alpine_url(version, arch), + } +} + +/// Fetch the latest Ubuntu codename from a changelogs.ubuntu.com meta-release file. +/// Pass the LTS-only URL to get the latest LTS, or the full URL for the latest release. +fn fetch_ubuntu_codename(meta_release_url: &str) -> Result { + let text = reqwest::blocking::get(meta_release_url) + .with_context(|| format!("Failed to fetch {}", meta_release_url))? + .text() + .with_context(|| format!("Failed to read {}", meta_release_url))?; + + let mut current_dist: Option = None; + let mut latest: Option = None; + + for line in text.lines() { + if let Some(dist) = line.strip_prefix("Dist: ") { + current_dist = Some(dist.trim().to_string()); + } else if line.trim_start().starts_with("Supported: 1") { + if let Some(dist) = current_dist.take() { + latest = Some(dist); + } + } + } + + latest.ok_or_else(|| { + anyhow!( + "Could not determine Ubuntu codename from {}", + meta_release_url + ) + }) +} + +/// Fetch the current Alpine minirootfs version from latest-releases.yaml on the CDN. +/// The `latest-stable/` directory is a server-side symlink; the YAML file it contains +/// tells us the exact version number needed for the tarball filename. +/// Fetch the latest minirootfs version for a given Alpine CDN branch. +/// `branch` is the directory name on the CDN, e.g. `"latest-stable"` or `"v3.23"`. +fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result { + #[derive(serde::Deserialize)] + struct AlpineRelease { + file: Option, + version: Option, + } + + let url = format!( + "https://dl-cdn.alpinelinux.org/alpine/{}/releases/{}/latest-releases.yaml", + branch, arch + ); + let text = reqwest::blocking::get(&url) + .with_context(|| { + format!( + "Failed to fetch Alpine latest-releases.yaml for branch {}", + branch + ) + })? + .text() + .context("Failed to read Alpine latest-releases.yaml")?; + + let releases: Vec = + serde_yaml::from_str(&text).context("Failed to parse Alpine latest-releases.yaml")?; + + for release in releases { + let is_minirootfs = release + .file + .as_deref() + .map(|f| f.contains("minirootfs")) + .unwrap_or(false); + if is_minirootfs { + // Prefer explicit `version:` field; fall back to parsing the filename. + // Filename format: alpine-minirootfs-3.23.0-x86_64.tar.gz + if let Some(v) = release.version { + return Ok(v); + } + if let Some(v) = release.file.as_deref().and_then(|f| f.split('-').nth(2)) { + return Ok(v.to_string()); + } + } + } + + Err(anyhow!( + "Could not find minirootfs entry in Alpine latest-releases.yaml for branch {}", + branch + )) +} + +fn fetch_alpine_latest_version(arch: &str) -> Result { + fetch_alpine_version_from_branch("latest-stable", arch) +} + +/// Resolve a `major.minor` Alpine series (e.g. `"3.24"`) to its current +/// patch release by querying the CDN branch `v{minor}`. +fn fetch_alpine_minor_version(minor: &str, arch: &str) -> Result { + fetch_alpine_version_from_branch(&format!("v{}", minor), arch) +} + +/// Resolve the canonical version string for a distro, performing a network lookup +/// only when the requested version is a floating alias (e.g. "latest", "lts"). +/// The returned string is suitable for use as a stable cache key. +pub fn resolve_distro_version( + distro: &Distro, + version: Option<&str>, + arch: &str, +) -> Result { + match distro { + Distro::Ubuntu => resolve_ubuntu_version(version), + Distro::Alpine => resolve_alpine_version(version, arch), + } +} + +/// Build a map of YY.MM version strings to codenames by parsing the Ubuntu +/// meta-release file (e.g. "24.04" → "noble", "22.04" → "jammy"). +/// Uses the full meta-release (not -lts) so non-LTS versions are also covered. +fn fetch_ubuntu_version_map() -> Result> { + let text = reqwest::blocking::get("https://changelogs.ubuntu.com/meta-release") + .context("Failed to fetch Ubuntu meta-release")? + .text() + .context("Failed to read Ubuntu meta-release")?; + + let mut map = std::collections::HashMap::new(); + let mut current_dist: Option = None; + + for line in text.lines() { + if let Some(dist) = line.strip_prefix("Dist: ") { + current_dist = Some(dist.trim().to_string()); + } else if let Some(version_str) = line.strip_prefix("Version: ") { + // Version field may be "22.04", "22.04 LTS", or "24.04.1 LTS". + // Normalise to YY.MM by taking the first two dot-separated components. + let raw = version_str.split_whitespace().next().unwrap_or(""); + let normalised: String = { + let mut parts = raw.splitn(3, '.'); + match (parts.next(), parts.next()) { + (Some(a), Some(b)) => format!("{}.{}", a, b), + _ => raw.to_string(), + } + }; + if let Some(dist) = ¤t_dist { + map.insert(normalised, dist.clone()); + } + } + } + + Ok(map) +} + +fn resolve_ubuntu_version(version: Option<&str>) -> Result { + let version = version.unwrap_or("latest"); + match version { + "latest" => fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release"), + "lts" | "latest-lts" => { + fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release-lts") + } + other => { + // If it looks like a YY.MM version number, resolve it to a codename + // via the meta-release file so the mapping never goes stale. + if is_ubuntu_version_number(other) { + let map = fetch_ubuntu_version_map()?; + map.get(other).cloned().ok_or_else(|| { + anyhow!( + "Unknown Ubuntu version '{}'. \ + Use the codename directly (e.g. noble, jammy) or check \ + https://changelogs.ubuntu.com/meta-release", + other + ) + }) + } else { + // Treat as a codename and pass through (e.g. "noble", "jammy") + Ok(other.to_string()) + } + } + } +} + +/// Returns true for strings of the form "YY.MM" (two numeric dot-separated components). +fn is_ubuntu_version_number(s: &str) -> bool { + let mut parts = s.splitn(3, '.'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(a), Some(b), None) + if !a.is_empty() && !b.is_empty() + && a.chars().all(|c| c.is_ascii_digit()) + && b.chars().all(|c| c.is_ascii_digit()) + ) +} + +fn resolve_alpine_version(version: Option<&str>, arch: &str) -> Result { + let alpine_arch = map_arch(Distro::Alpine, arch); + Ok(match version.unwrap_or("latest") { + "latest" | "stable" => fetch_alpine_latest_version(&alpine_arch)?, + // edge is a rolling branch; query the CDN to get the current + // date-stamped version (e.g. "20250401") so the URL and cache key + // are correct. resolve_alpine_url maps this date string back to the + // "edge" CDN directory via the all-digits guard in its release match. + "edge" => fetch_alpine_version_from_branch("edge", &alpine_arch)?, + v => { + // "3.23" — one dot: major.minor series → fetch current patch from CDN + // "3.23.0" — two dots: full version already → pass through as-is + let dots = v.chars().filter(|&c| c == '.').count(); + if dots == 1 { + fetch_alpine_minor_version(v, &alpine_arch)? + } else { + v.to_string() + } + } + }) +} + +fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result { + let codename = resolve_ubuntu_version(version)?; + let arch = map_arch(Distro::Ubuntu, arch); + Ok(format!( + "https://cdimage.ubuntu.com/ubuntu-base/{}/daily/current/{}-base-{}.tar.gz", + codename, codename, arch + )) +} + +fn resolve_alpine_url(version: Option<&str>, arch: &str) -> Result { + let version_str = version.unwrap_or("latest"); + let alpine_arch = map_arch(Distro::Alpine, arch); + let version_num = resolve_alpine_version(Some(version_str), arch)?; + + // Derive the CDN release directory from the version string. + // After resolve_distro_version runs, version_str may already be the + // *resolved* value rather than the original alias: + // "latest"/"stable" → e.g. "3.23.1" (dots present → v3.23 below) + // "edge" → e.g. "20250401" (all digits, no dots) + // "3.23" → e.g. "3.23.1" (dots present → v3.23 below) + // The all-digit check catches resolved edge dates and maps them back to + // the "edge" CDN directory. + let release = match version_str { + "latest" | "stable" => "latest-stable".to_string(), + "edge" => "edge".to_string(), + v if v.chars().all(|c| c.is_ascii_digit()) => "edge".to_string(), + other => { + // "3.23" or "3.23.1" → "v3.23" + let mut parts = other.splitn(3, '.'); + let major = parts.next().unwrap_or("0"); + let minor = parts.next().unwrap_or("0"); + format!("v{}.{}", major, minor) + } + }; + + Ok(format!( + "https://dl-cdn.alpinelinux.org/alpine/{}/releases/{}/alpine-minirootfs-{}-{}.tar.gz", + release, alpine_arch, version_num, alpine_arch + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn oci(registry: &str, repository: &str, tag: &str) -> ImageSource { + ImageSource::OciImage { + registry: registry.to_string(), + repository: repository.to_string(), + tag: tag.to_string(), + architecture: "amd64".to_string(), + } + } + + fn parse(input: &str) -> ImageSource { + parse_oci_ref(input, "amd64").expect("parse failed") + } + + #[test] + fn test_bare_name() { + // "ubuntu" → Docker Hub library, tag=latest + let src = parse("ubuntu"); + assert_eq!(src, oci("docker.io", "library/ubuntu", "latest")); + } + + #[test] + fn test_name_with_tag() { + let src = parse("ubuntu:noble"); + assert_eq!(src, oci("docker.io", "library/ubuntu", "noble")); + } + + #[test] + fn test_org_repo() { + let src = parse("myorg/myimage:v2"); + assert_eq!(src, oci("docker.io", "myorg/myimage", "v2")); + } + + #[test] + fn test_registry_with_port_no_tag() { + // The colon in "localhost:5000" must NOT be treated as a tag separator + let src = parse("localhost:5000/myimage"); + assert_eq!(src, oci("localhost:5000", "myimage", "latest")); + } + + #[test] + fn test_registry_with_port_and_tag() { + let src = parse("localhost:5000/myimage:v1"); + assert_eq!(src, oci("localhost:5000", "myimage", "v1")); + } + + #[test] + fn test_registry_with_port_and_org() { + let src = parse("localhost:5000/org/myimage:v1"); + assert_eq!(src, oci("localhost:5000", "org/myimage", "v1")); + } + + #[test] + fn test_named_registry() { + let src = parse("quay.io/centos/centos:stream9"); + assert_eq!(src, oci("quay.io", "centos/centos", "stream9")); + } + + #[test] + fn test_docker_hub_fqdn() { + let src = parse("registry-1.docker.io/library/ubuntu:noble"); + assert_eq!(src, oci("registry-1.docker.io", "library/ubuntu", "noble")); + } + + /// After resolve_distro_version, alpine:edge carries a date string like + /// "20250401". resolve_alpine_url must still produce a URL under the + /// "edge" CDN directory, not a bogus "v20250401.0" directory. + #[test] + fn test_alpine_edge_resolved_date_uses_edge_directory() { + // Simulate the already-resolved version that main.rs stores after calling + // resolve_distro_version("edge", …). + let url = resolve_alpine_url(Some("20250401"), "amd64").unwrap(); + assert!( + url.contains("/alpine/edge/"), + "expected URL to contain '/alpine/edge/' but got: {}", + url + ); + assert!( + url.contains("minirootfs-20250401-"), + "expected URL to contain 'minirootfs-20250401-' but got: {}", + url + ); + } +} diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..8266759 --- /dev/null +++ b/src/download.rs @@ -0,0 +1,703 @@ +use crate::veprintln; +use anyhow::{anyhow, Context, Result}; +use futures_util::StreamExt; +use indicatif::{ProgressBar, ProgressStyle}; +use reqwest::Client; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::distro::ImageSource; + +/// Path of the digest sidecar file for a cached OCI image. +/// e.g. `foo.tar.gz` → `foo.tar.gz.digest` +pub fn digest_sidecar(cache_path: &Path) -> PathBuf { + let mut s = cache_path.as_os_str().to_owned(); + s.push(".digest"); + PathBuf::from(s) +} + +/// Fetch the current manifest digest for an OCI tag without downloading any +/// layers. The registry returns the content-addressable digest in the +/// `Docker-Content-Digest` response header of any manifest GET. +pub fn fetch_oci_digest(registry: &str, repository: &str, tag: &str) -> Result { + tokio::runtime::Runtime::new() + .context("Failed to create Tokio runtime")? + .block_on(fetch_oci_digest_async(registry, repository, tag)) +} + +async fn fetch_oci_digest_async(registry: &str, repository: &str, tag: &str) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + let token = get_auth_token(&client, registry, repository).await?; + + let manifest_url = if registry == "docker.io" { + format!( + "https://registry-1.docker.io/v2/{}/manifests/{}", + repository, tag + ) + } else { + format!("https://{}/v2/{}/manifests/{}", registry, repository, tag) + }; + + let response = client + .get(&manifest_url) + .header( + "Accept", + "application/vnd.docker.distribution.manifest.list.v2+json, \ + application/vnd.docker.distribution.manifest.v2+json, \ + application/vnd.oci.image.index.v1+json, \ + application/vnd.oci.image.manifest.v1+json", + ) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .context("Failed to fetch manifest for digest check")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Manifest fetch returned HTTP {}", + response.status() + )); + } + + response + .headers() + .get("Docker-Content-Digest") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("Registry did not return a Docker-Content-Digest header")) +} + +/// Download a container image (either direct tarball or OCI image) +pub fn download_image(source: &ImageSource, dest: &Path, arch: &str) -> Result<()> { + // Create a single Tokio runtime for all async I/O in this download. + // Previously each of the two sync wrappers (download_file_sync and + // download_oci_image) created their own runtime; this consolidates them. + let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; + + match source { + ImageSource::DirectTarball { distro, version } => { + let url = crate::distro::resolve_distro_url(distro, version.as_deref(), arch)?; + veprintln!("Resolved URL: {}", url); + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(anyhow!("Unsupported URL scheme: {}", url)); + } + rt.block_on(download_file_async(&url, dest)) + } + ImageSource::OciImage { + registry, + repository, + tag, + architecture, + } => { + veprintln!("Pulling OCI image: {}/{}:{}", registry, repository, tag); + rt.block_on(download_oci_image_async( + registry, + repository, + tag, + architecture, + dest, + )) + } + } +} + +async fn download_file_async(url: &str, dest: &Path) -> Result<()> { + veprintln!("Downloading: {}", url); + + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .context("Failed to create HTTP client")?; + + let response = client + .get(url) + .send() + .await + .context("Failed to start download")?; + + if !response.status().is_success() { + return Err(anyhow!("Download failed: HTTP {}", response.status())); + } + + let total_size = response.content_length().unwrap_or(0); + + // Create parent directories + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("Failed to create cache directory")?; + } + + // Setup progress bar + let pb = ProgressBar::new(total_size); + pb.set_style(ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") + .unwrap() + .progress_chars("#>-")); + + let temp_dest = dest.with_extension("partial"); + + // Download into the temp file, then atomically rename to the final path. + // On any failure after the temp file is created we remove it so stale + // .partial files don't accumulate in the cache directory. + let result: Result<()> = async { + let mut file = tokio::fs::File::create(&temp_dest) + .await + .context("Failed to create temporary file")?; + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Download interrupted")?; + tokio::io::copy(&mut &chunk[..], &mut file).await?; + pb.inc(chunk.len() as u64); + } + + pb.finish_and_clear(); + + tokio::fs::rename(&temp_dest, dest) + .await + .context("Failed to move partial download to final destination")?; + veprintln!("Download complete: {}", dest.display()); + + Ok(()) + } + .await; + + if result.is_err() { + // Best-effort cleanup; ignore errors (file may not exist if creation failed). + let _ = tokio::fs::remove_file(&temp_dest).await; + } + + result +} + +async fn download_oci_image_async( + registry: &str, + repository: &str, + tag: &str, + arch: &str, + dest: &Path, +) -> Result<()> { + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .context("Failed to create HTTP client")?; + + let digest = try_download_oci_image(&client, registry, repository, tag, arch, dest).await?; + + // Persist the manifest digest so the next run can skip the download when + // the tag hasn't changed (see fetch_oci_digest / digest_sidecar). + if !digest.is_empty() { + let _ = std::fs::write(digest_sidecar(dest), &digest); + } + + Ok(()) +} + +async fn try_download_oci_image( + client: &Client, + registry: &str, + repository: &str, + tag: &str, + arch: &str, + dest: &Path, +) -> Result { + // Get authentication token for the registry + let token = get_auth_token(client, registry, repository).await?; + + // Construct manifest URL based on registry + let manifest_url = if registry == "docker.io" { + // Docker Hub uses registry-1.docker.io for API + format!( + "https://registry-1.docker.io/v2/{}/manifests/{}", + repository, tag + ) + } else { + format!("https://{}/v2/{}/manifests/{}", registry, repository, tag) + }; + + veprintln!("Fetching manifest from: {}", manifest_url); + + // Request both manifest list and single manifest types (including OCI formats) + let response = client + .get(&manifest_url) + .header("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json") + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .context("Failed to get image manifest")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Failed to get manifest: HTTP {}", + response.status() + )); + } + + // Capture the tag-level digest before consuming the response body. + // This is the content-addressable identity of the manifest (or manifest + // list) at this tag — used to detect whether the tag has moved since the + // last download. + let tag_digest = response + .headers() + .get("Docker-Content-Digest") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + let body = response.text().await?; + let manifest: Value = serde_json::from_str(&body).context("Failed to parse manifest JSON")?; + + // Check if this is a manifest list (multi-arch) + let layers = if let Some(manifests) = manifest["manifests"].as_array() { + // This is a manifest list - find the right architecture + veprintln!("Got manifest list with {} manifests", manifests.len()); + + let manifest_entry = manifests + .iter() + .find(|m| { + let m_arch = m["platform"]["architecture"].as_str().unwrap_or(""); + let m_os = m["platform"]["os"].as_str().unwrap_or(""); + m_arch == arch && m_os == "linux" + }) + .ok_or_else(|| { + // List available architectures in the error message + let available: Vec<&str> = manifests + .iter() + .filter_map(|m| m["platform"]["architecture"].as_str()) + .collect(); + anyhow!( + "No manifest found for architecture '{}'. Available: {}", + arch, + available.join(", ") + ) + })?; + + let manifest_digest = manifest_entry["digest"] + .as_str() + .ok_or_else(|| anyhow!("No digest in manifest entry"))?; + + veprintln!("Found manifest digest: {}", manifest_digest); + + // Now get the actual manifest for this architecture + let arch_manifest_url = if registry == "docker.io" { + format!( + "https://registry-1.docker.io/v2/{}/manifests/{}", + repository, manifest_digest + ) + } else { + format!( + "https://{}/v2/{}/manifests/{}", + registry, repository, manifest_digest + ) + }; + + let arch_response = client + .get(&arch_manifest_url) + .header("Accept", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json") + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .context("Failed to get architecture manifest")?; + + if !arch_response.status().is_success() { + return Err(anyhow!( + "Failed to get arch manifest: HTTP {}", + arch_response.status() + )); + } + + let arch_body = arch_response.text().await?; + let arch_manifest: Value = serde_json::from_str(&arch_body) + .context("Failed to parse architecture manifest JSON")?; + + // Get all layers from architecture manifest (in order) + arch_manifest["layers"] + .as_array() + .ok_or_else(|| anyhow!("No layers in architecture manifest"))? + .clone() + } else { + // Single architecture manifest - get all layers in order + manifest["layers"] + .as_array() + .ok_or_else(|| anyhow!("No layers in manifest"))? + .clone() + }; + + veprintln!("Found {} layers to download", layers.len()); + + // Download all layer blobs into a temp directory + let temp_dir = tempfile::tempdir()?; + + // layer_names[i] is the filename used both for the downloaded blob and in + // layers.manifest. The extension is derived from the layer's mediaType so + // extract_oci_layer can dispatch on the filename without relying on + // magic-byte detection (which would misfire on a zstd blob named .tar.gz). + let mut layer_names: Vec = Vec::with_capacity(layers.len()); + + for (i, layer) in layers.iter().enumerate() { + let layer_digest = layer["digest"] + .as_str() + .ok_or_else(|| anyhow!("No digest in layer"))?; + + let media_type = layer["mediaType"].as_str().unwrap_or(""); + let ext = media_type_to_extension(media_type); + let layer_name = format!("layer_{}.{}", i, ext); + + veprintln!( + "Fetching layer {}/{}: {}", + i + 1, + layers.len(), + layer_digest + ); + + let blob_url = if registry == "docker.io" { + format!( + "https://registry-1.docker.io/v2/{}/blobs/{}", + repository, layer_digest + ) + } else { + format!( + "https://{}/v2/{}/blobs/{}", + registry, repository, layer_digest + ) + }; + + let layer_path = temp_dir.path().join(&layer_name); + let label = format!("Layer {}/{} ({})", i + 1, layers.len(), layer_name); + download_blob_with_auth(client, &blob_url, &layer_path, &token, &label).await?; + layer_names.push(layer_name); + } + + // Write the layer index so extract.rs knows the order + { + use std::io::Write; + let mut manifest_file = std::fs::File::create(temp_dir.path().join("layers.manifest"))?; + for name in &layer_names { + writeln!(manifest_file, "{}", name)?; + } + } + + // Bundle layers.manifest + all layer blobs into a single .tar.gz cache file + // using the tar + flate2 crates — no external `tar` binary required. + let temp_dest = dest.with_extension("tar.partial"); + + let bundle_result: Result<()> = (|| { + use flate2::{write::GzEncoder, Compression}; + + let out_file = + std::fs::File::create(&temp_dest).context("Failed to create temporary bundle file")?; + let mut builder = tar::Builder::new(GzEncoder::new(out_file, Compression::default())); + + builder + .append_path_with_name(temp_dir.path().join("layers.manifest"), "layers.manifest") + .context("Failed to add layers.manifest to bundle")?; + + for name in &layer_names { + builder + .append_path_with_name(temp_dir.path().join(name), name) + .with_context(|| format!("Failed to add {} to bundle", name))?; + } + + // into_inner finalises the tar end-of-archive marker and returns the + // GzEncoder; finish() flushes and closes the gzip stream. + builder + .into_inner() + .context("Failed to finalise tar archive")? + .finish() + .context("Failed to finalise gzip stream")?; + + std::fs::rename(&temp_dest, dest).context("Failed to move bundle to cache destination") + })(); + + if bundle_result.is_err() { + // Best-effort cleanup; ignore errors (file may not exist if creation failed). + let _ = std::fs::remove_file(&temp_dest); + } + bundle_result?; + + Ok(tag_digest) +} + +async fn get_auth_token(client: &Client, registry: &str, repository: &str) -> Result { + // Docker Hub has a well-known, stable auth endpoint. + if registry == "docker.io" { + let url = format!( + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:{}:pull", + repository + ); + veprintln!("Getting auth token from: {}", url); + return fetch_bearer_token(client, &url).await; + } + + // For every other registry follow the OCI Distribution Spec §4.2: + // 1. Probe GET /v2/ unauthenticated. + // 2. Read the WWW-Authenticate challenge from the 401 response. + // 3. Build the token URL from the advertised realm + service. + let probe_url = format!("https://{}/v2/", registry); + let probe = client + .get(&probe_url) + .send() + .await + .with_context(|| format!("Failed to probe registry at {}", probe_url))?; + + match probe.status().as_u16() { + 200 => { + // Registry requires no authentication (e.g. local insecure registry). + return Ok(String::new()); + } + 401 => {} + other => { + // Unexpected status; proceed without a token and let the manifest + // request fail with a more descriptive error. + eprintln!( + "Warning: registry probe returned HTTP {}; trying without auth", + other + ); + return Ok(String::new()); + } + } + + // Parse the WWW-Authenticate challenge. + let www_auth = probe + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + anyhow!( + "Registry {} returned 401 but no WWW-Authenticate header", + registry + ) + })?; + + let (realm, service) = parse_www_authenticate(www_auth) + .ok_or_else(|| anyhow!("Could not parse WWW-Authenticate header: {}", www_auth))?; + + // The probe scope is generic; override it with the per-repository pull scope. + let token_url = format!( + "{}?service={}&scope=repository:{}:pull", + realm, service, repository + ); + veprintln!("Getting auth token from: {}", token_url); + fetch_bearer_token(client, &token_url).await +} + +/// Parse a `Bearer realm="...",service="..."[,scope="..."]` header value. +/// Returns `(realm, service)` on success. +fn parse_www_authenticate(header: &str) -> Option<(String, String)> { + let params = header.strip_prefix("Bearer ")?; + + let mut realm: Option = None; + let mut service: Option = None; + + // Split on commas that lie outside quoted strings. + let mut start = 0; + let mut in_quotes = false; + let bytes = params.as_bytes(); + let mut parts: Vec<&str> = Vec::new(); + for i in 0..bytes.len() { + match bytes[i] { + b'"' => in_quotes = !in_quotes, + b',' if !in_quotes => { + parts.push(params[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + parts.push(params[start..].trim()); + + for part in parts { + if let Some(val) = part.strip_prefix("realm=") { + realm = Some(val.trim_matches('"').to_string()); + } else if let Some(val) = part.strip_prefix("service=") { + service = Some(val.trim_matches('"').to_string()); + } + } + + Some((realm?, service?)) +} + +/// Fetch a bearer token from a fully-constructed token URL. +async fn fetch_bearer_token(client: &Client, token_url: &str) -> Result { + let response = client + .get(token_url) + .send() + .await + .context("Failed to request auth token")?; + + if !response.status().is_success() { + // Registry may allow anonymous pulls; proceed with an empty token. + return Ok(String::new()); + } + + let body = response.text().await?; + let json: Value = serde_json::from_str(&body).context("Failed to parse auth token response")?; + + // OCI spec uses "token"; Docker also emits "access_token". + json["token"] + .as_str() + .or_else(|| json["access_token"].as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("No token field in auth response: {}", body)) +} + +async fn download_blob_with_auth( + client: &Client, + url: &str, + dest: &Path, + token: &str, + label: &str, +) -> Result<()> { + let mut request = client.get(url); + if !token.is_empty() { + request = request.header("Authorization", format!("Bearer {}", token)); + } + + let response = request.send().await.context("Failed to start download")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Failed to download blob: HTTP {}", + response.status() + )); + } + + let total_size = response.content_length().unwrap_or(0); + + let pb = ProgressBar::new(total_size); + pb.set_style( + ProgressStyle::default_bar() + .template("{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") + .unwrap() + .progress_chars("#>-"), + ); + pb.set_message(label.to_string()); + + let mut file = tokio::fs::File::create(dest) + .await + .context("Failed to create destination file")?; + + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read chunk")?; + file.write_all(&chunk) + .await + .context("Failed to write chunk")?; + pb.inc(chunk.len() as u64); + } + + file.flush().await?; + pb.finish_and_clear(); + + Ok(()) +} + +/// Map an OCI layer mediaType to the appropriate file extension. +/// The extension is stored in layers.manifest and used by extract_oci_layer +/// for compression dispatch, so it must accurately reflect the blob encoding. +fn media_type_to_extension(media_type: &str) -> &'static str { + match media_type { + // Docker V2 schema 2 + "application/vnd.docker.image.rootfs.diff.tar.gzip" => "tar.gz", + // OCI image spec + "application/vnd.oci.image.layer.v1.tar+gzip" => "tar.gz", + "application/vnd.oci.image.layer.v1.tar+zstd" => "tar.zst", + "application/vnd.oci.image.layer.v1.tar" => "tar", + // Non-distributable variants (same encoding, different semantics) + "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip" => "tar.gz", + "application/vnd.oci.image.layer.nondistributable.v1.tar+zstd" => "tar.zst", + "application/vnd.oci.image.layer.nondistributable.v1.tar" => "tar", + // Unknown: fall back to gzip (historically the most common format) + // and let magic-byte detection in extract_oci_layer handle it. + _ => "tar.gz", + } +} + +#[cfg(test)] +mod tests { + use super::parse_www_authenticate; + + #[test] + fn test_docker_hub_challenge() { + let hdr = r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/ubuntu:pull""#; + let (realm, service) = parse_www_authenticate(hdr).unwrap(); + assert_eq!(realm, "https://auth.docker.io/token"); + assert_eq!(service, "registry.docker.io"); + } + + #[test] + fn test_quay_challenge() { + let hdr = r#"Bearer realm="https://quay.io/v2/auth",service="quay.io",scope="repository:centos/centos:pull""#; + let (realm, service) = parse_www_authenticate(hdr).unwrap(); + assert_eq!(realm, "https://quay.io/v2/auth"); + assert_eq!(service, "quay.io"); + } + + #[test] + fn test_ghcr_challenge() { + let hdr = r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:owner/repo:pull""#; + let (realm, service) = parse_www_authenticate(hdr).unwrap(); + assert_eq!(realm, "https://ghcr.io/token"); + assert_eq!(service, "ghcr.io"); + } + + #[test] + fn test_gcr_challenge() { + let hdr = r#"Bearer realm="https://gcr.io/v2/token",service="gcr.io""#; + let (realm, service) = parse_www_authenticate(hdr).unwrap(); + assert_eq!(realm, "https://gcr.io/v2/token"); + assert_eq!(service, "gcr.io"); + } + + #[test] + fn test_value_with_comma_in_scope() { + // scope field itself contains no comma but realm/service values may + // contain other special chars — ensure the quoted-comma splitter works + let hdr = r#"Bearer realm="https://example.com/auth",service="example.com",scope="repository:a/b:pull""#; + let (realm, service) = parse_www_authenticate(hdr).unwrap(); + assert_eq!(realm, "https://example.com/auth"); + assert_eq!(service, "example.com"); + } + + #[test] + fn test_not_bearer_returns_none() { + assert!(parse_www_authenticate("Basic realm=\"registry\"").is_none()); + } + + #[test] + fn test_media_type_to_extension_known_types() { + use super::media_type_to_extension; + assert_eq!( + media_type_to_extension("application/vnd.docker.image.rootfs.diff.tar.gzip"), + "tar.gz" + ); + assert_eq!( + media_type_to_extension("application/vnd.oci.image.layer.v1.tar+gzip"), + "tar.gz" + ); + assert_eq!( + media_type_to_extension("application/vnd.oci.image.layer.v1.tar+zstd"), + "tar.zst" + ); + assert_eq!( + media_type_to_extension("application/vnd.oci.image.layer.v1.tar"), + "tar" + ); + } + + #[test] + fn test_media_type_to_extension_unknown_falls_back_to_gz() { + use super::media_type_to_extension; + assert_eq!(media_type_to_extension(""), "tar.gz"); + assert_eq!(media_type_to_extension("text/plain"), "tar.gz"); + } +} diff --git a/src/extract.rs b/src/extract.rs new file mode 100644 index 0000000..670fcde --- /dev/null +++ b/src/extract.rs @@ -0,0 +1,312 @@ +use crate::veprintln; +use anyhow::{Context, Result}; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::Path; + +/// Extract a tarball to the specified directory +pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> { + let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // OCI bundle files are always named "oci-…" by generate_cache_filename. + // Use the filename prefix as a zero-I/O dispatch signal instead of + // scanning the archive for a layers.manifest entry (which required + // reading the entire compressed archive twice for large OCI images). + if filename.starts_with("oci-") { + veprintln!("Detected multi-layer OCI image, extracting layers..."); + return extract_multi_layer_oci(tarball, dest); + } + + let file = File::open(tarball) + .with_context(|| format!("Failed to open tarball: {}", tarball.display()))?; + + let reader = BufReader::new(file); + + // Detect compression format from filename + if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + extract_gz(reader, dest)?; + } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { + extract_xz(reader, dest)?; + } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { + extract_zst(reader, dest)?; + } else if filename.ends_with(".tar") { + extract_tar(reader, dest)?; + } else { + // Try to detect from magic bytes + let mut magic = [0u8; 6]; + let mut peek_reader = BufReader::new(File::open(tarball)?); + peek_reader.read_exact(&mut magic)?; + + match magic { + [0x1f, 0x8b, ..] => { + // gzip magic + drop(peek_reader); + let file = File::open(tarball)?; + extract_gz(BufReader::new(file), dest)?; + } + [0xfd, b'7', b'z', b'X', b'Z', 0x00] => { + // xz magic + drop(peek_reader); + let file = File::open(tarball)?; + extract_xz(BufReader::new(file), dest)?; + } + [0x28, 0xb5, 0x2f, 0xfd, ..] => { + // zstd magic + drop(peek_reader); + let file = File::open(tarball)?; + extract_zst(BufReader::new(file), dest)?; + } + _ => { + // Assume uncompressed tar + drop(peek_reader); + let file = File::open(tarball)?; + extract_tar(BufReader::new(file), dest)?; + } + } + } + + Ok(()) +} + +/// Extract a multi-layer OCI image +fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> { + let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files) + // into a temp directory. This is our own format, not an OCI layer, so + // plain unpack is fine here. + let temp_dir = tempfile::tempdir().context("Failed to create temp directory for OCI layers")?; + + let file = File::open(tarball) + .with_context(|| format!("Failed to open OCI tarball: {}", tarball.display()))?; + let reader = BufReader::new(file); + + if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + tar::Archive::new(flate2::read::GzDecoder::new(reader)) + .unpack(temp_dir.path()) + .context("Failed to unpack OCI bundle")?; + } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { + tar::Archive::new(xz2::read::XzDecoder::new(reader)) + .unpack(temp_dir.path()) + .context("Failed to unpack OCI bundle")?; + } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { + tar::Archive::new(zstd::stream::read::Decoder::new(reader)?) + .unpack(temp_dir.path()) + .context("Failed to unpack OCI bundle")?; + } else { + tar::Archive::new(reader) + .unpack(temp_dir.path()) + .context("Failed to unpack OCI bundle")?; + } + + // Read the layers manifest + let manifest = std::fs::read_to_string(temp_dir.path().join("layers.manifest")) + .context("Failed to read layers.manifest")?; + + // Apply each layer in order with full whiteout handling. + for layer_name in manifest.lines() { + let layer_path = temp_dir.path().join(layer_name); + if !layer_path.exists() { + continue; + } + veprintln!("Extracting layer: {}", layer_name); + extract_oci_layer(&layer_path, dest)?; + } + + Ok(()) +} + +/// Decompress and extract one OCI layer tarball into `dest`, honouring +/// whiteout markers. Compression is inferred from the filename then from +/// magic bytes. +fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> { + use std::io::Read; + let layer_name = layer_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + let file = File::open(layer_path) + .with_context(|| format!("Failed to open layer: {}", layer_path.display()))?; + let reader = BufReader::new(file); + + if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") { + extract_archive_with_whiteouts( + tar::Archive::new(flate2::read::GzDecoder::new(reader)), + dest, + ) + } else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") { + extract_archive_with_whiteouts(tar::Archive::new(xz2::read::XzDecoder::new(reader)), dest) + } else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") { + extract_archive_with_whiteouts( + tar::Archive::new(zstd::stream::read::Decoder::new(reader)?), + dest, + ) + } else { + // Fall back to magic-byte detection + let mut magic = [0u8; 6]; + let mut peek = BufReader::new(File::open(layer_path)?); + let _ = peek.read_exact(&mut magic); // short reads are fine for detection + drop(peek); + match magic { + [0x1f, 0x8b, ..] => extract_archive_with_whiteouts( + tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open( + layer_path, + )?))), + dest, + ), + [0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_archive_with_whiteouts( + tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open( + layer_path, + )?))), + dest, + ), + [0x28, 0xb5, 0x2f, 0xfd, ..] => extract_archive_with_whiteouts( + tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new( + File::open(layer_path)?, + ))?), + dest, + ), + _ => extract_archive_with_whiteouts( + tar::Archive::new(BufReader::new(File::open(layer_path)?)), + dest, + ), + } + } +} + +/// Apply one OCI layer archive to `dest`, interpreting Docker whiteout markers: +/// +/// - `.wh.` — Delete `` from a lower layer that was already +/// extracted into `dest`. +/// - `.wh..wh..opq` — Opaque whiteout: the directory that contains this entry +/// is new in this layer; delete everything already in that +/// directory from lower layers before applying new content. +/// +/// All other entries are extracted normally via `Entry::unpack_in`. +fn extract_archive_with_whiteouts( + mut archive: tar::Archive, + dest: &Path, +) -> Result<()> { + archive.set_preserve_permissions(true); + archive.set_preserve_ownerships(false); + archive.set_unpack_xattrs(false); + + for entry in archive.entries().context("Failed to iterate tar entries")? { + let mut entry = entry.context("Failed to read tar entry")?; + + // Clone the path before any mutable borrow of entry (needed for unpack_in) + let path = entry.path().context("Invalid tar entry path")?.into_owned(); + + let filename = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if filename == ".wh..wh..opq" { + // Opaque whiteout: clear all previously-extracted content in the + // parent directory so only this layer's content is visible. + let parent = path.parent().unwrap_or(Path::new("")); + let dest_dir = dest.join(parent); + if dest_dir.symlink_metadata().is_ok() { + for child in std::fs::read_dir(&dest_dir) + .with_context(|| format!("Failed to read {}", dest_dir.display()))? + { + let child = child?; + let child_path = child.path(); + remove_path(&child_path).with_context(|| { + format!("Opaque whiteout: failed to remove {}", child_path.display()) + })?; + } + } + // Do not extract the .wh..wh..opq marker itself. + } else if let Some(real_name) = filename.strip_prefix(".wh.") { + // Regular whiteout: delete the named path from lower layers. + let parent = path.parent().unwrap_or(Path::new("")); + let target = dest.join(parent).join(real_name); + // symlink_metadata (lstat) does not follow symlinks, so a dangling + // symlink is correctly detected and removed rather than silently skipped. + if target.symlink_metadata().is_ok() { + remove_path(&target) + .with_context(|| format!("Whiteout: failed to remove {}", target.display()))?; + } + // Do not extract the .wh.* marker itself. + } else { + entry + .unpack_in(dest) + .with_context(|| format!("Failed to extract {}", path.display()))?; + } + } + Ok(()) +} + +/// Remove a path: uses remove_dir_all for real directories, remove_file for +/// everything else (regular files, symlinks — including symlinks-to-dirs). +fn remove_path(path: &Path) -> std::io::Result<()> { + // symlink_metadata does not follow symlinks, so a symlink-to-dir correctly + // reports file_type().is_symlink() rather than is_dir(). + let meta = std::fs::symlink_metadata(path)?; + if meta.is_dir() { + std::fs::remove_dir_all(path) + } else { + std::fs::remove_file(path) + } +} + +fn extract_gz(reader: R, dest: &Path) -> Result<()> { + let gz_decoder = flate2::read::GzDecoder::new(reader); + let mut archive = tar::Archive::new(gz_decoder); + + archive.set_preserve_permissions(true); + archive.set_preserve_ownerships(false); + archive.set_unpack_xattrs(false); + + archive + .unpack(dest) + .context("Failed to extract gzip archive")?; + + Ok(()) +} + +fn extract_xz(reader: R, dest: &Path) -> Result<()> { + let xz_decoder = xz2::read::XzDecoder::new(reader); + let mut archive = tar::Archive::new(xz_decoder); + + archive.set_preserve_permissions(true); + archive.set_preserve_ownerships(false); + archive.set_unpack_xattrs(false); + + archive + .unpack(dest) + .context("Failed to extract xz archive")?; + + Ok(()) +} + +fn extract_zst(reader: R, dest: &Path) -> Result<()> { + let zst_decoder = zstd::Decoder::new(reader)?; + let mut archive = tar::Archive::new(zst_decoder); + + archive.set_preserve_permissions(true); + archive.set_preserve_ownerships(false); + archive.set_unpack_xattrs(false); + + archive + .unpack(dest) + .context("Failed to extract zstd archive")?; + + Ok(()) +} + +fn extract_tar(reader: R, dest: &Path) -> Result<()> { + let mut archive = tar::Archive::new(reader); + + archive.set_preserve_permissions(true); + archive.set_preserve_ownerships(false); + archive.set_unpack_xattrs(false); + + archive + .unpack(dest) + .context("Failed to extract tar archive")?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..b8ca765 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,301 @@ -fn main() { - println!("Hello, world!"); +mod chroot; +mod cli; +mod config; +mod distro; +mod download; +mod extract; +mod mount; +mod namespace; +mod qemu; +mod verbose; + +/// Print to stderr only when --verbose / -v is active. +#[macro_export] +macro_rules! veprintln { + ($($arg:tt)*) => { + if $crate::verbose::is_verbose() { + eprintln!($($arg)*); + } + }; +} + +use anyhow::Result; +use clap::Parser; + +use cli::Args; +use config::Config; +use distro::{ + map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource, +}; +use download::{digest_sidecar, download_image, fetch_oci_digest}; +use extract::extract_tarball; + +fn main() -> Result<()> { + let args = Args::parse(); + + // Initialise verbosity before anything else so all downstream code can use veprintln!. + verbose::set(args.verbose); + + // Load config file + let config = Config::load()?; + + // Get architecture + let host_arch = get_host_arch(); + let arch = args.arch.clone().unwrap_or_else(|| host_arch.clone()); + + // Parse image reference + let image_source = parse_image_ref(&args.distro, &arch)?; + + // For DirectTarball, resolve floating aliases ("latest", "lts") to a concrete + // version string *before* computing the cache key. This ensures we cache as + // e.g. "ubuntu-noble-amd64" rather than "ubuntu-latest-amd64", so a future + // release automatically gets its own cache entry. + let image_source = match image_source { + ImageSource::DirectTarball { distro, version } => { + let resolved = resolve_distro_version(&distro, version.as_deref(), &arch)?; + ImageSource::DirectTarball { + distro, + version: Some(resolved), + } + } + other => other, + }; + + // Determine cache directory and filename + let cache_dir = dirs::cache_dir() + .expect("Could not determine cache directory") + .join("ecr"); + let cache_filename = generate_cache_filename(&image_source, &arch); + let cache_path = cache_dir.join(&cache_filename); + + // OCI images with a floating tag (":latest") need a freshness check: + // fetch the current manifest digest from the registry and compare it + // against the digest stored from the last download. Only re-pull when + // the digest has actually changed. On a network error we fall back to + // the cached image with a warning rather than hard-failing. + let oci_digest_changed = if cache_path.exists() { + if let ImageSource::OciImage { + registry, + repository, + tag, + .. + } = &image_source + { + if tag == "latest" { + match fetch_oci_digest(registry, repository, tag) { + Ok(current) => { + let stored = std::fs::read_to_string(digest_sidecar(&cache_path)).ok(); + stored.as_deref() != Some(current.trim()) + } + Err(e) => { + eprintln!( + "Warning: could not check image freshness ({}); using cache", + e + ); + false + } + } + } else { + false // pinned tags are assumed immutable + } + } else { + false + } + } else { + false // cache absent — download triggered by !cache_path.exists() below + }; + + // Download if not cached, --no-cache, or the remote digest has moved + if args.no_cache || !cache_path.exists() || oci_digest_changed { + std::fs::create_dir_all(&cache_dir)?; + download_image(&image_source, &cache_path, &arch)?; + } else { + veprintln!("Using cached tarball: {}", cache_path.display()); + } + + // Check QEMU if foreign architecture + if arch != host_arch { + qemu::check_binfmt(&arch)?; + } + + // Check user namespace availability + namespace::check_user_namespace()?; + + // Process bind paths - use current directory if none specified + let cwd = std::env::current_dir().expect("Could not get current directory"); + let bind_paths: Vec = if args.bind.is_empty() && !args.no_bind { + vec![cwd.clone()] + } else { + args.bind.clone() + }; + + // --no-bind means "skip mounting any directory". Combining it with an + // explicit --bind-rw is contradictory; error rather than silently ignoring + // the flag the user asked for. + if args.no_bind && !args.bind_rw.is_empty() { + return Err(anyhow::anyhow!( + "--no-bind and --bind-rw cannot be used together: \ + --no-bind skips all mounts, including read-write ones" + )); + } + let bind_rw_paths: Vec = args.bind_rw.clone(); + + // Create temp directory for extraction + let temp_dir = tempfile::tempdir()?; + let rootfs = temp_dir.path().to_path_buf(); + + veprintln!("Extracting to: {}", rootfs.display()); + extract_tarball(&cache_path, &rootfs)?; + + // Prepare data for the closure + let bind_paths_clone = bind_paths.clone(); + let bind_rw_paths_clone = bind_rw_paths.clone(); + let args_clone = args.clone(); + let rootfs_clone = rootfs.clone(); + let dns_clone = config.dns.clone(); + + // Run in namespace + let result = namespace::setup_namespaces(move || -> Result<()> { + // Setup mounts - overlay_temps must be kept alive for overlay to work + let overlay_temps = mount::setup_mounts( + &rootfs_clone, + &bind_paths_clone, + &bind_rw_paths_clone, + &args_clone, + )?; + + // Write resolv.conf with DNS from config + write_resolv_conf(&rootfs_clone, &dns_clone)?; + + // Run chroot + let command = if args_clone.command.is_empty() { + None + } else { + Some(args_clone.command.clone()) + }; + + let result = chroot::run_chroot(&rootfs_clone, command, &bind_rw_paths_clone); + + // Keep overlay_temps alive until chroot exits + drop(overlay_temps); + + result + }); + + // Cleanup happens automatically via tempfile + match &result { + Ok(_) => veprintln!("Cleanup complete."), + Err(e) => eprintln!("Error: {}", e), + } + + result +} + +/// Generate a cache filename based on the image source +fn generate_cache_filename(source: &ImageSource, arch: &str) -> String { + match source { + ImageSource::DirectTarball { distro, version } => { + let distro_name = match distro { + Distro::Ubuntu => "ubuntu", + Distro::Alpine => "alpine", + }; + let distro_arch = map_arch(*distro, arch); + // Get extension from URL + let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default(); + let ext = get_tarball_extension(&url); + format!( + "{}-{}-{}.{}", + distro_name, + version.as_deref().unwrap_or("latest"), + distro_arch, + ext + ) + } + ImageSource::OciImage { + registry, + repository, + tag, + architecture, + } => { + // Sanitize for filename + let safe_registry = registry.replace(['.', ':'], "_"); + let safe_repo = repository.replace(['/', ':'], "_"); + format!( + "oci-{}-{}-{}-{}.tar.gz", + safe_registry, safe_repo, tag, architecture + ) + } + } +} + +fn get_tarball_extension(url: &str) -> &str { + // Extract extension from URL (e.g., .tar.gz, .tar.xz, .tar.zst) + if url.ends_with(".tar.zst") { + "tar.zst" + } else if url.ends_with(".tar.xz") { + "tar.xz" + } else if url.ends_with(".tar.gz") { + "tar.gz" + } else if url.ends_with(".tar.bz2") { + "tar.bz2" + } else { + "tar.gz" // default + } +} + +fn get_host_arch() -> String { + // Use the uname(2) syscall directly — no subprocess, no PATH dependency, + // no panic-on-missing-binary. This gives the runtime machine string + // (e.g. "x86_64", "aarch64") exactly as `uname -m` would, which is what + // we need for the QEMU check. std::env::consts::ARCH is compile-time and + // would be wrong if the binary itself is running under emulation. + let utsname = nix::sys::utsname::uname() + .expect("uname(2) syscall failed — cannot determine host architecture"); + let machine = utsname.machine().to_string_lossy(); + + match machine.as_ref() { + "x86_64" => "amd64".to_string(), + "aarch64" => "arm64".to_string(), + "armv7l" | "armv7" => "armhf".to_string(), + "riscv64" => "riscv64".to_string(), + "ppc64le" => "ppc64el".to_string(), + "s390x" => "s390x".to_string(), + other => other.to_string(), + } +} + +fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> { + let resolv_conf = rootfs.join("etc/resolv.conf"); + + // Create /etc if it doesn't exist + if let Some(parent) = resolv_conf.parent() { + std::fs::create_dir_all(parent)?; + } + + // Copy host's resolv.conf if dns is empty, otherwise use provided DNS + let content = if dns.is_empty() { + // Try to copy from host + match std::fs::read_to_string("/etc/resolv.conf") { + Ok(host_resolv) => host_resolv, + Err(_) => "nameserver 1.1.1.1\nnameserver 8.8.8.8\n".to_string(), + } + } else { + let mut c = dns + .iter() + .map(|s| format!("nameserver {}", s)) + .collect::>() + .join("\n"); + c.push('\n'); + c + }; + + // Remove any existing file or symlink before writing so that std::fs::write + // always creates a plain file. Without this, an absolute symlink such as + // /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the + // write to follow the symlink through the *host* root (chroot() has not been + // called yet) and corrupt the host's DNS configuration. + let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT + std::fs::write(&resolv_conf, content)?; + + Ok(()) } diff --git a/src/mount.rs b/src/mount.rs new file mode 100644 index 0000000..dd776b6 --- /dev/null +++ b/src/mount.rs @@ -0,0 +1,278 @@ +use anyhow::{anyhow, Context, Result}; +use nix::mount::{mount, MsFlags}; +use std::path::Path; +use tempfile::TempDir; + +/// Escape a path for use as an overlayfs mount option value. +/// +/// The overlayfs kernel driver uses `,` as its option delimiter and `\` as +/// the escape character (Linux ≥ 5.1, commit 6b2d09a). A bare comma in a +/// path would silently split the option string at the wrong boundary and +/// produce a cryptic kernel error; a bare backslash would be mis-interpreted +/// as starting an escape sequence. +fn escape_overlay_path(path: &Path) -> Result { + let s = path.to_str().ok_or_else(|| { + anyhow!( + "Overlay path '{}' contains non-UTF-8 characters", + path.display() + ) + })?; + // Backslashes must be escaped before commas to avoid double-escaping. + Ok(s.replace('\\', "\\\\").replace(',', "\\,")) +} + +use crate::cli::Args; + +/// Setup all required mounts inside the chroot +/// Returns a TempDir that must be kept alive for the duration of the chroot +pub fn setup_mounts( + rootfs: &Path, + bind_paths: &[std::path::PathBuf], + bind_rw_paths: &[std::path::PathBuf], + args: &Args, +) -> Result> { + // Keep all overlay temp dirs alive + let mut overlay_temps: Vec = Vec::new(); + + // Make all mounts private to avoid propagation to host + if let Err(e) = mount( + None::<&str>, + "/", + None::<&str>, + MsFlags::MS_PRIVATE | MsFlags::MS_REC, + None::<&str>, + ) { + eprintln!("Warning: Failed to make mounts private: {}", e); + } + + // Mount /proc + mount_proc(rootfs)?; + + // Setup /dev by bind mounting from host + mount_dev(rootfs)?; + + // Mount /dev/pts + mount_devpts(rootfs)?; + + // Try to mount /sys (may fail in some environments) + if let Err(e) = mount_sys(rootfs) { + eprintln!("Warning: Could not mount /sys: {}", e); + } + + // Setup overlay mounts for bind paths (read-only via overlay) + if !args.no_bind { + for bind_path in bind_paths { + // Skip if this path is also in bind_rw (bind_rw takes precedence) + if !bind_rw_paths.contains(bind_path) { + let temp = setup_overlay(rootfs, bind_path)?; + overlay_temps.push(temp); + } + } + } + + // Setup read-write bind mounts (these override regular bind for same paths) + for bind_rw_path in bind_rw_paths { + setup_bind_rw(rootfs, bind_rw_path)?; + } + + Ok(overlay_temps) +} + +fn mount_proc(rootfs: &Path) -> Result<()> { + let proc_path = rootfs.join("proc"); + std::fs::create_dir_all(&proc_path)?; + + let flags = MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV; + + mount(Some("proc"), &proc_path, Some("proc"), flags, None::<&str>) + .with_context(|| format!("Failed to mount proc at {}", proc_path.display()))?; + + Ok(()) +} + +fn mount_sys(rootfs: &Path) -> Result<()> { + let sys_path = rootfs.join("sys"); + std::fs::create_dir_all(&sys_path)?; + + // Bind mount /sys from host as read-only + mount( + Some("/sys"), + &sys_path, + None::<&str>, + MsFlags::MS_BIND | MsFlags::MS_REC, + None::<&str>, + ) + .with_context(|| format!("Failed to bind mount sys at {}", sys_path.display()))?; + + // Remount as read-only with full security flags. + // MS_BIND | MS_REMOUNT does NOT inherit the original mount's flags; every + // desired flag must be listed explicitly. /proc uses the same set. + mount( + Some(&sys_path), + &sys_path, + None::<&str>, + MsFlags::MS_BIND + | MsFlags::MS_REMOUNT + | MsFlags::MS_RDONLY + | MsFlags::MS_NOSUID + | MsFlags::MS_NODEV + | MsFlags::MS_NOEXEC, + None::<&str>, + ) + .with_context(|| { + format!( + "Failed to remount sys as read-only at {}", + sys_path.display() + ) + })?; + + Ok(()) +} + +fn mount_dev(rootfs: &Path) -> Result<()> { + let dev_path = rootfs.join("dev"); + std::fs::create_dir_all(&dev_path)?; + + // Bind mount /dev from host + mount( + Some("/dev"), + &dev_path, + None::<&str>, + MsFlags::MS_BIND | MsFlags::MS_REC, + None::<&str>, + ) + .with_context(|| format!("Failed to bind mount dev at {}", dev_path.display()))?; + + Ok(()) +} + +fn mount_devpts(rootfs: &Path) -> Result<()> { + let devpts_path = rootfs.join("dev/pts"); + std::fs::create_dir_all(&devpts_path)?; + + let flags = MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC; + + mount( + Some("devpts"), + &devpts_path, + Some("devpts"), + flags, + None::<&str>, + ) + .with_context(|| format!("Failed to mount devpts at {}", devpts_path.display()))?; + + Ok(()) +} + +/// Setup overlay mount for workspace directory +/// Returns a TempDir that must be kept alive for the overlay to work +fn setup_overlay(rootfs: &Path, source: &Path) -> Result { + let basename = source + .file_name() + .ok_or_else(|| anyhow!("Invalid bind path"))? + .to_string_lossy(); + + let mount_point = rootfs.join("root").join(basename.as_ref()); + std::fs::create_dir_all(&mount_point)?; + + // Create temp directories for overlay + let temp_dir = tempfile::tempdir()?; + let upper_dir = temp_dir.path().join("upper"); + let work_dir = temp_dir.path().join("work"); + std::fs::create_dir_all(&upper_dir)?; + std::fs::create_dir_all(&work_dir)?; + + // Create overlay mount options + let lowerdir = source.canonicalize()?; + let upperdir = upper_dir.canonicalize()?; + let workdir = work_dir.canonicalize()?; + + let options = format!( + "lowerdir={},upperdir={},workdir={}", + escape_overlay_path(&lowerdir)?, + escape_overlay_path(&upperdir)?, + escape_overlay_path(&workdir)?, + ); + + mount( + Some("overlay"), + &mount_point, + Some("overlay"), + MsFlags::empty(), + Some(options.as_str()), + ) + .with_context(|| format!("Failed to mount overlay at {}", mount_point.display()))?; + + // Return temp_dir so caller can keep it alive + Ok(temp_dir) +} + +/// Setup read-write bind mount +fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> { + let basename = source + .file_name() + .ok_or_else(|| anyhow!("Invalid bind-rw path"))? + .to_string_lossy(); + + let mount_point = rootfs.join("mnt").join(basename.as_ref()); + std::fs::create_dir_all(&mount_point)?; + + let source = source.canonicalize()?; + + mount( + Some(&source), + &mount_point, + None::<&str>, + MsFlags::MS_BIND | MsFlags::MS_REC, + None::<&str>, + ) + .with_context(|| { + format!( + "Failed to bind mount {} at {}", + source.display(), + mount_point.display() + ) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::escape_overlay_path; + use std::path::Path; + + #[test] + fn plain_path_unchanged() { + assert_eq!( + escape_overlay_path(Path::new("/home/user/project")).unwrap(), + "/home/user/project" + ); + } + + #[test] + fn comma_in_path_escaped() { + assert_eq!( + escape_overlay_path(Path::new("/home/user/my,project")).unwrap(), + "/home/user/my\\,project" + ); + } + + #[test] + fn backslash_escaped_before_comma() { + // Backslash must be doubled first so a path like "a\,b" becomes + // "a\\\,b" and not "a\,b" (which would look like an escaped comma). + assert_eq!( + escape_overlay_path(Path::new("/a\\,b")).unwrap(), + "/a\\\\\\,b" + ); + } + + #[test] + fn multiple_commas_all_escaped() { + assert_eq!( + escape_overlay_path(Path::new("/a,b,c")).unwrap(), + "/a\\,b\\,c" + ); + } +} diff --git a/src/namespace.rs b/src/namespace.rs new file mode 100644 index 0000000..2a08dd4 --- /dev/null +++ b/src/namespace.rs @@ -0,0 +1,413 @@ +use anyhow::{anyhow, Context, Result}; +use nix::sched::{clone, CloneFlags}; +use nix::sys::signal::Signal; +use nix::unistd::{getgid, getuid, Pid}; + +/// RAII wrapper that closes a raw file descriptor on drop. +/// Guarantees all pipe fds are closed on every return path, including +/// clone() and setup_user_namespace() failures. +struct AutoCloseFd(i32); + +impl AutoCloseFd { + fn raw(&self) -> i32 { + self.0 + } +} + +impl Drop for AutoCloseFd { + fn drop(&mut self) { + unsafe { + libc::close(self.0); + } + } +} + +/// Clone flags for namespace creation +const CLONE_FLAGS: CloneFlags = CloneFlags::CLONE_NEWUSER + .union(CloneFlags::CLONE_NEWPID) + .union(CloneFlags::CLONE_NEWNS) + .union(CloneFlags::CLONE_NEWUTS); + +/// Check if user namespaces are available +pub fn check_user_namespace() -> Result<()> { + // Check kernel.unprivileged_userns_clone on systems that have it + if let Ok(content) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") { + if content.trim() == "0" { + return Err(anyhow!( + "User namespaces not available\n\n\ + Enable with:\n\ + sysctl -w kernel.unprivileged_userns_clone=1\n\n\ + Or check AppArmor profile restrictions." + )); + } + } + + // Check max_user_namespaces + if let Ok(content) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") { + if let Ok(max) = content.trim().parse::() { + if max == 0 { + return Err(anyhow!( + "User namespaces not available\n\n\ + Enable with:\n\ + sysctl -w user.max_user_namespaces=10000" + )); + } + } + } + + Ok(()) +} + +/// Setup namespaces and run the provided function inside them +pub fn setup_namespaces(f: F) -> Result<()> +where + F: FnOnce() -> Result<()> + Send + 'static, +{ + // sync pipe: parent signals child to proceed after UID/GID mapping + // done pipe: child signals parent it has finished (or exec'd the shell) + // error pipe: child writes the anyhow error chain to the parent on failure. + // The write end is O_CLOEXEC so it is automatically closed when execvp + // succeeds — the parent then reads EOF and knows there was no error. + // + // All six fds are wrapped in AutoCloseFd so they are closed on every return + // path, including clone() and setup_user_namespace() failures. + let (parent_read, parent_write, child_read, child_write, error_read, error_write); + + unsafe { + let mut fds: [i32; 2] = [-1, -1]; + + if libc::pipe(fds.as_mut_ptr()) != 0 { + return Err(anyhow!("Failed to create sync pipe")); + } + parent_read = AutoCloseFd(fds[0]); + parent_write = AutoCloseFd(fds[1]); + // parent_read/write auto-closed if subsequent pipes fail ↑ + + if libc::pipe(fds.as_mut_ptr()) != 0 { + return Err(anyhow!("Failed to create done pipe")); + } + child_read = AutoCloseFd(fds[0]); + child_write = AutoCloseFd(fds[1]); + // O_CLOEXEC on the write end: if execve succeeds the kernel closes cw + // atomically, the parent's read(child_read) gets EOF immediately, and + // waitpid becomes the real wait. The done-pipe is then only used on + // the error path (f() returned Err before execve was reached). + libc::fcntl(child_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC); + + if libc::pipe(fds.as_mut_ptr()) != 0 { + return Err(anyhow!("Failed to create error pipe")); + } + error_read = AutoCloseFd(fds[0]); + error_write = AutoCloseFd(fds[1]); + // Same treatment for error_write: auto-closed on exec (no error), + // written explicitly on the error path before the child exits. + libc::fcntl(error_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC); + } + + // Stack for the child process + let stack_size = 1024 * 1024; + let mut stack = vec![0u8; stack_size]; + + // Wrap f in Option to allow taking it once inside the child closure + let mut f = Some(f); + + // Extract raw fds for the child closure. The child is a clone of the + // parent process and gets its own copies of all open fds; the parent's + // AutoCloseFd wrappers independently manage the parent's copies. + let pr = parent_read.raw(); + let pw = parent_write.raw(); + let cr = child_read.raw(); + let cw = child_write.raw(); + let er = error_read.raw(); + let ew = error_write.raw(); + + // Clone with new namespaces + let pid = unsafe { + clone( + Box::new(move || { + // Close unused pipe ends in the child + libc::close(pw); + libc::close(cr); + libc::close(er); + + // Wait for parent to set up UID/GID mappings + let mut buf = [0u8; 1]; + libc::read(pr, buf.as_mut_ptr() as *mut libc::c_void, 1); + libc::close(pr); + + // Run the function + let result = if let Some(func) = f.take() { + func() + } else { + Err(anyhow!("Function already called")) + }; + + // On failure, write the full error chain to the error pipe + // before signalling done, so the parent can reconstruct it. + if let Err(ref e) = result { + let msg = format!("{:#}", e); + let bytes = msg.as_bytes(); + libc::write(ew, bytes.as_ptr() as *const libc::c_void, bytes.len()); + } + libc::close(ew); + + // Signal completion + libc::write(cw, c"done".as_ptr() as *const libc::c_void, 4); + libc::close(cw); + + if result.is_ok() { + 0 + } else { + 1 + } + }), + &mut stack, + CLONE_FLAGS, + Some(Signal::SIGCHLD as i32), + ) + } + .context("Failed to clone with new namespaces")?; + // clone() failure: all six AutoCloseFds drop here, closing every fd. ✓ + + // Parent: drop the child-side ends now that clone has succeeded. + // The child process has its own copies; dropping here closes the parent's. + drop(parent_read); + drop(child_write); + drop(error_write); + + // Set up UID/GID mappings for the child + // setup_user_namespace failure: parent_write, child_read, error_read + // auto-closed by AutoCloseFd drop. ✓ + setup_user_namespace(pid)?; + + // Signal child to proceed + unsafe { + libc::write(parent_write.raw(), c"go".as_ptr() as *const libc::c_void, 2); + } + drop(parent_write); + + // Wait for child to complete (or the exec'd shell to exit) + let mut buf = [0u8; 4]; + unsafe { + libc::read(child_read.raw(), buf.as_mut_ptr() as *mut libc::c_void, 4); + } + drop(child_read); + + // Read the error message written by the child, if any. + // error_write was either closed explicitly (on error) or auto-closed via + // O_CLOEXEC (on successful exec), so this read always terminates. + let child_error: Option = unsafe { + let mut error_bytes = Vec::new(); + let mut tmp = [0u8; 4096]; + loop { + let n = libc::read( + error_read.raw(), + tmp.as_mut_ptr() as *mut libc::c_void, + tmp.len(), + ); + if n <= 0 { + break; + } + error_bytes.extend_from_slice(&tmp[..n as usize]); + } + if error_bytes.is_empty() { + None + } else { + Some(String::from_utf8_lossy(&error_bytes).into_owned()) + } + }; + drop(error_read); + + // Wait for child process + let status = nix::sys::wait::waitpid(pid, None)?; + + match status { + nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(()), + nix::sys::wait::WaitStatus::Exited(_, code) => { + if let Some(msg) = child_error { + Err(anyhow!("{}", msg)) + } else { + Err(anyhow!("Child process exited with code {}", code)) + } + } + nix::sys::wait::WaitStatus::Signaled(_, sig, _) => { + Err(anyhow!("Child process killed by signal {:?}", sig)) + } + _ => Ok(()), + } +} + +/// Set up UID/GID mappings for user namespace +fn setup_user_namespace(pid: Pid) -> Result<()> { + let uid = getuid(); + let gid = getgid(); + + // Get the subordinate UID/GID ranges from /etc/subuid and /etc/subgid + // For unprivileged users, we need to use these ranges + let (sub_uid_start, sub_uid_count) = get_subuid_range(uid)?; + let (sub_gid_start, sub_gid_count) = get_subgid_range(gid)?; + + // We map UID 0 (root inside the namespace) to the host user, then map + // IDs 1..sub_uid_count-1 to the subordinate range. A count of 0 or 1 + // leaves no subordinate IDs to map and indicates a malformed /etc/subuid. + if sub_uid_count < 2 { + return Err(anyhow!( + "subuid count {} for uid {} is too small (need at least 2); \ + check /etc/subuid", + sub_uid_count, + uid + )); + } + if sub_gid_count < 2 { + return Err(anyhow!( + "subgid count {} for gid {} is too small (need at least 2); \ + check /etc/subgid", + sub_gid_count, + gid + )); + } + + // Allow setgroups so apt and other tools can drop privileges + let setgroups_path = format!("/proc/{}/setgroups", pid); + std::fs::write(&setgroups_path, "allow\n") + .with_context(|| format!("Failed to write {}", setgroups_path))?; + + // Use newuidmap and newgidmap for setting up mappings + // These are setuid binaries that allow unprivileged users to map subuid/subgid ranges + let pid_str = pid.to_string(); + + // newuidmap format: newuidmap pid ns_start host_start count ... + // Map current user to root (0), then subordinate UIDs starting from 1 + let uid_result = std::process::Command::new("newuidmap") + .arg(&pid_str) + .arg("0") + .arg(uid.to_string()) + .arg("1") + .arg("1") + .arg(sub_uid_start.to_string()) + .arg((sub_uid_count - 1).to_string()) + .status() + .context("Failed to execute newuidmap")?; + + if !uid_result.success() { + return Err(anyhow!( + "newuidmap failed - ensure subuid entry exists in /etc/subuid" + )); + } + + // newgidmap format: newgidmap pid ns_start host_start count ... + // Map current group to root (0), then subordinate GIDs starting from 1 + let gid_result = std::process::Command::new("newgidmap") + .arg(&pid_str) + .arg("0") + .arg(gid.to_string()) + .arg("1") + .arg("1") + .arg(sub_gid_start.to_string()) + .arg((sub_gid_count - 1).to_string()) + .status() + .context("Failed to execute newgidmap")?; + + if !gid_result.success() { + return Err(anyhow!( + "newgidmap failed - ensure subgid entry exists in /etc/subgid" + )); + } + + Ok(()) +} + +/// Get subordinate UID range for a user from /etc/subuid +fn get_subuid_range(uid: nix::unistd::Uid) -> Result<(u32, u32)> { + let content = std::fs::read_to_string("/etc/subuid").context("Failed to read /etc/subuid")?; + + let username = + users::get_user_by_uid(uid.as_raw()).map(|u| u.name().to_string_lossy().to_string()); + + for line in content.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 3 { + // Check if this line matches our user (by name or UID) + let matches = parts[0] == username.as_deref().unwrap_or("") + || parts[0].parse::().ok() == Some(uid.as_raw()); + + if matches { + let start: u32 = parts[1].parse().context("Invalid subuid start")?; + let count: u32 = parts[2].parse().context("Invalid subuid count")?; + return Ok((start, count)); + } + } + } + + Err(anyhow!( + "No subuid entry found for user {} (uid {}). \ + Add one to /etc/subuid, e.g.:\n {}:100000:65536", + username.as_deref().unwrap_or(""), + uid, + username.as_deref().unwrap_or(&uid.to_string()), + )) +} + +/// Get subordinate GID range for a group from /etc/subgid +fn get_subgid_range(gid: nix::unistd::Gid) -> Result<(u32, u32)> { + let content = std::fs::read_to_string("/etc/subgid").context("Failed to read /etc/subgid")?; + + let groupname = + users::get_group_by_gid(gid.as_raw()).map(|g| g.name().to_string_lossy().to_string()); + + for line in content.lines() { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 3 { + // Check if this line matches our group (by name or GID) + let matches = parts[0] == groupname.as_deref().unwrap_or("") + || parts[0].parse::().ok() == Some(gid.as_raw()); + + if matches { + let start: u32 = parts[1].parse().context("Invalid subgid start")?; + let count: u32 = parts[2].parse().context("Invalid subgid count")?; + return Ok((start, count)); + } + } + } + + Err(anyhow!( + "No subgid entry found for group {} (gid {}). \ + Add one to /etc/subgid, e.g.:\n {}:100000:65536", + groupname.as_deref().unwrap_or(""), + gid, + groupname.as_deref().unwrap_or(&gid.to_string()), + )) +} + +/// Set hostname in UTS namespace +pub fn set_hostname(distro: &str) -> Result<()> { + use nix::unistd::sethostname; + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + // Seed from both the current time and the PID so each invocation gets a + // distinct suffix even when called in rapid succession. Time alone is + // not sufficient: truncating nanoseconds to u8 in a tight loop produces + // the same byte every iteration. + let mut hasher = DefaultHasher::new(); + std::time::SystemTime::now().hash(&mut hasher); + std::process::id().hash(&mut hasher); + let mut state = hasher.finish(); + + let chars = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let random_suffix: String = (0..6) + .map(|_| { + // Knuth multiplicative LCG — each step advances the full 64-bit state. + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + chars[(state >> 33) as usize % chars.len()] as char + }) + .collect(); + + let hostname = format!("ecr-{}-{}", distro, random_suffix); + + sethostname(&hostname).with_context(|| format!("Failed to set hostname to {}", hostname))?; + + Ok(()) +} diff --git a/src/qemu.rs b/src/qemu.rs new file mode 100644 index 0000000..7fa4c89 --- /dev/null +++ b/src/qemu.rs @@ -0,0 +1,37 @@ +use crate::veprintln; +use anyhow::{anyhow, Result}; +use std::path::Path; + +/// Check if binfmt_misc is registered for the target architecture +pub fn check_binfmt(arch: &str) -> Result<()> { + let qemu_arch = map_arch_to_qemu(arch); + + let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch); + + if !Path::new(&binfmt_path).exists() { + return Err(anyhow!( + "binfmt_misc not registered for {}\n\n\ + Install QEMU user emulation:\n\ + Ubuntu/Debian: sudo apt install qemu-user-static\n\ + Arch: sudo pacman -S qemu-user-static-binfmt\n\ + Alpine: sudo apk add qemu-user-static", + arch + )); + } + + veprintln!("QEMU binfmt_misc registered for {}", arch); + Ok(()) +} + +/// Map ecr architecture names to QEMU binary names +fn map_arch_to_qemu(arch: &str) -> &str { + match arch { + "amd64" | "x86_64" => "x86_64", + "arm64" | "aarch64" => "aarch64", + "armhf" | "armv7" => "arm", + "riscv64" => "riscv64", + "ppc64el" | "ppc64le" => "ppc64le", + "s390x" => "s390x", + _ => arch, + } +} diff --git a/src/verbose.rs b/src/verbose.rs new file mode 100644 index 0000000..efa6386 --- /dev/null +++ b/src/verbose.rs @@ -0,0 +1,11 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +static VERBOSE: AtomicBool = AtomicBool::new(false); + +pub fn set(v: bool) { + VERBOSE.store(v, Ordering::Relaxed); +} + +pub fn is_verbose() -> bool { + VERBOSE.load(Ordering::Relaxed) +}