diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a403dd7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + 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..07ae121 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1002 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[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", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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 = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "oci_squashfs" +version = "0.1.0" +dependencies = [ + "anyhow", + "bzip2", + "flate2", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "tokio", + "xz2", + "zstd", +] + +[[package]] +name = "oci_squashfs_cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "oci_squashfs", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "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 = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..351e267 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["oci_squashfs", "oci_squashfs_cli"] +resolver = "2" diff --git a/oci_squashfs/Cargo.toml b/oci_squashfs/Cargo.toml new file mode 100644 index 0000000..6513935 --- /dev/null +++ b/oci_squashfs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "oci_squashfs" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["full"] } +tar = "0.4" +flate2 = { version = "1", features = ["zlib-rs"], default-features = false } +zstd = "0.13" +bzip2 = "0.6" +xz2 = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" +anyhow = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/oci_squashfs/src/canonical.rs b/oci_squashfs/src/canonical.rs new file mode 100644 index 0000000..8ad8796 --- /dev/null +++ b/oci_squashfs/src/canonical.rs @@ -0,0 +1,94 @@ +//! CanonicalTarHeader: a tar Header paired with its PAX extensions. +//! Ported from production vfs.rs — lets the `tar` crate own PAX serialization. + +use anyhow::{anyhow, Result}; +use std::borrow::Cow; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use tar::{Builder, EntryType, Header}; + +#[derive(Clone, Debug)] +pub struct CanonicalTarHeader { + pub header: Header, + pub pax_extensions: Vec<(String, String)>, +} + +impl CanonicalTarHeader { + pub fn from_entry(entry: &mut tar::Entry<'_, R>) -> Result { + let header = entry.header().clone(); + let pax_extensions = match entry.pax_extensions() { + Err(e) => return Err(anyhow!("failed to read PAX extensions: {e}")), + Ok(None) => vec![], + Ok(Some(exts)) => { + let mut pairs = Vec::new(); + for ext in exts { + let ext = ext.map_err(|e| anyhow!("invalid PAX extension: {e}"))?; + let key = ext + .key() + .map_err(|e| anyhow!("invalid PAX key: {e}"))? + .to_string(); + let val = ext + .value() + .map_err(|e| anyhow!("invalid PAX value: {e}"))? + .to_string(); + pairs.push((key, val)); + } + pairs + } + }; + Ok(Self { + header, + pax_extensions, + }) + } + + pub fn path(&self) -> Result> { + self.header + .path() + .map_err(|e| anyhow!("reading path from header: {e}")) + } + + pub fn entry_type(&self) -> EntryType { + self.header.entry_type() + } + + /// Return the link target path, preferring the PAX `linkpath` extension + /// over the USTAR header field. This is necessary because `Header::link_name()` + /// only reads the raw 100-byte USTAR field and will return a truncated path + /// for targets longer than 100 bytes, whereas the PAX extension carries the + /// full value. + pub fn link_name(&self) -> Result> { + // Check PAX extensions first. + if let Some((_, v)) = self.pax_extensions.iter().find(|(k, _)| k == "linkpath") { + return Ok(Some(PathBuf::from(v))); + } + // Fall back to the USTAR field. + Ok(self + .header + .link_name() + .map_err(|e| anyhow!("reading link_name from header: {e}"))? + .map(|p| p.into_owned())) + } + + pub fn write_to_tar( + &self, + path: &Path, + data: R, + builder: &mut Builder, + ) -> Result<()> { + if !self.pax_extensions.is_empty() { + builder + .append_pax_extensions( + self.pax_extensions + .iter() + .map(|(k, v)| (k.as_str(), v.as_bytes())), + ) + .map_err(|e| anyhow!("failed to append PAX extensions: {e}"))?; + } + let mut header = self.header.clone(); + builder + .append_data(&mut header, path, data) + .map_err(|e| anyhow!("failed to append entry: {e}"))?; + Ok(()) + } +} diff --git a/oci_squashfs/src/image.rs b/oci_squashfs/src/image.rs new file mode 100644 index 0000000..b63c72e --- /dev/null +++ b/oci_squashfs/src/image.rs @@ -0,0 +1,117 @@ +//! Parse OCI index.json + manifest, resolve layer blobs. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Deserialize)] +pub struct OciIndex { + pub manifests: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct OciDescriptor { + pub digest: String, + #[serde(rename = "mediaType", default)] + pub media_type: String, +} + +#[derive(Debug, Deserialize)] +pub struct OciManifest { + pub layers: Vec, +} + +/// One layer blob ready for processing. +#[derive(Debug)] +pub struct LayerBlob { + pub path: PathBuf, + pub media_type: String, + pub index: usize, +} + +/// Load the OCI manifest from index.json → manifest blob. +pub fn load_manifest(image_dir: &Path) -> Result { + // Try index.json first (OCI layout), fall back to manifest.json (Docker save). + let index_path = image_dir.join("index.json"); + if index_path.exists() { + let data = std::fs::read_to_string(&index_path) + .with_context(|| format!("reading {}", index_path.display()))?; + let index: OciIndex = serde_json::from_str(&data).context("parsing index.json")?; + let desc = index + .manifests + .into_iter() + .next() + .context("index.json has no manifests")?; + let digest = strip_digest_prefix(&desc.digest)?; + let manifest_path = image_dir.join("blobs").join("sha256").join(digest); + let mdata = std::fs::read_to_string(&manifest_path) + .with_context(|| format!("reading manifest blob {}", manifest_path.display()))?; + let manifest: OciManifest = + serde_json::from_str(&mdata).context("parsing manifest blob")?; + return Ok(manifest); + } + + // Docker save manifest.json + let manifest_path = image_dir.join("manifest.json"); + if manifest_path.exists() { + #[derive(Deserialize)] + struct DockerManifest { + #[serde(rename = "Layers")] + layers: Vec, + } + let data = std::fs::read_to_string(&manifest_path).context("reading manifest.json")?; + let manifests: Vec = + serde_json::from_str(&data).context("parsing manifest.json")?; + let dm = manifests + .into_iter() + .next() + .context("manifest.json is empty")?; + let layers = dm + .layers + .into_iter() + .map(|l| OciDescriptor { + digest: l, + media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".into(), + }) + .collect(); + return Ok(OciManifest { layers }); + } + + bail!( + "no index.json or manifest.json found in {}", + image_dir.display() + ); +} + +/// Resolve layer descriptors to actual file paths. +pub fn resolve_layers(image_dir: &Path, manifest: &OciManifest) -> Result> { + manifest + .layers + .iter() + .enumerate() + .map(|(i, desc)| { + let path = if desc.digest.contains(':') { + // OCI digest: sha256: + let hex = strip_digest_prefix(&desc.digest)?; + image_dir.join("blobs").join("sha256").join(hex) + } else { + // Docker save: relative path like "abc123.../layer.tar" + image_dir.join(&desc.digest) + }; + if !path.exists() { + bail!("layer blob not found: {}", path.display()); + } + Ok(LayerBlob { + path, + media_type: desc.media_type.clone(), + index: i, + }) + }) + .collect() +} + +pub fn strip_digest_prefix(digest: &str) -> Result<&str> { + digest + .strip_prefix("sha256:") + .with_context(|| format!("unsupported digest algorithm in: {digest}")) +} diff --git a/oci_squashfs/src/layers.rs b/oci_squashfs/src/layers.rs new file mode 100644 index 0000000..72140fb --- /dev/null +++ b/oci_squashfs/src/layers.rs @@ -0,0 +1,40 @@ +//! Open a layer blob and return a decompressed tar::Archive. + +use anyhow::{bail, Result}; +use std::{ + fs::File, + io::{self, BufReader, Read}, + path::Path, +}; +use tar::Archive; + +/// A type-erased decompressed tar reader. +pub type DynArchive = Archive>; + +pub fn open_layer(path: &Path, media_type: &str) -> Result { + let file = File::open(path)?; + let buf = BufReader::new(file); + let reader: Box = match media_type { + t if t.ends_with("+gzip") || t == "application/vnd.docker.image.rootfs.diff.tar.gzip" => { + Box::new(flate2::read::GzDecoder::new(buf)) + } + t if t.ends_with("+zstd") => Box::new(zstd::stream::read::Decoder::new(buf)?), + t if t.ends_with("+bzip2") => Box::new(bzip2::read::BzDecoder::new(buf)), + t if t.ends_with("+xz") || t.ends_with("+lzma") => Box::new(xz2::read::XzDecoder::new(buf)), + "application/vnd.oci.image.layer.v1.tar" => Box::new(buf), + other => bail!("unsupported layer media type: {other}"), + }; + let mut archive = Archive::new(reader); + archive.set_preserve_permissions(true); + archive.set_preserve_mtime(true); + archive.set_unpack_xattrs(true); + Ok(archive) +} + +/// Read all raw bytes of a layer entry into memory. +/// Returns (header_bytes, data_bytes) — header_bytes is the raw 512-byte block(s). +pub fn read_entry_data(entry: &mut tar::Entry) -> Result> { + let mut buf = Vec::new(); + io::copy(entry, &mut buf)?; + Ok(buf) +} diff --git a/oci_squashfs/src/lib.rs b/oci_squashfs/src/lib.rs new file mode 100644 index 0000000..ea7f4f7 --- /dev/null +++ b/oci_squashfs/src/lib.rs @@ -0,0 +1,26 @@ +pub mod canonical; +pub mod image; +pub mod layers; +pub mod overlay; +pub mod squashfs; +pub mod tracker; +pub mod verify; + +use anyhow::Result; +use std::path::Path; + +/// Convert an extracted OCI image directory into a squashfs file. +/// +/// Layer content is streamed directly into mksquashfs's stdin — the merged +/// tar is never fully materialised in memory. +pub async fn convert(image_dir: &Path, output_squashfs: &Path) -> Result<()> { + let image_dir = image_dir.to_path_buf(); + let output_squashfs = output_squashfs.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let manifest = image::load_manifest(&image_dir)?; + let layers = image::resolve_layers(&image_dir, &manifest)?; + squashfs::write_squashfs(layers, &output_squashfs) + }) + .await? +} diff --git a/oci_squashfs/src/overlay.rs b/oci_squashfs/src/overlay.rs new file mode 100644 index 0000000..737c908 --- /dev/null +++ b/oci_squashfs/src/overlay.rs @@ -0,0 +1,128 @@ +//! Core algorithm: merge OCI layers into a single flat tar stream. +//! +//! File content is never buffered in full — entries are streamed directly +//! into the provided `Write` sink (typically mksquashfs's stdin pipe). +//! The only in-memory state is the tracker data structures and the small +//! hard-link metadata structs deferred to the end. + +use anyhow::{Context, Result}; +use std::{ + io::Write, + path::{Path, PathBuf}, +}; +use tar::{Builder, EntryType}; + +use crate::{ + canonical::CanonicalTarHeader, + image::LayerBlob, + layers::open_layer, + tracker::{EmittedPathTracker, HardLinkTracker, WhiteoutTracker}, +}; + +/// Merge layers, streaming the resulting tar into `sink`. +/// +/// `sink` is typically the stdin pipe of a `mksquashfs` subprocess. +/// File data flows directly from the layer blobs into `sink` without +/// being accumulated in memory. +pub fn merge_layers_into(mut layers: Vec, sink: W) -> Result<()> { + // Process in reverse (newest first). + layers.sort_by_key(|l| std::cmp::Reverse(l.index)); + + let mut whiteout = WhiteoutTracker::default(); + let mut emitted = EmittedPathTracker::default(); + let mut hardlinks = HardLinkTracker::default(); + + let mut output = Builder::new(sink); + output.mode(tar::HeaderMode::Complete); + + for blob in &layers { + let mut archive = open_layer(&blob.path, &blob.media_type) + .with_context(|| format!("opening layer {}", blob.path.display()))?; + + let entries = archive.entries().context("reading tar entries")?; + for entry_result in entries { + let mut entry = entry_result.context("reading tar entry")?; + let raw_path = entry.path().context("entry path")?.into_owned(); + let path = normalize_path(&raw_path); + + // 1. Check whiteout suppression. + if whiteout.is_suppressed(&path, blob.index) { + continue; + } + + // 2. Check already-emitted. + if emitted.contains(&path) { + continue; + } + + // 3. Handle whiteout entries. + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if file_name == ".wh..wh..opq" { + let parent = path.parent().unwrap_or(Path::new("")); + whiteout.insert_opaque(parent, blob.index); + continue; + } + if let Some(real_name) = file_name.strip_prefix(".wh.") { + let parent = path.parent().unwrap_or(Path::new("")); + whiteout.insert_simple(&parent.join(real_name), blob.index); + continue; + } + + // We capture the header + PAX extensions first (cheap), then + // stream the entry body straight into the Builder without + // buffering it in a Vec. + let canonical = + CanonicalTarHeader::from_entry(&mut entry).context("capturing entry header")?; + + // 4. Handle hard links — defer, emitting only metadata structs. + if canonical.entry_type() == EntryType::Link { + let link_target = canonical + .link_name() + .context("reading hard link target")? + .context("hard link has no target")?; + let target_path = normalize_path(&link_target); + if !whiteout.is_suppressed(&target_path, blob.index) { + hardlinks.record(path, target_path, blob.index, canonical); + } + continue; + } + + // 5. Stream entry directly into the output tar. + // + canonical + .write_to_tar(&path, &mut entry, &mut output) + .with_context(|| format!("emitting {}", path.display()))?; + emitted.insert(&path); + } + } + + // Emit deferred hard links (oldest-layer first). + // These are pure metadata — no file content to stream. + for hl in hardlinks.drain_sorted() { + if !emitted.contains(&hl.target_path) { + continue; + } + hl.canonical + .write_to_tar(&hl.link_path, &[] as &[u8], &mut output) + .with_context(|| format!("emitting hard link {}", hl.link_path.display()))?; + emitted.insert(&hl.link_path); + } + + output.finish()?; + // Flush and drop the Builder, closing the write end of the pipe so + // mksquashfs sees EOF and knows the tar stream is complete. + let mut sink = output.into_inner()?; + sink.flush()?; + Ok(()) +} + +/// Strip leading `./` or `/` from paths. +pub fn normalize_path(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + let s = s.trim_start_matches("./").trim_start_matches('/'); + PathBuf::from(s) +} diff --git a/oci_squashfs/src/squashfs.rs b/oci_squashfs/src/squashfs.rs new file mode 100644 index 0000000..522f84d --- /dev/null +++ b/oci_squashfs/src/squashfs.rs @@ -0,0 +1,58 @@ +//! Spawn mksquashfs and stream the merged tar directly into its stdin. + +use anyhow::{bail, Context, Result}; +use std::{ + path::Path, + process::{Command, Stdio}, +}; + +use crate::{image::LayerBlob, overlay::merge_layers_into}; + +/// Convert `layers` into a squashfs image at `output` by streaming a merged +/// tar directly into mksquashfs's stdin. No full tar buffer is held in memory. +pub fn write_squashfs(layers: Vec, output: &Path) -> Result<()> { + if output.exists() { + std::fs::remove_file(output) + .with_context(|| format!("removing existing {}", output.display()))?; + } + + let mut child = Command::new("mksquashfs") + .args([ + "-", + output.to_str().context("output path is not UTF-8")?, + "-tar", + "-noappend", + "-no-fragments", + "-comp", + "zstd", + "-Xcompression-level", + "2", + "-quiet", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning mksquashfs — is it installed?")?; + + let stdin = child.stdin.take().context("child stdin")?; + + // Drive the merge on the current thread, writing directly into the pipe. + // mksquashfs reads and compresses concurrently in its own process, so + // the pipe provides natural backpressure without a helper thread. + let merge_result = merge_layers_into(layers, stdin); + + // Wait for mksquashfs regardless of whether the merge succeeded, so we + // don't leave a zombie process behind. + let exit = child.wait_with_output().context("waiting for mksquashfs")?; + + // Surface merge errors before subprocess errors — they're more actionable. + merge_result.context("merging layers into mksquashfs stdin")?; + + if !exit.status.success() { + let stderr = String::from_utf8_lossy(&exit.stderr); + bail!("mksquashfs failed:\n{stderr}"); + } + + Ok(()) +} diff --git a/oci_squashfs/src/tracker.rs b/oci_squashfs/src/tracker.rs new file mode 100644 index 0000000..36eb524 --- /dev/null +++ b/oci_squashfs/src/tracker.rs @@ -0,0 +1,139 @@ +//! WhiteoutTracker, EmittedPathTracker, HardLinkTracker. + +use std::{ + collections::{HashMap, HashSet}, + path::{Component, Path, PathBuf}, +}; + +use crate::canonical::CanonicalTarHeader; + +// ─── WhiteoutTracker ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +enum WhiteoutState { + Simple { layer_index: usize }, + Opaque { layer_index: usize }, +} + +#[derive(Debug, Default)] +struct WhiteoutNode { + state: Option, + children: HashMap, +} + +#[derive(Debug, Default)] +pub struct WhiteoutTracker { + root: WhiteoutNode, +} + +impl WhiteoutTracker { + /// Mark a specific path as suppressed (simple `.wh.` whiteout). + pub fn insert_simple(&mut self, path: &Path, layer_index: usize) { + let node = self.walk_or_create(path); + node.state = Some(WhiteoutState::Simple { layer_index }); + } + + /// Mark a directory path as opaque (`.wh..wh..opq`). + pub fn insert_opaque(&mut self, dir_path: &Path, layer_index: usize) { + let node = self.walk_or_create(dir_path); + node.state = Some(WhiteoutState::Opaque { layer_index }); + } + + /// Returns true if `path` from `current_layer` should be suppressed. + pub fn is_suppressed(&self, path: &Path, current_layer: usize) -> bool { + let components = normal_components(path); + let mut node = &self.root; + for (i, comp) in components.iter().enumerate() { + if let Some(state) = &node.state { + if let WhiteoutState::Opaque { layer_index } = state { + if current_layer < *layer_index { + return true; + } + } + } + match node.children.get(comp.as_str()) { + Some(child) => node = child, + None => return false, + } + // At terminal component: check Simple or Opaque. + if i == components.len() - 1 { + if let Some(state) = &node.state { + match state { + WhiteoutState::Simple { layer_index } + | WhiteoutState::Opaque { layer_index } => { + return current_layer < *layer_index; + } + } + } + } + } + false + } + + fn walk_or_create(&mut self, path: &Path) -> &mut WhiteoutNode { + let components = normal_components(path); + let mut node = &mut self.root; + for comp in &components { + node = node.children.entry(comp.clone()).or_default(); + } + node + } +} + +fn normal_components(path: &Path) -> Vec { + path.components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect() +} + +// ─── EmittedPathTracker ───────────────────────────────────────────────────── + +#[derive(Debug, Default)] +pub struct EmittedPathTracker(HashSet); + +impl EmittedPathTracker { + pub fn insert(&mut self, path: &Path) { + self.0.insert(path.to_path_buf()); + } + pub fn contains(&self, path: &Path) -> bool { + self.0.contains(path) + } +} + +// ─── HardLinkTracker ──────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct HardLinkEntry { + pub link_path: PathBuf, + pub target_path: PathBuf, + pub layer_index: usize, + pub canonical: CanonicalTarHeader, +} + +#[derive(Debug, Default)] +pub struct HardLinkTracker(pub Vec); + +impl HardLinkTracker { + pub fn record( + &mut self, + link_path: PathBuf, + target_path: PathBuf, + layer_index: usize, + canonical: CanonicalTarHeader, + ) { + self.0.push(HardLinkEntry { + link_path, + target_path, + layer_index, + canonical, + }); + } + /// Return deferred links sorted by ascending layer index. + pub fn drain_sorted(mut self) -> Vec { + self.0.sort_by_key(|e| e.layer_index); + self.0 + } +} diff --git a/oci_squashfs/src/verify.rs b/oci_squashfs/src/verify.rs new file mode 100644 index 0000000..9a931ab --- /dev/null +++ b/oci_squashfs/src/verify.rs @@ -0,0 +1,239 @@ +//! Verification helper: mount squashfs and diff against a reference directory. + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fs, + io::Read, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::{Path, PathBuf}, + process::Command, +}; +use tempfile::TempDir; + +// ─── RAII mount guard ───────────────────────────────────────────────────────── + +/// Mounts a squashfs via squashfuse and unmounts it on drop, even if an error +/// occurs during the walk. The underlying `TempDir` is kept alive for the +/// lifetime of this guard so the mountpoint isn't deleted while still mounted. +struct SquashMount { + mountpoint: TempDir, +} + +impl SquashMount { + fn new(squashfs: &Path) -> Result { + let mountpoint = TempDir::new().context("creating temp mount dir")?; + let status = Command::new("squashfuse") + .arg(squashfs) + .arg(mountpoint.path()) + .status() + .context("spawning squashfuse — is it installed?")?; + if !status.success() { + anyhow::bail!("squashfuse failed with status {status}"); + } + Ok(Self { mountpoint }) + } + + fn path(&self) -> &Path { + self.mountpoint.path() + } +} + +impl Drop for SquashMount { + fn drop(&mut self) { + // Try fusermount first (Linux), fall back to umount (macOS/BSD). + let ok = Command::new("fusermount") + .args(["-u", self.mountpoint.path().to_str().unwrap_or("")]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !ok { + let _ = Command::new("umount").arg(self.mountpoint.path()).status(); + } + // TempDir::drop runs after this and removes the now-unmounted directory. + } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct VerifyReport { + pub only_in_squashfs: Vec, + pub only_in_reference: Vec, + pub differences: Vec, +} + +#[derive(Debug)] +pub struct FileDiff { + pub path: PathBuf, + pub detail: String, +} + +pub fn verify(squashfs: &Path, reference: &Path) -> Result { + let mount = SquashMount::new(squashfs)?; + + let squashfs_tree = walk_tree(mount.path()).context("walking squashfs mount")?; + let reference_tree = walk_tree(reference).context("walking reference directory")?; + + let mut report = VerifyReport { + only_in_squashfs: Vec::new(), + only_in_reference: Vec::new(), + differences: Vec::new(), + }; + + for (rel, sq_info) in &squashfs_tree { + match reference_tree.get(rel) { + None => report.only_in_squashfs.push(rel.clone()), + Some(ref_info) => report + .differences + .extend(compare_entries(rel, sq_info, ref_info)), + } + } + for rel in reference_tree.keys() { + if !squashfs_tree.contains_key(rel) { + report.only_in_reference.push(rel.clone()); + } + } + + Ok(report) +} + +// ─── Tree walking ───────────────────────────────────────────────────────────── + +#[derive(Debug)] +struct EntryInfo { + kind: EntryKind, + mode: u32, + uid: u32, + gid: u32, + size: u64, + symlink_target: Option, + sha256: Option, +} + +#[derive(Debug, PartialEq)] +enum EntryKind { + File, + Dir, + Symlink, + Other, +} + +fn walk_tree(root: &Path) -> Result> { + let mut map = HashMap::new(); + walk_dir(root, root, &mut map)?; + Ok(map) +} + +fn walk_dir(root: &Path, current: &Path, map: &mut HashMap) -> Result<()> { + for entry in + fs::read_dir(current).with_context(|| format!("reading dir {}", current.display()))? + { + let entry = entry?; + let abs = entry.path(); + let rel = abs + .strip_prefix(root) + .context("strip prefix")? + .to_path_buf(); + let meta = fs::symlink_metadata(&abs) + .with_context(|| format!("metadata for {}", abs.display()))?; + let ft = meta.file_type(); + + let (kind, symlink_target, sha256) = if ft.is_symlink() { + (EntryKind::Symlink, Some(fs::read_link(&abs)?), None) + } else if ft.is_file() { + (EntryKind::File, None, Some(hash_file(&abs)?)) + } else if ft.is_dir() { + (EntryKind::Dir, None, None) + } else { + (EntryKind::Other, None, None) + }; + + // Mask to permission bits (rwxrwxrwx + setuid/setgid/sticky). + // File type bits are captured separately in `kind`. + const PERMISSION_BITS: u32 = 0o7777; + + map.insert( + rel, + EntryInfo { + kind, + mode: meta.permissions().mode() & PERMISSION_BITS, + uid: meta.uid(), + gid: meta.gid(), + size: meta.len(), + symlink_target, + sha256, + }, + ); + + if ft.is_dir() { + walk_dir(root, &abs, map)?; + } + } + Ok(()) +} + +fn hash_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +// ─── Comparison ─────────────────────────────────────────────────────────────── + +fn compare_entries(rel: &Path, sq: &EntryInfo, rf: &EntryInfo) -> Vec { + let mut diffs = Vec::new(); + macro_rules! diff { + ($msg:expr) => { + diffs.push(FileDiff { + path: rel.to_path_buf(), + detail: $msg, + }) + }; + } + + if sq.kind != rf.kind { + diff!(format!( + "type mismatch: squashfs={:?} ref={:?}", + sq.kind, rf.kind + )); + return diffs; + } + if sq.mode != rf.mode { + diff!(format!( + "mode: squashfs={:04o} ref={:04o}", + sq.mode, rf.mode + )); + } + if sq.uid != rf.uid { + diff!(format!("uid: squashfs={} ref={}", sq.uid, rf.uid)); + } + if sq.gid != rf.gid { + diff!(format!("gid: squashfs={} ref={}", sq.gid, rf.gid)); + } + if sq.symlink_target != rf.symlink_target { + diff!(format!( + "symlink target: squashfs={:?} ref={:?}", + sq.symlink_target, rf.symlink_target + )); + } + if sq.kind == EntryKind::File { + if sq.size != rf.size { + diff!(format!("size: squashfs={} ref={}", sq.size, rf.size)); + } + if sq.sha256 != rf.sha256 { + diff!(format!("sha256 mismatch")); + } + } + diffs +} diff --git a/oci_squashfs/tests/helpers/mod.rs b/oci_squashfs/tests/helpers/mod.rs new file mode 100644 index 0000000..8d9ba26 --- /dev/null +++ b/oci_squashfs/tests/helpers/mod.rs @@ -0,0 +1,212 @@ +//! Shared test helpers for integration and regression tests. +#![allow(dead_code)] + +use oci_squashfs::canonical::CanonicalTarHeader; +use oci_squashfs::image::LayerBlob; +use std::io::{Cursor, Write}; +use tar::{Archive, Builder, EntryType, Header}; + +// ─── LayerBuilder ──────────────────────────────────────────────────────────── + +pub struct LayerBuilder { + inner: Builder>, +} + +impl LayerBuilder { + pub fn new() -> Self { + Self { + inner: Builder::new(Vec::new()), + } + } + + pub fn add_file(mut self, path: &str, data: &[u8], mode: u32) -> Self { + if path.len() > 99 { + self.inner + .append_pax_extensions([("path", path.as_bytes())]) + .unwrap(); + } + let mut hdr = Header::new_ustar(); + hdr.set_path(truncate(path, 99)).unwrap(); + hdr.set_size(data.len() as u64); + hdr.set_mode(mode); + hdr.set_mtime(0); + hdr.set_uid(0); + hdr.set_gid(0); + hdr.set_cksum(); + self.inner.append(&hdr, Cursor::new(data)).unwrap(); + self + } + + pub fn add_dir(mut self, path: &str) -> Self { + if path.len() > 99 { + self.inner + .append_pax_extensions([("path", path.as_bytes())]) + .unwrap(); + } + let mut hdr = Header::new_ustar(); + hdr.set_path(truncate(path, 99)).unwrap(); + hdr.set_entry_type(EntryType::Directory); + hdr.set_size(0); + hdr.set_mode(0o755); + hdr.set_mtime(0); + hdr.set_uid(0); + hdr.set_gid(0); + hdr.set_cksum(); + self.inner.append(&hdr, Cursor::new(b"" as &[u8])).unwrap(); + self + } + + pub fn add_symlink(mut self, path: &str, target: &str) -> Self { + let mut pax: Vec<(&str, &[u8])> = Vec::new(); + if path.len() > 99 { + pax.push(("path", path.as_bytes())); + } + if target.len() > 99 { + pax.push(("linkpath", target.as_bytes())); + } + if !pax.is_empty() { + self.inner.append_pax_extensions(pax).unwrap(); + } + let mut hdr = Header::new_ustar(); + hdr.set_path(truncate(path, 99)).unwrap(); + hdr.set_entry_type(EntryType::Symlink); + hdr.set_link_name(truncate(target, 99)).ok(); + hdr.set_size(0); + hdr.set_mode(0o777); + hdr.set_mtime(0); + hdr.set_uid(0); + hdr.set_gid(0); + hdr.set_cksum(); + self.inner.append(&hdr, Cursor::new(b"" as &[u8])).unwrap(); + self + } + + pub fn add_hardlink(mut self, path: &str, target: &str) -> Self { + let mut pax: Vec<(&str, &[u8])> = Vec::new(); + if path.len() > 99 { + pax.push(("path", path.as_bytes())); + } + if target.len() > 99 { + pax.push(("linkpath", target.as_bytes())); + } + if !pax.is_empty() { + self.inner.append_pax_extensions(pax).unwrap(); + } + let mut hdr = Header::new_ustar(); + hdr.set_path(truncate(path, 99)).unwrap(); + hdr.set_entry_type(EntryType::Link); + hdr.set_link_name(truncate(target, 99)).ok(); + hdr.set_size(0); + hdr.set_mode(0o644); + hdr.set_mtime(0); + hdr.set_uid(0); + hdr.set_gid(0); + hdr.set_cksum(); + self.inner.append(&hdr, Cursor::new(b"" as &[u8])).unwrap(); + self + } + + pub fn add_whiteout(self, dir: &str, name: &str) -> Self { + let path = if dir.is_empty() { + format!(".wh.{name}") + } else { + format!("{dir}/.wh.{name}") + }; + self.add_file(&path, b"", 0o644) + } + + pub fn add_opaque_whiteout(self, dir: &str) -> Self { + self.add_file(&format!("{dir}/.wh..wh..opq"), b"", 0o644) + } + + pub fn finish(mut self) -> Vec { + self.inner.finish().unwrap(); + self.inner.into_inner().unwrap() + } +} + +/// Truncate a string to at most `max_chars` characters. +fn truncate(s: &str, max_chars: usize) -> &str { + match s.char_indices().nth(max_chars) { + Some((idx, _)) => &s[..idx], + None => s, + } +} + +// ─── Blob / merge helpers ───────────────────────────────────────────────────── + +pub fn blob(bytes: Vec, index: usize) -> LayerBlob { + use tempfile::NamedTempFile; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(&bytes).unwrap(); + let (_, path) = f.keep().unwrap(); + LayerBlob { + path, + media_type: "application/vnd.oci.image.layer.v1.tar".into(), + index, + } +} + +pub fn merge(layers: Vec) -> Vec { + let mut out = Vec::new(); + oci_squashfs::overlay::merge_layers_into(layers, &mut out).unwrap(); + out +} + +// ─── Tar inspection helpers ─────────────────────────────────────────────────── + +pub fn paths_in_tar(tar_bytes: &[u8]) -> Vec { + let mut archive = Archive::new(Cursor::new(tar_bytes)); + archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + !matches!( + e.header().entry_type(), + EntryType::XHeader | EntryType::XGlobalHeader + ) + }) + .map(|e| e.path().unwrap().to_string_lossy().into_owned()) + .collect() +} + +/// Read the symlink target for `link_path`, preferring PAX `linkpath`. +pub fn symlink_target_in_tar(tar_bytes: &[u8], link_path: &str) -> Option { + let mut archive = Archive::new(Cursor::new(tar_bytes)); + for mut entry in archive.entries().unwrap().flatten() { + let canonical = CanonicalTarHeader::from_entry(&mut entry).ok()?; + if canonical.entry_type() != EntryType::Symlink { + continue; + } + if canonical.path().unwrap().to_string_lossy() != link_path { + continue; + } + return canonical + .link_name() + .ok() + .flatten() + .map(|p| p.to_string_lossy().into_owned()); + } + None +} + +/// Read the hard link target for `link_path`, preferring PAX `linkpath`. +pub fn hardlink_target_in_tar(tar_bytes: &[u8], link_path: &str) -> Option { + let mut archive = Archive::new(Cursor::new(tar_bytes)); + for mut entry in archive.entries().unwrap().flatten() { + let canonical = CanonicalTarHeader::from_entry(&mut entry).ok()?; + if canonical.entry_type() != EntryType::Link { + continue; + } + if canonical.path().unwrap().to_string_lossy() != link_path { + continue; + } + return canonical + .link_name() + .ok() + .flatten() + .map(|p| p.to_string_lossy().into_owned()); + } + None +} diff --git a/oci_squashfs/tests/integration.rs b/oci_squashfs/tests/integration.rs new file mode 100644 index 0000000..24f6bbb --- /dev/null +++ b/oci_squashfs/tests/integration.rs @@ -0,0 +1,110 @@ +//! Synthetic integration tests for the OCI → squashfs merge pipeline. +//! These tests exercise `overlay::merge_layers_into` directly on in-memory +//! blobs and inspect the resulting merged tar without invoking mksquashfs. + +#[path = "helpers/mod.rs"] +mod helpers; +use helpers::{ + blob, hardlink_target_in_tar, merge, paths_in_tar, symlink_target_in_tar, LayerBuilder, +}; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[test] +fn test_long_symlink_pax_preserved() { + let long_target: String = "a".repeat(200); + let layer0 = LayerBuilder::new() + .add_symlink("link_to_long", &long_target) + .finish(); + let merged = merge(vec![blob(layer0, 0)]); + assert_eq!( + symlink_target_in_tar(&merged, "link_to_long").as_deref(), + Some(long_target.as_str()), + "long symlink target must round-trip via PAX" + ); +} + +#[test] +fn test_long_hardlink_pax_preserved() { + let long_target: String = "a".repeat(200); + let layer0 = LayerBuilder::new() + .add_file(&long_target, b"hello", 0o644) + .add_hardlink("link_to_long", &long_target) + .finish(); + let merged = merge(vec![blob(layer0, 0)]); + assert_eq!( + hardlink_target_in_tar(&merged, "link_to_long").as_deref(), + Some(long_target.as_str()), + "long hardlink target must round-trip via PAX" + ); +} + +#[test] +fn test_hardlink_across_layers() { + let layer0 = LayerBuilder::new() + .add_file("original.txt", b"hello", 0o644) + .finish(); + let layer1 = LayerBuilder::new() + .add_hardlink("link.txt", "original.txt") + .finish(); + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + let paths = paths_in_tar(&merged); + assert!(paths.contains(&"original.txt".to_string())); + assert!(paths.contains(&"link.txt".to_string())); +} + +#[test] +fn test_simple_whiteout() { + let layer0 = LayerBuilder::new() + .add_file("secret.txt", b"private", 0o644) + .finish(); + let layer1 = LayerBuilder::new().add_whiteout("", "secret.txt").finish(); + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + assert!(!paths_in_tar(&merged).iter().any(|p| p == "secret.txt")); +} + +#[test] +fn test_opaque_whiteout() { + let layer0 = LayerBuilder::new() + .add_dir("mydir") + .add_file("mydir/old.txt", b"old", 0o644) + .finish(); + let layer1 = LayerBuilder::new() + .add_dir("mydir") + .add_opaque_whiteout("mydir") + .finish(); + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + assert!(!paths_in_tar(&merged).iter().any(|p| p == "mydir/old.txt")); +} + +#[test] +fn test_opaque_whiteout_with_repopulation() { + let layer0 = LayerBuilder::new() + .add_dir("dir") + .add_file("dir/old.txt", b"old", 0o644) + .finish(); + let layer1 = LayerBuilder::new() + .add_dir("dir") + .add_opaque_whiteout("dir") + .add_file("dir/new.txt", b"new", 0o644) + .finish(); + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + let paths = paths_in_tar(&merged); + assert!(paths.iter().any(|p| p == "dir/new.txt")); + assert!(!paths.iter().any(|p| p == "dir/old.txt")); +} + +#[test] +fn test_hardlink_to_whiteout_target_dropped() { + let layer0 = LayerBuilder::new() + .add_file("gone.txt", b"data", 0o644) + .finish(); + let layer1 = LayerBuilder::new() + .add_whiteout("", "gone.txt") + .add_hardlink("link.txt", "gone.txt") + .finish(); + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + let paths = paths_in_tar(&merged); + assert!(!paths.iter().any(|p| p == "gone.txt")); + assert!(!paths.iter().any(|p| p == "link.txt")); +} diff --git a/oci_squashfs/tests/regression.rs b/oci_squashfs/tests/regression.rs new file mode 100644 index 0000000..1aabc7d --- /dev/null +++ b/oci_squashfs/tests/regression.rs @@ -0,0 +1,165 @@ +//! Regression tests, one per production bug found during verify runs. +//! Each test is named after the bug it guards against and has a comment +//! explaining the root cause and how it was discovered. + +use std::io::Cursor; +use tar::{Builder, EntryType, Header}; + +#[path = "helpers/mod.rs"] +mod helpers; +use helpers::{blob, merge, paths_in_tar, LayerBuilder}; +// regression.rs also uses: +use helpers::hardlink_target_in_tar; + +// ─── Regression tests ──────────────────────────────────────────────────────── + +/// Bug: whiteout suppression condition was inverted (`current_layer > layer_index` +/// instead of `current_layer < layer_index`), causing whiteouts to suppress +/// entries from *newer* layers rather than older ones. Files removed via +/// `.wh.` in a later layer were still appearing in the output. +/// +/// Discovered via: `usr/share/vulkan/icd.d/lvp_icd.json` appearing in squashfs +/// output despite being removed by a dpkg-divert whiteout in a later layer. +#[test] +fn regress_whiteout_suppression_direction() { + let layer0 = LayerBuilder::new() + .add_file("usr/share/vulkan/icd.d/lvp_icd.json", b"data", 0o644) + .finish(); + // Layer 1 whiteouts the file from layer 0. + let layer1 = LayerBuilder::new() + .add_whiteout("usr/share/vulkan/icd.d", "lvp_icd.json") + .finish(); + + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + let paths = paths_in_tar(&merged); + assert!( + !paths + .iter() + .any(|p| p == "usr/share/vulkan/icd.d/lvp_icd.json"), + "whited-out file must not appear: got paths {paths:?}" + ); +} + +/// Bug: hard link targets longer than 100 bytes were being silently truncated +/// because we read them via `entry.header().link_name()`, which only reads the +/// raw 100-byte USTAR `linkname` field. The PAX `linkpath` extension carrying +/// the full target was ignored. The truncated target path was then not found in +/// the emitted-path tracker, so the link was silently dropped. +/// +/// Discovered via: PostgreSQL/hammerdb timezone hard-link aliases all missing +/// from squashfs output (e.g. `timezone/Jamaica -> .../America/...` dropped +/// because the full target path exceeded 100 bytes and was truncated to +/// `America` or similar). +#[test] +fn regress_long_hardlink_target_truncated() { + // Construct a target path that exceeds 100 bytes. + let long_dir = "a".repeat(95); + let target = format!("{long_dir}/canonical_file"); + assert!( + target.len() > 100, + "test setup: target must exceed 100 bytes" + ); + + let layer0 = LayerBuilder::new() + .add_dir(&long_dir) + .add_file(&target, b"content", 0o644) + .add_hardlink("alias_file", &target) + .finish(); + + let merged = merge(vec![blob(layer0, 0)]); + let paths = paths_in_tar(&merged); + + assert!( + paths.iter().any(|p| p == &target), + "canonical target must be present" + ); + assert!( + paths.iter().any(|p| p == "alias_file"), + "hard link alias must be present; was it silently dropped due to truncated target?" + ); + + let resolved = hardlink_target_in_tar(&merged, "alias_file"); + assert_eq!( + resolved.as_deref(), + Some(target.as_str()), + "hard link target must be the full untruncated path" + ); +} + +/// Variant of the above: hard link and target in different layers, with a long +/// target path. Guards against the combination of cross-layer deferral and PAX +/// target resolution both being required simultaneously. +#[test] +fn regress_long_hardlink_target_cross_layer() { + let long_dir = "b".repeat(60); + let target = format!("{long_dir}/canonical_file"); + + let layer0 = LayerBuilder::new() + .add_dir(&long_dir) + .add_file(&target, b"content", 0o644) + .finish(); + let layer1 = LayerBuilder::new() + .add_hardlink("alias_file", &target) + .finish(); + + let merged = merge(vec![blob(layer0, 0), blob(layer1, 1)]); + let paths = paths_in_tar(&merged); + + assert!( + paths.iter().any(|p| p == &target), + "canonical target must be present" + ); + assert!( + paths.iter().any(|p| p == "alias_file"), + "cross-layer hard link with long target must not be silently dropped" + ); +} + +/// Bug: normalize_path only stripped leading `./` but not leading `/`. Hard +/// link targets stored as absolute paths in the tar (e.g. `/usr/share/foo`) +/// would not match the normalized emitted path (`usr/share/foo`), causing the +/// link to be silently dropped. +#[test] +fn regress_absolute_hardlink_target_normalized() { + // Manually construct a layer with an absolute-path hard link target, + // which some tar producers emit. + let mut builder = Builder::new(Vec::new()); + + let mut file_hdr = Header::new_ustar(); + file_hdr.set_path("usr/share/foo").unwrap(); + file_hdr.set_size(4); + file_hdr.set_mode(0o644); + file_hdr.set_mtime(0); + file_hdr.set_uid(0); + file_hdr.set_gid(0); + file_hdr.set_cksum(); + builder.append(&file_hdr, Cursor::new(b"data")).unwrap(); + + // Use a PAX linkpath with a leading slash — the absolute form. + builder + .append_pax_extensions([("linkpath", b"/usr/share/foo" as &[u8])]) + .unwrap(); + let mut link_hdr = Header::new_ustar(); + link_hdr.set_path("usr/share/bar").unwrap(); + link_hdr.set_entry_type(EntryType::Link); + link_hdr.set_link_name("usr/share/foo").ok(); // truncated USTAR field (no leading slash here) + link_hdr.set_size(0); + link_hdr.set_mode(0o644); + link_hdr.set_mtime(0); + link_hdr.set_uid(0); + link_hdr.set_gid(0); + link_hdr.set_cksum(); + builder + .append(&link_hdr, Cursor::new(b"" as &[u8])) + .unwrap(); + + builder.finish().unwrap(); + let layer0 = builder.into_inner().unwrap(); + + let merged = merge(vec![blob(layer0, 0)]); + let paths = paths_in_tar(&merged); + assert!( + paths.iter().any(|p| p == "usr/share/bar"), + "hard link with absolute PAX linkpath must not be dropped after normalization" + ); +} diff --git a/oci_squashfs_cli/Cargo.toml b/oci_squashfs_cli/Cargo.toml new file mode 100644 index 0000000..8e81bdf --- /dev/null +++ b/oci_squashfs_cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "oci_squashfs_cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "oci2squashfs" +path = "src/main.rs" + +[dependencies] +oci_squashfs = { path = "../oci_squashfs" } +tokio = { version = "1", features = ["full"] } +clap = { version = "4", features = ["derive"] } +anyhow = "1" diff --git a/oci_squashfs_cli/src/main.rs b/oci_squashfs_cli/src/main.rs new file mode 100644 index 0000000..db6f088 --- /dev/null +++ b/oci_squashfs_cli/src/main.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "oci2squashfs", about = "Convert an OCI image to squashfs")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Convert an OCI image directory to a squashfs image. + Convert { + /// Path to the extracted OCI image directory. + #[arg(short, long)] + image: PathBuf, + /// Output squashfs file path. + #[arg(short, long)] + output: PathBuf, + }, + /// Verify a squashfs image against a reference directory. + Verify { + /// Path to the .squashfs file. + #[arg(short, long)] + squashfs: PathBuf, + /// Path to the reference directory (e.g. containerd-unpacked rootfs). + #[arg(short, long)] + reference: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Convert { image, output } => { + println!("Converting {} → {}", image.display(), output.display()); + oci_squashfs::convert(&image, &output).await?; + println!("Done: {}", output.display()); + } + Commands::Verify { + squashfs, + reference, + } => { + let report = tokio::task::spawn_blocking(move || { + oci_squashfs::verify::verify(&squashfs, &reference) + }) + .await??; + + if report.only_in_squashfs.is_empty() + && report.only_in_reference.is_empty() + && report.differences.is_empty() + { + println!("✓ No differences found."); + return Ok(()); + } + + for p in &report.only_in_squashfs { + println!("+ squashfs only: {}", p.display()); + } + for p in &report.only_in_reference { + println!("- reference only: {}", p.display()); + } + for d in &report.differences { + println!("~ {}: {}", d.path.display(), d.detail); + } + std::process::exit(1); + } + } + Ok(()) +}