diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c10fa56 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2264 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +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 = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecrou" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "flate2", + "futures-util", + "libc", + "libloading", + "log", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tar", + "tempfile", + "thiserror", + "tokio", + "xz2", + "zstd", +] + +[[package]] +name = "ecrou-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ecrou", + "indicatif", + "log", +] + +[[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.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +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.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[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 = "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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +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 = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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 = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +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", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2637d13 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +resolver = "2" +members = ["crates/ecrou", "crates/ecrou-cli"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.77" +license = "MIT" +authors = ["Valentin Haudiquet"] + +[profile.release] +strip = true +opt-level = "z" +lto = true diff --git a/crates/ecrou-cli/Cargo.toml b/crates/ecrou-cli/Cargo.toml new file mode 100644 index 0000000..204177b --- /dev/null +++ b/crates/ecrou-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ecrou-cli" +description = "Enter one-shot microVM environments" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[[bin]] +name = "ecrou" +path = "src/main.rs" + +[dependencies] +ecrou = { path = "../ecrou" } + +# CLI parsing +clap = { version = "4", features = ["derive", "env"] } + +# Error handling +anyhow = "1" + +# Logging facade; the stderr sink lives in src/logger.rs +log = { version = "0.4", features = ["std"] } + +# Progress bar rendering of library progress events +indicatif = "0.18" diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/ecrou-cli/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/ecrou/Cargo.toml b/crates/ecrou/Cargo.toml new file mode 100644 index 0000000..9a52d0a --- /dev/null +++ b/crates/ecrou/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "ecrou" +description = "One-shot microVM environments on libkrun" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +# 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" + +# Download integrity: verify content against registry/CDN digests +sha2 = "0.10" + +# Logging facade (the CLI installs the sink) +log = "0.4" + +# Temp directories (the per-run scratch rootfs) +tempfile = "3" + +# Error handling +anyhow = "1" +thiserror = "2" + +# Utilities +dirs = "6" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] } +futures-util = "0.3" +serde_json = "1" + +# libkrun FFI: raw ioctl for the KVM probe, runtime dlopen for the ABI +libc = "0.2" +libloading = "0.8" diff --git a/crates/ecrou/src/config.rs b/crates/ecrou/src/config.rs new file mode 100644 index 0000000..5ec6eb7 --- /dev/null +++ b/crates/ecrou/src/config.rs @@ -0,0 +1,43 @@ +use anyhow::Context; + +use crate::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("ecrou.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/crates/ecrou/src/distro.rs b/crates/ecrou/src/distro.rs new file mode 100644 index 0000000..4678ed4 --- /dev/null +++ b/crates/ecrou/src/distro.rs @@ -0,0 +1,534 @@ +use anyhow::{anyhow, Context}; + +use crate::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).into()), + } + } +} + +/// 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 Ok(parse_oci_ref(rest, arch)?); + } + + // Check for registry prefix (contains /) + if input.contains('/') { + return Ok(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.) + _ => Ok(parse_oci_ref(input, arch)?), + } +} + +/// Parse OCI image reference (registry/repo:tag or repo:tag) +fn parse_oci_ref(input: &str, arch: &str) -> anyhow::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 { + crate::utils::map_oci_arch(arch) +} + +/// Map ecrou architecture names to distro-specific names +pub fn map_arch(distro: Distro, arch: &str) -> String { + let distro_name = match distro { + Distro::Ubuntu => "ubuntu", + Distro::Alpine => "alpine", + }; + crate::utils::map_arch_for_distro(distro_name, arch) +} + +/// Resolve the download URL for a known distro (optimized path) +pub fn resolve_distro_url(distro: &Distro, version: Option<&str>, arch: &str) -> Result { + Ok(match distro { + Distro::Ubuntu => resolve_ubuntu_url(version, arch), + Distro::Alpine => resolve_alpine_url(version, arch), + }?) +} + +/// URL of the checksum artifact covering the tarball at `tarball_url`, when +/// the distro publishes one. Alpine ships a per-file `.sha256`; Ubuntu +/// publishes a `SHA256SUMS` manifest per release directory. +pub fn checksum_artifact_url(distro: &Distro, tarball_url: &str) -> Option { + match distro { + Distro::Alpine => Some(format!("{}.sha256", tarball_url)), + Distro::Ubuntu => { + let dir = tarball_url.rsplit_once('/')?.0; + Some(format!("{}/SHA256SUMS", dir)) + } + } +} + +/// 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) -> anyhow::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) -> anyhow::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) -> anyhow::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) -> anyhow::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 { + Ok(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() -> anyhow::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>) -> anyhow::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) -> anyhow::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) -> anyhow::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) -> anyhow::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::*; + + #[test] + fn checksum_artifact_url_per_distro() { + assert_eq!( + checksum_artifact_url( + &Distro::Alpine, + "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/x86_64/alpine-minirootfs-3.23.0-x86_64.tar.gz" + ) + .as_deref(), + Some( + "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/x86_64/alpine-minirootfs-3.23.0-x86_64.tar.gz.sha256" + ) + ); + assert_eq!( + checksum_artifact_url( + &Distro::Ubuntu, + "https://cdimage.ubuntu.com/ubuntu-base/noble/daily/current/noble-base-amd64.tar.gz" + ) + .as_deref(), + Some("https://cdimage.ubuntu.com/ubuntu-base/noble/daily/current/SHA256SUMS") + ); + } + + 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/crates/ecrou/src/download.rs b/crates/ecrou/src/download.rs new file mode 100644 index 0000000..c643596 --- /dev/null +++ b/crates/ecrou/src/download.rs @@ -0,0 +1,967 @@ +use crate::progress::{Progress, Unit}; +use anyhow::{anyhow, Context}; +use futures_util::StreamExt; +use reqwest::Client; +use serde_json::Value; +use sha2::{Digest, Sha256, Sha512}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::distro::{Distro, ImageSource}; +use crate::Result; + +/// 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) +} + +/// Incremental sha256+sha512 hasher used to verify downloaded content against +/// content-addressed digests. Both algorithms run side by side so the caller +/// can check whatever digest the manifest or checksum file names. +struct DownloadHasher { + sha256: Sha256, + sha512: Sha512, +} + +impl DownloadHasher { + fn new() -> Self { + DownloadHasher { + sha256: Sha256::new(), + sha512: Sha512::new(), + } + } + + fn update(&mut self, chunk: &[u8]) { + self.sha256.update(chunk); + self.sha512.update(chunk); + } + + /// Verify the streamed content. `expected` is an OCI `algo:hex` digest or + /// a bare sha256 hex string (distro checksum files); None verifies nothing. + /// Algorithms outside sha256/sha512 cannot be checked: warn and accept + /// rather than reject images a registry may legitimately serve. + fn verify(&self, expected: Option<&str>) -> anyhow::Result<()> { + let Some(expected) = expected else { + return Ok(()); + }; + let (algo, hex) = match expected.split_once(':') { + Some((algo, hex)) => (algo, hex), + None => ("sha256", expected), + }; + + let actual = match algo { + "sha256" => hex_encode(&self.sha256.clone().finalize()), + "sha512" => hex_encode(&self.sha512.clone().finalize()), + other => { + log::warn!( + "digest algorithm '{}' not supported; skipping verification", + other + ); + return Ok(()); + } + }; + + if actual.eq_ignore_ascii_case(hex) { + Ok(()) + } else { + Err(anyhow!( + "content verification failed: expected {} {}, got {}", + algo, + hex, + actual + )) + } + } +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// 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 { + let digest = tokio::runtime::Runtime::new() + .context("Failed to create Tokio runtime")? + .block_on(fetch_oci_digest_async(registry, repository, tag))?; + Ok(digest) +} + +async fn fetch_oci_digest_async( + registry: &str, + repository: &str, + tag: &str, +) -> anyhow::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), +/// reporting transfer progress through `progress` +pub fn download_image( + source: &ImageSource, + dest: &Path, + arch: &str, + progress: &dyn Progress, +) -> 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)?; + log::debug!("Resolved URL: {}", url); + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(anyhow!("Unsupported URL scheme: {}", url).into()); + } + // Verify the tarball against the checksum the distro publishes + // next to it. A fetch failure (missing artifact, network error) + // degrades to an unverified download with a warning rather than + // hard-failing, mirroring the digest freshness check in rootfs. + let expected = match rt.block_on(fetch_published_checksum(distro, &url)) { + Ok(expected) => Some(expected), + Err(e) => { + log::warn!( + "could not fetch published checksum ({}); \ + proceeding without verification", + e + ); + None + } + }; + Ok(rt.block_on(download_file_async( + &url, + dest, + progress, + expected.as_deref(), + ))?) + } + ImageSource::OciImage { + registry, + repository, + tag, + architecture, + } => { + log::debug!("Pulling OCI image: {}/{}:{}", registry, repository, tag); + Ok(rt.block_on(download_oci_image_async( + registry, + repository, + tag, + architecture, + dest, + progress, + ))?) + } + } +} + +/// Fetch the sha256 the distro publishes for the tarball at `tarball_url`. +async fn fetch_published_checksum(distro: &Distro, tarball_url: &str) -> anyhow::Result { + let Some(checksum_url) = crate::distro::checksum_artifact_url(distro, tarball_url) else { + return Err(anyhow!("no checksum artifact for {}", tarball_url)); + }; + + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + let response = client + .get(&checksum_url) + .send() + .await + .with_context(|| format!("Failed to fetch checksum artifact {}", checksum_url))?; + + if !response.status().is_success() { + return Err(anyhow!( + "checksum artifact {} returned HTTP {}", + checksum_url, + response.status() + )); + } + + let text = response.text().await?; + let filename = tarball_url.rsplit('/').next().unwrap_or(""); + expected_digest_from_checksums(&text, filename).ok_or_else(|| { + anyhow!( + "checksum artifact {} does not list {}", + checksum_url, + filename + ) + }) +} + +/// Extract the digest for `filename` from a sha256sum-style checksum file +/// (lines of ` ` or ` *`). +fn expected_digest_from_checksums(text: &str, filename: &str) -> Option { + for line in text.lines() { + let mut fields = line.split_whitespace(); + let (Some(digest), Some(name)) = (fields.next(), fields.next()) else { + continue; + }; + if name.trim_start_matches('*') == filename { + return Some(digest.to_lowercase()); + } + } + None +} + +async fn download_file_async( + url: &str, + dest: &Path, + progress: &dyn Progress, + expected: Option<&str>, +) -> anyhow::Result<()> { + log::debug!("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")?; + } + + progress.begin( + "Downloading", + Unit::Bytes, + (total_size > 0).then_some(total_size), + ); + + let temp_dest = dest.with_extension("partial"); + + // Download into the temp file, then atomically rename to the final path. + // Verification runs before the rename so a corrupted download never + // reaches the cache; on any failure after the temp file is created we + // remove it so stale .partial files don't accumulate. + let mut hasher = DownloadHasher::new(); + let result: anyhow::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?; + hasher.update(&chunk); + progress.advance(chunk.len() as u64); + } + + progress.finish(); + hasher.verify(expected)?; + if expected.is_some() { + log::debug!("Verified content digest: {}", dest.display()); + } + + tokio::fs::rename(&temp_dest, dest) + .await + .context("Failed to move partial download to final destination")?; + log::debug!("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, + progress: &dyn Progress, +) -> anyhow::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, progress).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, + progress: &dyn Progress, +) -> anyhow::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) + }; + + log::debug!("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 + log::debug!("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"))?; + + log::debug!("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?; + + // The arch manifest was requested by digest: verify the response + // matches, since the layer digests read from it are trusted next. + let mut manifest_hasher = DownloadHasher::new(); + manifest_hasher.update(arch_body.as_bytes()); + manifest_hasher + .verify(Some(manifest_digest)) + .context("architecture manifest failed digest verification")?; + + 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() + }; + + log::debug!("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); + + log::debug!( + "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, + layer_digest, + progress, + ) + .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: anyhow::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, +) -> anyhow::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 + ); + log::debug!("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. + log::warn!( + "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 + ); + log::debug!("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) -> anyhow::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, + expected_digest: &str, + progress: &dyn Progress, +) -> anyhow::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); + + progress.begin(label, Unit::Bytes, (total_size > 0).then_some(total_size)); + + let mut file = tokio::fs::File::create(dest) + .await + .context("Failed to create destination file")?; + + let mut hasher = DownloadHasher::new(); + 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")?; + hasher.update(&chunk); + progress.advance(chunk.len() as u64); + } + + file.flush().await?; + progress.finish(); + + // The blob was addressed by this digest in the manifest; a mismatch means + // truncated or tampered content. The blob lives in a temp directory that + // is discarded on error, so a bad layer never reaches the cache. + hasher.verify(Some(expected_digest)).with_context(|| { + format!( + "layer blob {} failed verification against manifest digest", + url + ) + })?; + + 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::expected_digest_from_checksums; + use super::parse_www_authenticate; + use super::DownloadHasher; + + // sha256("abc") and sha512("abc") — standard test vectors + const SHA256_ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const SHA512_ABC: &str = "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a\ + 2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"; + + fn hasher_with(content: &[u8]) -> DownloadHasher { + let mut hasher = DownloadHasher::new(); + hasher.update(content); + hasher + } + + #[test] + fn verify_oci_digests() { + let h = hasher_with(b"abc"); + assert!(h.verify(Some(&format!("sha256:{}", SHA256_ABC))).is_ok()); + assert!(h.verify(Some(&format!("sha512:{}", SHA512_ABC))).is_ok()); + } + + #[test] + fn verify_bare_hex_is_sha256() { + // distro checksum files carry a bare sha256 hex string + let h = hasher_with(b"abc"); + assert!(h.verify(Some(SHA256_ABC)).is_ok()); + } + + #[test] + fn verify_mismatch_errors() { + let h = hasher_with(b"abc"); + let wrong = format!("sha256:{}", "0".repeat(64)); + assert!(h.verify(Some(&wrong)).is_err()); + // right hash, wrong algorithm name pinned to it + let algo_mismatch = format!("sha512:{}", SHA256_ABC); + assert!(h.verify(Some(&algo_mismatch)).is_err()); + } + + #[test] + fn verify_none_and_unknown_algo_accept() { + let h = hasher_with(b"abc"); + assert!(h.verify(None).is_ok()); + assert!(h.verify(Some("md5:0123456789abcdef")).is_ok()); + } + + #[test] + fn checksum_parsing_bsd_style() { + // Alpine's per-file .sha256: " " + let text = format!("{} alpine-minirootfs-3.23.0-x86_64.tar.gz\n", SHA256_ABC); + assert_eq!( + expected_digest_from_checksums(&text, "alpine-minirootfs-3.23.0-x86_64.tar.gz"), + Some(SHA256_ABC.to_string()) + ); + } + + #[test] + fn checksum_parsing_binary_marker() { + // Ubuntu's SHA256SUMS: " *" (binary-mode marker) + let text = format!("{} *noble-base-amd64.tar.gz\n", SHA256_ABC); + assert_eq!( + expected_digest_from_checksums(&text, "noble-base-amd64.tar.gz"), + Some(SHA256_ABC.to_string()) + ); + } + + #[test] + fn checksum_parsing_missing_and_malformed() { + let text = format!("{} *other-file.tar.gz\nnot-a-checksum-line\n", SHA256_ABC); + assert_eq!( + expected_digest_from_checksums(&text, "noble-base-amd64.tar.gz"), + None + ); + assert_eq!(expected_digest_from_checksums("", "x"), None); + } + + #[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/crates/ecrou/src/error.rs b/crates/ecrou/src/error.rs new file mode 100644 index 0000000..30daf76 --- /dev/null +++ b/crates/ecrou/src/error.rs @@ -0,0 +1,71 @@ +//! Typed error for the public library API. +//! +//! Public functions return [`Result`] carrying [`Error`] so consumers can +//! match on the failure modes that call for different handling, a host +//! without working KVM, an architecture the registry does not serve, +//! versus ordinary failures. Everything without a semantic variant +//! surfaces as [`Error::Other`], which preserves the full anyhow context +//! chain. + +/// Errors returned by the ecrou library API. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// No working KVM on this host, and ecrou has no software-emulation + /// fallback. The remediation names the usual causes in order. + #[error( + "KVM is not available\n\n\ + ecrou boots microVMs and needs /dev/kvm:\n\ + \x20 check the module: lsmod | grep kvm\n\ + \x20 load it: sudo modprobe kvm_intel (or kvm_amd)\n\ + \x20 nested virt (in a VM): enable it on the hypervisor, then\n\ + \x20 cat /sys/module/kvm_intel/parameters/nested\n\ + \x20 access: sudo usermod -aG kvm $USER (re-login)" + )] + KvmUnavailable, + + /// The registry publishes no linux manifest for the requested + /// architecture; `available` lists the architectures it does serve. + #[error("No manifest found for architecture '{arch}'. Available: {available}")] + ArchNotAvailable { arch: String, available: String }, + + /// Any other failure, preserving the full context chain. + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Result alias for the public library API: `Result` carries [`Error`]. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kvm_unavailable_renders_remediation() { + let msg = Error::KvmUnavailable.to_string(); + assert!(msg.contains("KVM is not available")); + assert!(msg.contains("modprobe kvm_intel")); + assert!(msg.contains("usermod -aG kvm")); + } + + #[test] + fn other_preserves_chain_through_sources() { + let inner = anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing file", + )) + .context("extract failed"); + let e = Error::Other(inner); + // Walk the source chain: the io error must remain reachable for + // error-report crates, even though Display shows only the top. + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&e); + let mut found = false; + while let Some(err) = source { + if err.to_string().contains("missing file") { + found = true; + } + source = err.source(); + } + assert!(found, "chain root lost through Error::Other"); + } +} diff --git a/crates/ecrou/src/extract.rs b/crates/ecrou/src/extract.rs new file mode 100644 index 0000000..7645775 --- /dev/null +++ b/crates/ecrou/src/extract.rs @@ -0,0 +1,630 @@ +use crate::progress::{Progress, Unit}; +use anyhow::Context; + +use crate::Result; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path, PathBuf}; + +/// Extract a tarball to the specified directory +pub fn extract_tarball(tarball: &Path, dest: &Path, progress: &dyn Progress) -> 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-") { + log::debug!("Detected multi-layer OCI image, extracting layers..."); + return Ok(extract_multi_layer_oci(tarball, dest, progress)?); + } + + Ok(extract_single_archive(tarball, dest, progress)?) +} + +/// Extract a plain rootfs tarball, detecting the compression format from +/// the filename with a magic-byte fallback. +fn extract_single_archive( + tarball: &Path, + dest: &Path, + progress: &dyn Progress, +) -> anyhow::Result<()> { + let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + let file = File::open(tarball) + .with_context(|| format!("Failed to open tarball: {}", tarball.display()))?; + + let reader = BufReader::new(file); + let mut fixups = DeferredFixups::default(); + + // Detect compression format from filename + if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + extract_gz(reader, dest, &mut fixups, progress)?; + } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { + extract_xz(reader, dest, &mut fixups, progress)?; + } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { + extract_zst(reader, dest, &mut fixups, progress)?; + } else if filename.ends_with(".tar") { + extract_tar(reader, dest, &mut fixups, progress)?; + } 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, &mut fixups, progress)?; + } + [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, &mut fixups, progress)?; + } + [0x28, 0xb5, 0x2f, 0xfd, ..] => { + // zstd magic + drop(peek_reader); + let file = File::open(tarball)?; + extract_zst(BufReader::new(file), dest, &mut fixups, progress)?; + } + _ => { + // Assume uncompressed tar + drop(peek_reader); + let file = File::open(tarball)?; + extract_tar(BufReader::new(file), dest, &mut fixups, progress)?; + } + } + } + + apply_fixups(dest, &mut fixups) +} + +/// Extract a multi-layer OCI image +fn extract_multi_layer_oci( + tarball: &Path, + dest: &Path, + progress: &dyn Progress, +) -> anyhow::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")?; + } + + let manifest_path = temp_dir.path().join("layers.manifest"); + if !manifest_path.exists() { + // Not our layer-bundle layout: a provisioned rootfs persisted over + // an OCI cache entry (the dispatch above keys on the "oci-" filename + // prefix). The outer archive is a plain rootfs tarball. + log::debug!("No layers.manifest in bundle; extracting as plain rootfs tarball"); + return extract_single_archive(tarball, dest, progress); + } + + // Read the layers manifest + let manifest = + std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?; + + // One fixup set across all layers: later layers routinely write into + // directories the base layer ships read-only, and hard-link targets + // routinely live in an earlier layer. + let mut fixups = DeferredFixups::default(); + + // 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; + } + log::debug!("Extracting layer: {}", layer_name); + extract_oci_layer(&layer_path, dest, &mut fixups, progress)?; + } + + apply_fixups(dest, &mut fixups) +} + +/// 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, + fixups: &mut DeferredFixups, + progress: &dyn Progress, +) -> anyhow::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); + + // The decompressor type differs per format; erase it behind a trait + // object so a single archive-extraction call site serves every branch. + let archive: tar::Archive> = + if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") { + tar::Archive::new(Box::new(flate2::read::GzDecoder::new(reader))) + } else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") { + tar::Archive::new(Box::new(xz2::read::XzDecoder::new(reader))) + } else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") { + tar::Archive::new(Box::new(zstd::stream::read::Decoder::new(reader)?)) + } 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); + let file = File::open(layer_path)?; + match magic { + [0x1f, 0x8b, ..] => { + tar::Archive::new(Box::new(flate2::read::GzDecoder::new(BufReader::new(file)))) + } + [0xfd, b'7', b'z', b'X', b'Z', 0x00] => { + tar::Archive::new(Box::new(xz2::read::XzDecoder::new(BufReader::new(file)))) + } + [0x28, 0xb5, 0x2f, 0xfd, ..] => tar::Archive::new(Box::new( + zstd::stream::read::Decoder::new(BufReader::new(file))?, + )), + _ => tar::Archive::new(Box::new(BufReader::new(file))), + } + }; + + extract_archive(archive, dest, "Extracting OCI layer", fixups, progress) +} + +fn extract_gz( + reader: R, + dest: &Path, + fixups: &mut DeferredFixups, + progress: &dyn Progress, +) -> anyhow::Result<()> { + let gz_decoder = flate2::read::GzDecoder::new(reader); + let archive = tar::Archive::new(gz_decoder); + + extract_archive(archive, dest, "Extracting gzip archive", fixups, progress)?; + + Ok(()) +} + +fn extract_xz( + reader: R, + dest: &Path, + fixups: &mut DeferredFixups, + progress: &dyn Progress, +) -> anyhow::Result<()> { + let xz_decoder = xz2::read::XzDecoder::new(reader); + let archive = tar::Archive::new(xz_decoder); + + extract_archive(archive, dest, "Extracting xz archive", fixups, progress)?; + + Ok(()) +} + +fn extract_zst( + reader: R, + dest: &Path, + fixups: &mut DeferredFixups, + progress: &dyn Progress, +) -> anyhow::Result<()> { + let zst_decoder = zstd::Decoder::new(reader)?; + let archive = tar::Archive::new(zst_decoder); + + extract_archive(archive, dest, "Extracting zstd archive", fixups, progress)?; + + Ok(()) +} + +fn extract_tar( + reader: R, + dest: &Path, + fixups: &mut DeferredFixups, + progress: &dyn Progress, +) -> anyhow::Result<()> { + let archive = tar::Archive::new(reader); + + extract_archive(archive, dest, "Extracting tar archive", fixups, progress)?; + + Ok(()) +} + +/// Archive entries that must not be materialized inline because the tar +/// order does not guarantee their prerequisites exist yet. +#[derive(Default)] +struct DeferredFixups { + /// Directory entries with the mode they ship; applied last. Images + /// routinely ship read-only directories (Fedora's ca-trust tree is + /// dr-xr-xr-x) whose contents would be rejected by an eagerly-applied + /// mode. + dir_modes: Vec<(PathBuf, u32)>, + /// Hard link entries as (destination, raw link target); applied after + /// the file pass so targets appearing later in the tar (or in an + /// earlier layer) resolve. + hard_links: Vec<(PathBuf, PathBuf)>, +} + +/// Join an archive-relative entry path onto `dest`, refusing paths that +/// traverse above the root (mirrors tar's own unpack component walk). +fn safe_join(dest: &Path, rel: &Path) -> Option { + let mut out = dest.to_path_buf(); + for part in rel.components() { + match part { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => return None, + Component::Normal(p) => out.push(p), + } + } + Some(out) +} + +/// Resolve a raw link target against `dest`, normalizing `..` lexically. +/// None when the target climbs out of the rootfs. +fn normalize_within(dest: &Path, target: &Path) -> Option { + let mut out = dest.to_path_buf(); + for part in target.components() { + match part { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + if out == dest { + return None; + } + out.pop(); + } + Component::Normal(p) => out.push(p), + } + } + Some(out) +} + +/// Apply hard links and directory modes collected during extraction. +fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> anyhow::Result<()> { + for (dst, target) in fixups.hard_links.drain(..) { + apply_hard_link(dest, &dst, &target)?; + } + for (path, mode) in fixups.dir_modes.drain(..) { + // Errors are ignored: a later layer's whiteout may have removed the + // directory, and the mode is cosmetic for our chroot use. + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)); + } + Ok(()) +} + +/// Materialize one hard link. A real hard link is used when the target +/// exists on disk; otherwise the entry is preserved verbatim as a symlink — +/// some images (Fedora's ca-trust directory-hash) ship links whose targets +/// are generated after linking and never exist anywhere in the archive. +fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> anyhow::Result<()> { + if dst.symlink_metadata().is_ok() { + std::fs::remove_file(dst) + .with_context(|| format!("Failed to replace {}", dst.display()))?; + } + if let Some(src) = normalize_within(dest, target) { + if std::fs::hard_link(&src, dst).is_ok() { + return Ok(()); + } + } + std::os::unix::fs::symlink(target, dst) + .with_context(|| format!("Failed to link {} -> {}", dst.display(), target.display()))?; + log::debug!( + "Warning: hard link target {} absent; extracted {} as a symlink", + target.display(), + dst.display() + ); + Ok(()) +} + +/// Extract tar archive entries, reporting a per-file count through +/// `progress` and handling whiteout files for OCI layers +fn extract_archive( + mut archive: tar::Archive, + dest: &Path, + label: &str, + fixups: &mut DeferredFixups, + progress: &dyn Progress, +) -> anyhow::Result<()> { + progress.begin(label, Unit::Files, None); + + archive.set_preserve_permissions(true); + archive.set_preserve_ownerships(false); + archive.set_unpack_xattrs(false); + + let entries = archive + .entries() + .context("Failed to read archive entries")?; + + let mut count: u64 = 0; + for entry in entries { + let mut entry = entry.context("Failed to read archive entry")?; + + // Clone the path before any mutable borrow of entry + 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(); + + // Handle whiteout files (OCI layer markers for deletions) + if filename == ".wh..wh..opq" { + // Opaque whiteout: clear all previously-extracted content in the parent directory + 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); + 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 if entry.header().entry_type().is_dir() { + // Create the directory but defer its mode: the mode in the tar + // may be read-only, and this directory's own contents (and other + // layers') still need to be written into it. + if let Some(dest_dir) = safe_join(dest, &path) { + std::fs::create_dir_all(&dest_dir) + .with_context(|| format!("Failed to create {}", dest_dir.display()))?; + if let Ok(mode) = entry.header().mode() { + fixups.dir_modes.push((dest_dir, mode)); + } + } + } else if entry.header().entry_type().is_hard_link() { + // Defer: the target may be a later entry in this tar, live in an + // earlier layer, or not exist at all (see apply_hard_link). + if let Some(dest_path) = safe_join(dest, &path) { + if let Some(link) = entry.link_name().context("Invalid hard link target")? { + if !link.as_os_str().is_empty() { + fixups.hard_links.push((dest_path, link.into_owned())); + } + } + } + } else { + entry + .unpack_in(dest) + .with_context(|| format!("Failed to extract {}", path.display()))?; + } + + count += 1; + progress.advance(1); + } + + progress.finish(); + log::debug!("Extracted {} files", count); + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::progress::NoProgress; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use tar::{Builder, EntryType, Header}; + + /// Build a plain tar archive from (path, entry-type, mode, payload) + /// tuples in the given order; link payloads are the raw link targets. + fn build_tar(path: &Path, entries: &[(&str, EntryType, u32, &[u8])]) { + let file = File::create(path).unwrap(); + let mut ar = Builder::new(file); + for (name, kind, mode, payload) in entries { + let mut header = Header::new_gnu(); + header.set_size(if *kind == EntryType::Regular { + payload.len() as u64 + } else { + 0 + }); + header.set_entry_type(*kind); + header.set_mode(*mode); + if *kind == EntryType::Symlink || *kind == EntryType::Link { + header + .set_link_name(std::path::PathBuf::from( + std::str::from_utf8(payload).unwrap(), + )) + .unwrap(); + } + header.set_cksum(); + if *kind == EntryType::Regular { + ar.append_data(&mut header, name, *payload).unwrap(); + } else { + ar.append_data(&mut header, name, std::io::empty()).unwrap(); + } + } + ar.finish().unwrap(); + } + + /// Fedora's ca-certificates ships etc/pki/ca-trust/extracted/pem/ + /// directory-hash/ as a read-only (0555) directory full of per-cert hash + /// symlinks (002c0b4f.0 -> GlobalSign_Root_R46.pem) whose targets come + /// later in the tar or not at all, plus hard links elsewhere in the + /// image. Extracting must never open or follow a link payload, and must + /// not choke on the read-only directory. + #[test] + fn fedora_directory_hash_links_extract_without_following() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tarball = src.path().join("rootfs.tar"); + build_tar( + &tarball, + &[ + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/", + EntryType::Directory, + 0o555, + b"", + ), + // symlink whose target appears later in the archive + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/002c0b4f.0", + EntryType::Symlink, + 0o777, + b"GlobalSign_Root_R46.pem", + ), + // symlink whose target never appears (dangling) + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/01419da9.0", + EntryType::Symlink, + 0o777, + b"Missing_Root.pem", + ), + // the real cert file, written after the read-only dir exists + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/GlobalSign_Root_R46.pem", + EntryType::Regular, + 0o644, + b"-----BEGIN CERTIFICATE-----", + ), + ], + ); + + extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); + + let base = out + .path() + .join("etc/pki/ca-trust/extracted/pem/directory-hash"); + assert_eq!( + std::fs::read_link(base.join("002c0b4f.0")).unwrap(), + Path::new("GlobalSign_Root_R46.pem") + ); + assert_eq!( + std::fs::read_link(base.join("01419da9.0")).unwrap(), + Path::new("Missing_Root.pem"), + "dangling symlinks must be created verbatim, not followed" + ); + assert_eq!( + std::fs::read(base.join("GlobalSign_Root_R46.pem")).unwrap(), + b"-----BEGIN CERTIFICATE-----" + ); + let dir_mode = base.symlink_metadata().unwrap().permissions().mode() & 0o7777; + assert_eq!(dir_mode, 0o555, "shipped directory mode must be restored"); + } + + #[test] + fn hard_link_to_later_target_extracts() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tarball = src.path().join("rootfs.tar"); + build_tar( + &tarball, + &[ + ("etc/dir/", EntryType::Directory, 0o755, b""), + // hard link recorded before its target + ( + "etc/dir/link.0", + EntryType::Link, + 0o644, + b"etc/dir/real.pem", + ), + ("etc/dir/real.pem", EntryType::Regular, 0o644, b"payload"), + ], + ); + + extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); + assert_eq!( + std::fs::read(out.path().join("etc/dir/link.0")).unwrap(), + b"payload" + ); + let link = out + .path() + .join("etc/dir/link.0") + .symlink_metadata() + .unwrap(); + assert!( + !link.file_type().is_symlink(), + "target exists later in the tar, so a real hard link is expected" + ); + let link_ino = std::fs::metadata(out.path().join("etc/dir/link.0")) + .unwrap() + .ino(); + let real_ino = std::fs::metadata(out.path().join("etc/dir/real.pem")) + .unwrap() + .ino(); + assert_eq!(link_ino, real_ino); + } + + #[test] + fn dangling_hard_link_does_not_abort_extraction() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tarball = src.path().join("rootfs.tar"); + build_tar( + &tarball, + &[ + ("etc/dir/", EntryType::Directory, 0o755, b""), + // target is not in the archive at all + ( + "etc/dir/dangling.0", + EntryType::Link, + 0o644, + b"etc/dir/absent.pem", + ), + ("etc/dir/keep.txt", EntryType::Regular, 0o644, b"kept"), + ], + ); + + extract_tarball(&tarball, out.path(), &NoProgress).unwrap(); + assert_eq!( + std::fs::read(out.path().join("etc/dir/keep.txt")).unwrap(), + b"kept", + "extraction must not abort on an unresolvable hard link" + ); + let meta = out.path().join("etc/dir/dangling.0").symlink_metadata(); + assert!(meta.map(|m| m.file_type().is_symlink()).unwrap_or(false)); + assert_eq!( + std::fs::read_link(out.path().join("etc/dir/dangling.0")).unwrap(), + Path::new("etc/dir/absent.pem") + ); + } +} diff --git a/crates/ecrou/src/lib.rs b/crates/ecrou/src/lib.rs new file mode 100644 index 0000000..5a957b6 --- /dev/null +++ b/crates/ecrou/src/lib.rs @@ -0,0 +1,35 @@ +//! ecrou - one-shot microVM environments on libkrun. +//! +//! This crate is the library behind the `ecrou` CLI. It resolves distro +//! and OCI image references, downloads them through a content cache, +//! extracts the rootfs into a scratch directory, and boots it as a +//! one-shot microVM: one command runs as the machine's only workload, +//! its stdio streams through, its exit code comes back, and the machine +//! is thrown away. The rootfs crosses as a virtiofs share, the workspace +//! as explicit shares the consumer names. +//! +//! The founding rule: the host process never mounts a filesystem and +//! never enters a namespace. Every boundary crossing is a file or a +//! socket, which is what lets ecrou run inside a strict snap's seccomp +//! allowlist. See SPEC.md. +//! +//! The main entry points are [`rootfs`] (cache-aware rootfs preparation +//! and persistence) and [`vm`] (running a command inside a prepared +//! rootfs). Public functions return [`Result`] carrying the [`Error`] +//! enum, so consumers can match on host-readiness failures +//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus +//! ordinary failures ([`Error::Other`], which preserves the full context +//! chain). + +pub mod config; +pub mod distro; +pub mod download; +pub mod error; +pub mod extract; +pub mod progress; +pub mod rootfs; +pub mod utils; + +pub use error::{Error, Result}; +pub use progress::{NoProgress, Progress, Unit}; +pub use rootfs::{PrepareRequest, PreparedRootfs, RootfsCache}; diff --git a/crates/ecrou/src/progress.rs b/crates/ecrou/src/progress.rs new file mode 100644 index 0000000..58b3496 --- /dev/null +++ b/crates/ecrou/src/progress.rs @@ -0,0 +1,42 @@ +//! Progress reporting for long-running operations. +//! +//! The library never touches the terminal: downloads, extraction and +//! initramfs creation report their progress through the [`Progress`] +//! sink supplied by the caller. Consumers choose what to render — +//! progress bars, spinners, structured events — or pass [`NoProgress`] +//! for silence. + +/// What a progress operation counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unit { + /// Bytes transferred (downloads). + Bytes, + /// Files processed (extraction, archive creation). + Files, +} + +/// Sink for progress of long-running library operations. +/// +/// Operations are sequential: `begin` starts the next operation (any +/// previous one is implicitly over), `advance` counts completed units, +/// `finish` marks its end. Sinks must be callable from any thread. +pub trait Progress: Send + Sync { + /// An operation labelled `label` starts, counting `unit`s; `total` + /// is the unit count when known upfront. + fn begin(&self, label: &str, unit: Unit, total: Option); + + /// `n` more units of the current operation completed. + fn advance(&self, n: u64); + + /// The current operation ended. + fn finish(&self); +} + +/// A [`Progress`] sink that discards every event. +pub struct NoProgress; + +impl Progress for NoProgress { + fn begin(&self, _: &str, _: Unit, _: Option) {} + fn advance(&self, _: u64) {} + fn finish(&self) {} +} diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs new file mode 100644 index 0000000..f44581d --- /dev/null +++ b/crates/ecrou/src/rootfs.rs @@ -0,0 +1,597 @@ +//! Cache-aware rootfs lifecycle: resolve an image reference, download +//! through the tarball cache, extract into a scratch directory, and +//! optionally persist a provisioned rootfs back into the cache. +//! +//! The hot-cell flow is [`RootfsCache::prepare_provisioned`]: provision +//! once (package installs, user setup, …), and every subsequent call — +//! in this or another process sharing the cache — hits the persisted +//! entry and skips both the download and the provisioning step: +//! +//! ```no_run +//! use ecrou::{NoProgress, PrepareRequest, RootfsCache}; +//! +//! # fn main() -> anyhow::Result<()> { +//! let cache = RootfsCache::new(RootfsCache::default_dir()?); +//! let req = PrepareRequest::new("alpine:3.23"); +//! +//! let rootfs = cache.prepare_provisioned(&req, &NoProgress, |rootfs| { +//! // Provision by editing the tree host-side. In-rootfs commands +//! // arrive with vm::run, which boots the provisioned rootfs. +//! std::fs::write(rootfs.dir().join("etc/motd"), "provisioned by ecrou\n")?; +//! Ok(()) +//! })?; +//! +//! // Amortized runs hit the provisioned entry directly +//! let dir = rootfs.dir().to_path_buf(); +//! # let _ = dir; Ok(()) +//! # } +//! ``` + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context}; + +use crate::Result; +use tempfile::TempDir; + +use crate::distro::{ + map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource, +}; +use crate::download::{digest_sidecar, download_image, fetch_oci_digest}; +use crate::extract::extract_tarball; +use crate::progress::Progress; + +/// Sidecar suffix marking a cache entry that holds a provisioned rootfs. +const PROVISIONED_SUFFIX: &str = "provisioned"; + +/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.`. +fn sidecar(path: &Path, suffix: &str) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push("."); + s.push(suffix); + PathBuf::from(s) +} + +/// The image tarball cache (default location: `~/.cache/ecrou`). +#[derive(Debug, Clone)] +pub struct RootfsCache { + dir: PathBuf, +} + +impl RootfsCache { + pub fn new(dir: impl Into) -> Self { + RootfsCache { dir: dir.into() } + } + + /// Default cache directory: `~/.cache/ecrou` (or the platform equivalent). + pub fn default_dir() -> Result { + dirs::cache_dir() + .map(|p| p.join("ecrou")) + .ok_or_else(|| anyhow!("Could not determine cache directory").into()) + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Cache entry (tarball path) for an image source and architecture. + pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf { + self.dir.join(generate_cache_filename(source, arch)) + } + + /// Resolve `req`, download through the cache when needed, and extract + /// into a fresh scratch directory (removed when the returned + /// [`PreparedRootfs`] is dropped). Download and extraction progress is + /// reported through `progress`. + pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { + let arch = req.resolved_arch(); + let source = resolve_source(&req.image, &arch)?; + let cache_path = self.tarball_path(&source, &arch); + + // 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, + .. + } = &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) => { + log::warn!("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 when not cached, explicitly requested, or the remote digest + // has moved + if req.no_cache || !cache_path.exists() || oci_digest_changed { + std::fs::create_dir_all(&self.dir).with_context(|| { + format!("Failed to create cache directory {}", self.dir.display()) + })?; + download_image(&source, &cache_path, &arch, progress)?; + } else { + log::debug!("Using cached tarball: {}", cache_path.display()); + } + + Ok(extract_prepared(cache_path, arch, progress)?) + } + + /// Prepare a provisioned rootfs with cache amortization: when the cache + /// entry is already marked provisioned it is extracted as-is; otherwise + /// the rootfs is prepared from the pristine image, `provision` runs + /// once, and the result is persisted into the cache so subsequent calls + /// (including from other processes sharing this cache) skip both the + /// download and the provisioning step. + /// + /// A provisioned entry no longer tracks the upstream image: for + /// `:latest` OCI images the freshness check is skipped on a marker hit. + /// Re-provision by removing the entry and its `.provisioned` sidecar, or + /// by passing `no_cache: true` (which re-downloads, re-provisions and + /// re-persists). + pub fn prepare_provisioned( + &self, + req: &PrepareRequest, + progress: &dyn Progress, + provision: F, + ) -> Result + where + F: FnOnce(&PreparedRootfs) -> anyhow::Result<()>, + { + let arch = req.resolved_arch(); + let source = resolve_source(&req.image, &arch)?; + let cache_path = self.tarball_path(&source, &arch); + let marker = sidecar(&cache_path, PROVISIONED_SUFFIX); + + if !req.no_cache && cache_path.exists() && marker.exists() { + log::debug!("Using provisioned cache entry: {}", cache_path.display()); + return Ok(extract_prepared(cache_path, arch, progress)?); + } + + let rootfs = self.prepare(req, progress)?; + provision(&rootfs)?; + rootfs.persist().with_context(|| { + format!( + "Failed to persist provisioned rootfs to {}", + cache_path.display() + ) + })?; + Ok(rootfs) + } +} + +/// Request for [`RootfsCache::prepare`] / [`RootfsCache::prepare_provisioned`]. +#[derive(Debug, Clone)] +pub struct PrepareRequest { + /// Image reference: a distro name ("ubuntu", "ubuntu:noble", + /// "alpine:3.23") or an OCI reference ("debian", + /// "ghcr.io/org/image:tag", "docker://…"). + pub image: String, + + /// Target architecture (e.g. "amd64", "arm64"). Empty selects the host + /// architecture. + pub arch: String, + + /// Ignore the cache and download a fresh image. + pub no_cache: bool, +} + +impl PrepareRequest { + pub fn new(image: impl Into) -> Self { + PrepareRequest { + image: image.into(), + arch: String::new(), + no_cache: false, + } + } + + /// The effective architecture: the request value, or the host arch. + pub fn resolved_arch(&self) -> String { + if self.arch.is_empty() { + crate::utils::get_host_arch().debian_name().to_string() + } else { + self.arch.clone() + } + } +} + +/// A rootfs extracted into a scratch directory, ready for +/// [`crate::vm::run`] to boot. The directory is removed when this value is +/// dropped, unless [`PreparedRootfs::persist`] ran first. +#[derive(Debug)] +pub struct PreparedRootfs { + rootfs: TempDir, + cache_path: PathBuf, + arch: String, +} + +impl PreparedRootfs { + /// The extracted rootfs directory. + pub fn dir(&self) -> &Path { + self.rootfs.path() + } + + /// Architecture this rootfs was prepared for. + pub fn arch(&self) -> &str { + &self.arch + } + + /// The cache entry this rootfs was prepared from. + pub fn cache_entry(&self) -> &Path { + &self.cache_path + } + + /// Persist the current contents of the rootfs into the cache entry it + /// was prepared from, replacing the pristine image tarball, and mark + /// the entry provisioned. Later preparations for the same image and + /// architecture hit this entry directly; note that plain + /// [`RootfsCache::prepare`] also returns the provisioned contents + /// (indistinguishable from a pristine hit) — only + /// [`RootfsCache::prepare_provisioned`] interprets the marker. + pub fn persist(&self) -> Result<()> { + pack_rootfs(self.dir(), &self.cache_path)?; + std::fs::write(sidecar(&self.cache_path, PROVISIONED_SUFFIX), b"").with_context(|| { + format!( + "Failed to write provisioned marker for {}", + self.cache_path.display() + ) + })?; + log::debug!("Persisted rootfs to cache: {}", self.cache_path.display()); + Ok(()) + } +} + +/// Parse an image reference and resolve floating version aliases ("latest", +/// "lts", "edge", …) to a concrete version, so the cache key is stable +/// (e.g. "ubuntu-noble-amd64", not "ubuntu-latest-amd64") and a future +/// release automatically gets its own cache entry. +fn resolve_source(image: &str, arch: &str) -> anyhow::Result { + let source = parse_image_ref(image, arch)?; + match source { + ImageSource::DirectTarball { distro, version } => { + let resolved = resolve_distro_version(&distro, version.as_deref(), arch)?; + Ok(ImageSource::DirectTarball { + distro, + version: Some(resolved), + }) + } + other => Ok(other), + } +} + +fn extract_prepared( + cache_path: PathBuf, + arch: String, + progress: &dyn Progress, +) -> anyhow::Result { + let temp = tempfile::tempdir().context("Failed to create temporary directory")?; + let rootfs = temp.path().to_path_buf(); + + log::debug!("Extracting to: {}", rootfs.display()); + extract_tarball(&cache_path, &rootfs, progress)?; + + Ok(PreparedRootfs { + rootfs: temp, + cache_path, + arch, + }) +} + +/// Write a tarball of `rootfs` at `dest`, compressed according to dest's +/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a +/// `.partial` file. +fn pack_rootfs(rootfs: &Path, dest: &Path) -> anyhow::Result<()> { + // Packing the tree into a tarball placed inside it would recurse + // forever: the walker keeps seeing the archive grow. + let rootfs_canonical = rootfs + .canonicalize() + .with_context(|| format!("Failed to resolve rootfs directory {}", rootfs.display()))?; + if let Some(parent) = dest.parent() { + if let Ok(parent_canonical) = parent.canonicalize() { + if parent_canonical.starts_with(&rootfs_canonical) { + return Err(anyhow!( + "Cache entry {} must live outside the rootfs", + dest.display() + )); + } + } + } + + let filename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let partial = dest.with_extension("partial"); + + let file = std::fs::File::create(&partial) + .with_context(|| format!("Failed to create {}", partial.display()))?; + + let result: anyhow::Result<()> = (|| { + if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let encoder = write_rootfs_tar(rootfs, encoder)?; + encoder.finish().context("Failed to finalise gzip stream")?; + } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { + let encoder = xz2::write::XzEncoder::new(file, 6); + let encoder = write_rootfs_tar(rootfs, encoder)?; + encoder.finish().context("Failed to finalise xz stream")?; + } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { + let encoder = zstd::stream::write::Encoder::new(file, 0) + .context("Failed to create zstd encoder")?; + let encoder = write_rootfs_tar(rootfs, encoder)?; + encoder.finish().context("Failed to finalise zstd stream")?; + } else { + write_rootfs_tar(rootfs, file)?; + } + Ok(()) + })(); + + if result.is_err() { + // Best-effort cleanup; ignore errors (file may not exist if creation failed). + let _ = std::fs::remove_file(&partial); + return result; + } + + std::fs::rename(&partial, dest) + .with_context(|| format!("Failed to move packed rootfs to {}", dest.display())) +} + +/// Write the rootfs tree into a tar archive streamed to `writer`, returning +/// the writer so the caller can finalise the compression stream. +fn write_rootfs_tar(rootfs: &Path, writer: W) -> anyhow::Result { + let mut builder = tar::Builder::new(writer); + // Store symlinks as symlinks; following them would duplicate the target's + // contents into the archive (and recurse into symlinked directories). + builder.follow_symlinks(false); + + let entries = std::fs::read_dir(rootfs) + .with_context(|| format!("Failed to read {}", rootfs.display()))?; + + for entry in entries { + let entry = + entry.with_context(|| format!("Failed to read entry in {}", rootfs.display()))?; + let path = entry.path(); + let name = entry.file_name(); + + // symlink_metadata: never follow symlinks — a top-level symlink to a + // directory must be stored as a link, not recursed into. + let meta = std::fs::symlink_metadata(&path) + .with_context(|| format!("Failed to stat {}", path.display()))?; + + if meta.is_dir() { + builder + .append_dir_all(&name, &path) + .with_context(|| format!("Failed to archive {}", path.display()))?; + } else { + builder + .append_path_with_name(&path, &name) + .with_context(|| format!("Failed to archive {}", path.display()))?; + } + } + + builder + .into_inner() + .context("Failed to finalise tar archive") +} + +/// 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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::progress::NoProgress; + use std::os::unix::fs::PermissionsExt; + + fn direct_tarball(distro: Distro, version: &str) -> ImageSource { + ImageSource::DirectTarball { + distro, + version: Some(version.to_string()), + } + } + + #[test] + fn cache_filename_direct_tarball() { + // Explicit codename/version: no network lookups + let cache = RootfsCache::new("/cache"); + assert_eq!( + cache.tarball_path(&direct_tarball(Distro::Ubuntu, "noble"), "amd64"), + PathBuf::from("/cache/ubuntu-noble-amd64.tar.gz") + ); + assert_eq!( + cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"), + PathBuf::from("/cache/alpine-3.23.1-x86_64.tar.gz") + ); + } + + #[test] + fn cache_filename_oci_sanitized() { + let cache = RootfsCache::new("/cache"); + let source = ImageSource::OciImage { + registry: "docker.io".to_string(), + repository: "library/ubuntu".to_string(), + tag: "latest".to_string(), + architecture: "amd64".to_string(), + }; + assert_eq!( + cache.tarball_path(&source, "amd64"), + PathBuf::from("/cache/oci-docker_io-library_ubuntu-latest-amd64.tar.gz") + ); + } + + #[test] + fn resolved_arch_defaults_to_host() { + let req = PrepareRequest::new("alpine"); + assert_eq!( + req.resolved_arch(), + crate::utils::get_host_arch().debian_name() + ); + + let mut req = PrepareRequest::new("alpine"); + req.arch = "arm64".to_string(); + assert_eq!(req.resolved_arch(), "arm64"); + } + + #[test] + fn sidecar_appends_suffix() { + assert_eq!( + sidecar(Path::new("/cache/foo.tar.gz"), PROVISIONED_SUFFIX), + PathBuf::from("/cache/foo.tar.gz.provisioned") + ); + } + + /// Build a minimal rootfs tree with a nested executable and a symlink, + /// pack it, and extract it back through the public extract_tarball path. + fn roundtrip(dest_name: &str) { + let src = tempfile::tempdir().unwrap(); + std::fs::write(src.path().join("hello.txt"), b"hello").unwrap(); + std::fs::create_dir_all(src.path().join("usr/bin")).unwrap(); + std::fs::write(src.path().join("usr/bin/tool"), b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions( + src.path().join("usr/bin/tool"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + std::os::unix::fs::symlink("hello.txt", src.path().join("link.txt")).unwrap(); + + let cache = tempfile::tempdir().unwrap(); + let dest = cache.path().join(dest_name); + pack_rootfs(src.path(), &dest).unwrap(); + + let out = tempfile::tempdir().unwrap(); + crate::extract::extract_tarball(&dest, out.path(), &NoProgress).unwrap(); + + assert_eq!( + std::fs::read(out.path().join("hello.txt")).unwrap(), + b"hello" + ); + let mode = std::fs::metadata(out.path().join("usr/bin/tool")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755, "executable bit must survive"); + assert_eq!( + std::fs::read_link(out.path().join("link.txt")).unwrap(), + Path::new("hello.txt") + ); + } + + #[test] + fn persist_roundtrip_gzip() { + roundtrip("rootfs.tar.gz"); + } + + #[test] + fn persist_roundtrip_xz() { + roundtrip("rootfs.tar.xz"); + } + + #[test] + fn persist_roundtrip_zstd() { + roundtrip("rootfs.tar.zst"); + } + + #[test] + fn persist_roundtrip_plain_tar() { + roundtrip("rootfs.tar"); + } + + #[test] + fn persist_writes_entry_and_provisioned_marker() { + let rootfs_dir = tempfile::tempdir().unwrap(); + std::fs::write(rootfs_dir.path().join("file.txt"), b"data").unwrap(); + + let cache_dir = tempfile::tempdir().unwrap(); + let cache_path = cache_dir.path().join("ubuntu-noble-amd64.tar.gz"); + + let prepared = PreparedRootfs { + rootfs: rootfs_dir, + cache_path: cache_path.clone(), + arch: "amd64".to_string(), + }; + prepared.persist().unwrap(); + + assert!(cache_path.exists()); + assert!(sidecar(&cache_path, PROVISIONED_SUFFIX).exists()); + + // The persisted entry extracts back to the original contents + let out = tempfile::tempdir().unwrap(); + crate::extract::extract_tarball(&cache_path, out.path(), &NoProgress).unwrap(); + assert_eq!(std::fs::read(out.path().join("file.txt")).unwrap(), b"data"); + } + + #[test] + fn pack_failure_leaves_no_partial_file() { + // A nonexistent rootfs must fail cleanly and clean up the .partial + let cache = tempfile::tempdir().unwrap(); + let dest = cache.path().join("x.tar.gz"); + assert!(pack_rootfs(Path::new("/nonexistent-rootfs"), &dest).is_err()); + assert!(!dest.exists()); + assert!(!dest.with_extension("partial").exists()); + } +} diff --git a/crates/ecrou/src/utils.rs b/crates/ecrou/src/utils.rs new file mode 100644 index 0000000..d9da1e2 --- /dev/null +++ b/crates/ecrou/src/utils.rs @@ -0,0 +1,313 @@ +use anyhow::anyhow; + +use crate::Result; +use std::path::Path; + +// ============================================================================ +// Constants +// ============================================================================ + +/// Length of the random hostname suffix (36⁶ ≈ 2×10⁹ distinct values) +pub const HOSTNAME_SUFFIX_LEN: usize = 6; + +/// Random lowercase-alphanumeric suffix for hostnames. Seeded from the +/// current time and the PID so calls in rapid succession — including from +/// separate processes — produce different values; time alone truncated to +/// the suffix space repeats within a tight loop. +pub fn random_suffix(len: usize) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + std::time::SystemTime::now().hash(&mut hasher); + std::process::id().hash(&mut hasher); + let mut state = hasher.finish(); + + const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + // Knuth multiplicative LCG — each step advances the full 64-bit state. + (0..len) + .map(|_| { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + CHARS[(state >> 33) as usize % CHARS.len()] as char + }) + .collect() +} + +// ============================================================================ +// Architecture handling +// ============================================================================ + +/// Architecture representation in different naming conventions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arch { + /// x86-64 (AMD64, x86_64) + Amd64, + /// ARM 64-bit (AArch64) + Arm64, + /// ARM 32-bit hard-float + Armhf, + /// RISC-V 64-bit + Riscv64, + /// PowerPC 64-bit little-endian + Ppc64el, + /// IBM s390x + S390x, + /// Unknown architecture + Unknown, +} + +impl Arch { + /// Get the architecture from a string (any common naming convention) + /// Not a FromStr impl: unrecognized names map to Arch::Unknown rather + /// than an error, by design. + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + match s { + "amd64" | "x86_64" | "x64" => Arch::Amd64, + "arm64" | "aarch64" | "arm64v8" => Arch::Arm64, + "armhf" | "armv7" | "armv7l" | "arm" => Arch::Armhf, + "riscv64" => Arch::Riscv64, + "ppc64el" | "ppc64le" => Arch::Ppc64el, + "s390x" => Arch::S390x, + _ => Arch::Unknown, + } + } + + /// Get the Debian/Ubuntu style name + pub fn debian_name(&self) -> &'static str { + match self { + Arch::Amd64 => "amd64", + Arch::Arm64 => "arm64", + Arch::Armhf => "armhf", + Arch::Riscv64 => "riscv64", + Arch::Ppc64el => "ppc64el", + Arch::S390x => "s390x", + Arch::Unknown => "unknown", + } + } + + /// Get the OCI/Docker registry style name + pub fn oci_name(&self) -> &'static str { + match self { + Arch::Amd64 => "amd64", + Arch::Arm64 => "arm64", + // OCI uses "arm" for 32-bit ARM with variant field + Arch::Armhf => "arm", + Arch::Riscv64 => "riscv64", + Arch::Ppc64el => "ppc64le", + Arch::S390x => "s390x", + Arch::Unknown => "unknown", + } + } + + /// Get the Alpine style name + pub fn alpine_name(&self) -> &'static str { + match self { + Arch::Amd64 => "x86_64", + Arch::Arm64 => "aarch64", + Arch::Armhf => "armv7", + Arch::Riscv64 => "riscv64", + Arch::Ppc64el => "ppc64le", + Arch::S390x => "s390x", + Arch::Unknown => "unknown", + } + } + + /// Get the QEMU binary suffix (e.g., "qemu-system-x86_64") + pub fn qemu_system_name(&self) -> &'static str { + match self { + Arch::Amd64 => "x86_64", + Arch::Arm64 => "aarch64", + Arch::Armhf => "arm", + Arch::Riscv64 => "riscv64", + Arch::Ppc64el => "ppc64", + Arch::S390x => "s390x", + Arch::Unknown => "unknown", + } + } + + /// Get the QEMU binfmt_misc name + pub fn qemu_binfmt_name(&self) -> &'static str { + match self { + Arch::Amd64 => "x86_64", + Arch::Arm64 => "aarch64", + Arch::Armhf => "arm", + Arch::Riscv64 => "riscv64", + Arch::Ppc64el => "ppc64le", + Arch::S390x => "s390x", + Arch::Unknown => "unknown", + } + } + + /// Get the package suffix for QEMU system emulator + pub fn qemu_package_suffix(&self) -> &'static str { + match self { + Arch::Amd64 => "x86", + Arch::Arm64 => "aarch64", + Arch::Armhf => "arm", + Arch::Riscv64 => "riscv64", + Arch::Ppc64el => "ppc", + Arch::S390x => "s390x", + Arch::Unknown => "unknown", + } + } +} + +/// Get the host system architecture using uname(2) syscall +/// This returns the runtime machine string, which is correct even when +/// the binary itself is running under emulation. +pub fn get_host_arch() -> Arch { + // Safety: utsname is a valid zeroed libc struct; uname(2) writes only + // within it and NUL-terminates each field before we read machine. + let mut utsname: libc::utsname = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::uname(&mut utsname) }; + if rc != 0 { + panic!("uname(2) failed: {}", std::io::Error::last_os_error()); + } + let machine = unsafe { std::ffi::CStr::from_ptr(utsname.machine.as_ptr()) }; + Arch::from_str(machine.to_string_lossy().as_ref()) +} + +/// Map ecrou architecture names to distro-specific names +pub fn map_arch_for_distro(distro: &str, arch: &str) -> String { + let arch_enum = Arch::from_str(arch); + match distro.to_lowercase().as_str() { + "ubuntu" => arch_enum.debian_name().to_string(), + "alpine" => arch_enum.alpine_name().to_string(), + _ => arch_enum.oci_name().to_string(), + } +} + +/// Map architecture to OCI registry standard names +pub fn map_oci_arch(arch: &str) -> String { + Arch::from_str(arch).oci_name().to_string() +} + +// ============================================================================ +// Shell detection +// ============================================================================ + +/// Detect the best available shell in a rootfs +/// Checks for bash first, falls back to sh +/// Returns the path relative to the rootfs (e.g., "/bin/bash") +pub fn detect_shell(rootfs: &Path) -> &'static str { + // Check for bash first (preferred) + if rootfs.join("bin/bash").exists() { + "/bin/bash" + } else if rootfs.join("bin/sh").exists() { + "/bin/sh" + } else if rootfs.join("usr/bin/bash").exists() { + "/usr/bin/bash" + } else if rootfs.join("usr/bin/sh").exists() { + "/usr/bin/sh" + } else { + "/bin/sh" // Will fail with clear error if not present + } +} + +// ============================================================================ +// Memory string validation +// ============================================================================ + +/// Validate a QEMU memory size string (e.g., "512M", "2G") +/// Returns an error if the format is invalid +pub fn validate_memory_string(s: &str) -> Result<()> { + if s.is_empty() { + return Err(anyhow!("Memory size cannot be empty").into()); + } + + // Must end with a valid suffix or be a plain number + let suffix = s.chars().last().unwrap(); + let has_suffix = suffix.is_ascii_alphabetic(); + + let numeric_part = if has_suffix { &s[..s.len() - 1] } else { s }; + + // Check for negative numbers + if numeric_part.starts_with('-') { + return Err(anyhow!("Memory size cannot be negative: {}", s).into()); + } + + // Must be a valid positive number + if numeric_part.is_empty() { + return Err(anyhow!("Memory size must have a numeric value: {}", s).into()); + } + + // Check if it's a valid integer + if !numeric_part.chars().all(|c| c.is_ascii_digit()) { + return Err(anyhow!( + "Memory size must be a positive integer with optional suffix: {}", + s + ) + .into()); + } + + // Check suffix is valid + if has_suffix { + let valid_suffixes = ['K', 'M', 'G', 'T']; + let suffix_upper = suffix.to_ascii_uppercase(); + if !valid_suffixes.contains(&suffix_upper) { + return Err(anyhow!( + "Invalid memory suffix '{}'. Valid suffixes: K, M, G, T", + suffix + ) + .into()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_arch_from_str() { + assert_eq!(Arch::from_str("amd64"), Arch::Amd64); + assert_eq!(Arch::from_str("x86_64"), Arch::Amd64); + assert_eq!(Arch::from_str("arm64"), Arch::Arm64); + assert_eq!(Arch::from_str("aarch64"), Arch::Arm64); + assert_eq!(Arch::from_str("armhf"), Arch::Armhf); + assert_eq!(Arch::from_str("armv7"), Arch::Armhf); + assert_eq!(Arch::from_str("riscv64"), Arch::Riscv64); + assert_eq!(Arch::from_str("ppc64el"), Arch::Ppc64el); + assert_eq!(Arch::from_str("ppc64le"), Arch::Ppc64el); + assert_eq!(Arch::from_str("s390x"), Arch::S390x); + } + + #[test] + fn test_arch_alpine_name() { + assert_eq!(Arch::Amd64.alpine_name(), "x86_64"); + assert_eq!(Arch::Arm64.alpine_name(), "aarch64"); + assert_eq!(Arch::Armhf.alpine_name(), "armv7"); + } + + #[test] + fn test_arch_oci_name() { + assert_eq!(Arch::Amd64.oci_name(), "amd64"); + assert_eq!(Arch::Arm64.oci_name(), "arm64"); + assert_eq!(Arch::Armhf.oci_name(), "arm"); + } + + #[test] + fn test_validate_memory_string_valid() { + assert!(validate_memory_string("512M").is_ok()); + assert!(validate_memory_string("2G").is_ok()); + assert!(validate_memory_string("1024").is_ok()); + assert!(validate_memory_string("1T").is_ok()); + assert!(validate_memory_string("256K").is_ok()); + assert!(validate_memory_string("2g").is_ok()); // lowercase + } + + #[test] + fn test_validate_memory_string_invalid() { + assert!(validate_memory_string("").is_err()); + assert!(validate_memory_string("-1G").is_err()); + assert!(validate_memory_string("2X").is_err()); + assert!(validate_memory_string("abc").is_err()); + assert!(validate_memory_string("G").is_err()); + assert!(validate_memory_string("1.5G").is_err()); + } +}