diff --git a/Cargo.lock b/Cargo.lock index 8a78d1c..2518e98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -88,12 +88,46 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -135,6 +169,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -153,12 +193,30 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.60" @@ -218,12 +276,47 @@ dependencies = [ "hashbrown 0.16.1", ] +[[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.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -291,6 +384,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" @@ -319,7 +423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -348,6 +452,12 @@ dependencies = [ "libc", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -366,6 +476,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[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" version = "0.3.32" @@ -470,6 +595,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -477,9 +615,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -513,6 +653,7 @@ dependencies = [ "git-set-attr", "gix", "gix-glob", + "gix-quote", "proptest", "rstest", "tempfile", @@ -1256,7 +1397,7 @@ dependencies = [ "bitflags", "gix-path", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1337,13 +1478,16 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd6a5c676b92d4ead5f5a2b2935024415dec69edc997b6090ca9cac010a3018" dependencies = [ + "base64", "bstr", "gix-command", + "gix-credentials", "gix-features", "gix-packetline", "gix-quote", "gix-sec", "gix-url", + "reqwest", "thiserror", ] @@ -1456,6 +1600,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1507,18 +1670,219 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "human_format" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1541,6 +1905,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1565,7 +1935,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1594,6 +1964,76 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kstring" version = "2.0.2" @@ -1621,6 +2061,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1636,6 +2082,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "maybe-async" version = "0.2.11" @@ -1663,16 +2115,33 @@ dependencies = [ ] [[package]] -name = "nonempty" -version = "0.12.0" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "num-traits" -version = "0.2.19" +name = "mio" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -1689,6 +2158,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1739,6 +2214,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1812,6 +2296,62 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1915,6 +2455,60 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "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 = "roff" version = "0.2.2" @@ -1951,6 +2545,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1970,7 +2570,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2000,12 +2675,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "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", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2081,6 +2788,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.4.4" @@ -2101,6 +2814,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2113,6 +2842,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2131,6 +2870,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2142,6 +2887,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2152,7 +2917,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2175,6 +2940,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -2190,6 +2965,43 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2220,6 +3032,76 @@ dependencies = [ "winnow", ] +[[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.0" @@ -2268,6 +3150,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "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" @@ -2299,6 +3205,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -2317,6 +3238,61 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2351,6 +3327,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2373,7 +3378,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2388,6 +3393,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2397,6 +3420,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.3" @@ -2494,6 +3646,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2514,6 +3695,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 8c2bfd2..31418a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,9 @@ missing_docs = "warn" [workspace.dependencies] clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" -gix = { version = "0.83", features = ["merge", "tree-editor", "blocking-network-client", "worktree-mutation"] } +gix = { version = "0.83", features = ["attributes", "merge", "tree-editor", "blocking-network-client", "worktree-mutation", "blocking-http-transport-reqwest-rust-tls"] } gix-glob = "0.26.0" +gix-quote = "0.7" tempfile = "3" thiserror = "2" diff --git a/crates/git-vendor/Cargo.toml b/crates/git-vendor/Cargo.toml index 959df98..bbd7c02 100644 --- a/crates/git-vendor/Cargo.toml +++ b/crates/git-vendor/Cargo.toml @@ -28,6 +28,7 @@ git-set-attr = { version = "0.1.2", path = "../git-set-attr" } tempfile.workspace = true thiserror.workspace = true gix-glob.workspace = true +gix-quote.workspace = true [dev-dependencies] proptest = "1.5.0" diff --git a/crates/git-vendor/src/attr_tests.rs b/crates/git-vendor/src/attr_tests.rs new file mode 100644 index 0000000..dd7270e --- /dev/null +++ b/crates/git-vendor/src/attr_tests.rs @@ -0,0 +1,44 @@ +use rstest::rstest; + +use super::{check_attr_pattern, split_attr_line}; + +#[rstest] +#[case(b"vendor/a.txt")] +#[case(b"src/lib.rs")] +#[case(b"deep/nested/path.txt")] +fn check_plain_path_ok(#[case] path: &[u8]) { + assert!(check_attr_pattern(path).is_ok()); +} + +#[rstest] +#[case(b"vendor/a b.txt")] +#[case(b"#readme")] +#[case(b"say \"hi\"")] +#[case(b"a\tb")] +#[case(b"a\x01b")] +#[case(b"a\x7fb")] +fn check_path_with_special_chars_errors(#[case] path: &[u8]) { + assert!(check_attr_pattern(path).is_err()); +} + +#[rstest] +#[case(b"vendor/a.txt vendor=mylib", b"vendor/a.txt", b"vendor=mylib")] +#[case(b"vendor/a.txt\tvendor=mylib", b"vendor/a.txt", b"vendor=mylib")] +#[case(b"\"vendor/a b.txt\" vendor=mylib", b"vendor/a b.txt", b"vendor=mylib")] +#[case(b"\"say \\\"hi\\\"\" vendor=mylib", b"say \"hi\"", b"vendor=mylib")] +fn split_line(#[case] line: &[u8], #[case] pattern: &[u8], #[case] attr: &[u8]) { + let (p, a) = split_attr_line(line).unwrap(); + assert_eq!(p.as_ref(), pattern); + assert_eq!(a, attr); +} + +#[rstest] +#[case(b"vendor/a.txt")] +#[case(b"")] +#[case(b"# comment line")] +#[case(b"# vendor=mylib")] +#[case(b" ")] +#[case(b" vendor/a.txt attr")] +fn split_no_attr_returns_none(#[case] line: &[u8]) { + assert!(split_attr_line(line).is_none()); +} diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs new file mode 100644 index 0000000..8a3bd54 --- /dev/null +++ b/crates/git-vendor/src/cli.rs @@ -0,0 +1,114 @@ +//! Command-line interface shape for `git-vendor`. + +use clap::{Parser, Subcommand}; + +/// Manage vendored external repository content. +#[derive(Parser)] +#[command(name = "git-vendor", bin_name = "git vendor")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// Add a new vendor dependency and integrate it into the current branch. + /// + /// Fetches the upstream ref and three-way merges it into the working + /// tree, staging the result for review. Run `git commit` to complete, + /// mirroring `git submodule add` (which also never commits on your + /// behalf). + Add { + /// Remote URL of the upstream repository. + url: String, + + /// Unique name for this vendor (used in `.gitvendors` and `.gitattributes`). + /// Defaults to the last path component of the URL, stripped of `.git`. + name: Option, + + /// Branch, tag, or SHA to track on the upstream (defaults to `HEAD`). + #[arg(long = "ref", value_name = "REF")] + ref_name: Option, + + /// Destination directory for vendored files (defaults to `vendor//`). + /// Ignored when `--pattern` is also given. + #[arg(long, value_name = "DIR")] + prefix: Option, + + /// File pattern used to filter the upstream vendor content with optional + /// remapping into the working copy, e.g. `src/**:vendor/lib/`. May be + /// repeated to select multiple paths. + #[arg(long = "pattern", short = 'p', value_name = "GLOB[:DEST]")] + patterns: Vec, + + /// Record upstream history as a parentless squash commit instead of a + /// full merge. + #[arg(long)] + squash: bool, + + /// Show what would be fetched and merged without making any changes. + #[arg(long)] + dry_run: bool, + + /// Commit message (defaults to `vendor: add `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + }, + + /// Fetch and integrate upstream updates for one or all vendors. + /// + /// Equivalent to `git subtree pull` or `git submodule update --remote`. + /// Stages the result for review; run `git commit` to complete, mirroring + /// `git submodule` (which never commits on your behalf). + Update { + /// Vendor name to update; updates all configured vendors if omitted. + name: Option, + + /// Commit message (defaults to `vendor: update `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + + /// Allow integrating an upstream that was force-pushed (rewound + /// history). Without this flag, a force-push is reported as an error. + #[arg(long)] + force: bool, + + /// Rebuild from the recorded upstream base instead of fetching. Use + /// after editing a vendor's `pattern` entries to move or refilter its + /// files. Local modifications to vendored files would be discarded, + /// so this refuses to proceed on a modified vendor unless `--force` + /// is also given. + #[arg(long)] + no_fetch: bool, + + /// Show what would be fetched and merged without making any changes. + #[arg(long)] + dry_run: bool, + }, + + /// Show synchronization status for one or all vendors. + Status { + /// Vendor name to check; checks all configured vendors if omitted. + name: Option, + + /// Fetch from upstream before reporting status. + #[arg(long, short = 'f')] + fetch: bool, + }, + + /// Remove a vendor dependency and its files from the working tree. + #[command(visible_alias = "rm")] + Remove { + /// Vendor name to remove. + name: String, + + /// Remove the config entry and `.gitattributes` tracking but leave the + /// vendored files in the working tree. + #[arg(long)] + keep_files: bool, + }, + + /// List all configured vendor dependencies. + #[command(visible_alias = "ls")] + List, +} diff --git a/crates/git-vendor/src/error.rs b/crates/git-vendor/src/error.rs index 1fba694..6c8a3a6 100644 --- a/crates/git-vendor/src/error.rs +++ b/crates/git-vendor/src/error.rs @@ -15,6 +15,8 @@ pub enum Error { Fetch(String), #[error("merge conflict: {0}")] Conflict(String), + #[error("path requires quoting in .gitattributes: {0}")] + InvalidPath(String), #[error(transparent)] Gix(Box), } diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index beb9648..0e40e8b 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -1,229 +1,727 @@ -//! Working-copy projection layer: the side-effecting half of vendoring. -//! -//! Where [`VendorRepository`](crate::VendorRepository) is a pure -//! object-database algebra, every method here writes the index and working -//! tree (including tracked files like `.gitattributes`). It is the sole owner -//! of the one working copy and its ambient `HEAD`/index state, kept distinct -//! from the pure object-database operations. - -use gix::bstr::{BStr, ByteSlice as _}; - -use crate::{Error, VendorEntry, VendorMerge, VendorRepository, VendorWorktree}; - -impl VendorWorktree for gix::Repository { - fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error> { - // SAFETY - // This is the trust boundary where upstream content (carried verbatim - // through `upstream_tree`, including symlink and gitlink modes, - // mirroring git-subtree/submodule) reaches the working copy. Like - // core git's `verify_path`/checkout, projection MUST refuse to write - // through a symlinked leading path and reject `..`/absolute - // components — use gix-worktree's checked checkout, never naive - // `std::fs` writes. See the `upstream_tree` adversarial review (#5). - // NOTE - // Path-traversal safety (e.g. `../` components, symlinked leading - // paths) is delegated to gix and is not covered by automated tests. - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - - let head_id = self.head_commit().ok().map(|c| c.id().detach()); - - let old_paths: std::collections::BTreeSet = head_id - .and_then(|id| crate::resolve_vendor_paths(self, entry, id).ok()) - .into_iter() - .flatten() - .collect(); - - let mut vendor_index = self.index_from_tree(&tree)?; - - let new_paths: std::collections::BTreeSet = vendor_index - .entries() - .iter() - .map(|e| e.path(&vendor_index).to_owned()) - .collect(); - - let opts = self - .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping) - .map_err(|e| Error::Gix(Box::new(e)))?; - let progress = gix::progress::Discard; - gix::worktree::state::checkout( - &mut vendor_index, - workdir, - self.objects.clone().into_arc().map_err(Error::Io)?, - &progress, - &progress, - &gix::interrupt::IS_INTERRUPTED, - gix::worktree::state::checkout::Options { - overwrite_existing: true, - ..opts - }, - ) - .map_err(|e| Error::Gix(Box::new(e)))?; - - for removed in old_paths.difference(&new_paths) { - let abs = workdir.join(gix::path::from_bstr(removed).as_ref()); - if abs.symlink_metadata().is_ok() { - std::fs::remove_file(&abs)?; - } - } +use std::io::Write as _; +use std::path::{Path, PathBuf}; - // Overlay the vendor tree onto the full HEAD tree and rebuild the index - // from the result. An unborn HEAD has no base commit, so the vendor tree - // is itself the whole tree. - let full_tree = match head_id { - Some(id) => self.vendor_overlay(entry, id, tree)?, - None => tree, - }; - let mut main_index = self.index_from_tree(&full_tree)?; - main_index.set_path(self.git_dir().join("index")); - - // `index_from_tree` zeroes stat data; carry over the stats checkout just - // populated on the vendor entries so `git status` need not re-hash them. - let vendor_stats: std::collections::HashMap = - vendor_index - .entries() - .iter() - .map(|e| (e.path(&vendor_index).to_owned(), e.stat)) - .collect(); - for (e, path) in main_index.entries_mut_with_paths() { - if let Some(stat) = vendor_stats.get(path) { - e.stat = *stat; - } +use git_vendor::{ + PatternMapping, VendorConfig, VendorEntry, VendorMode, VendorName, VendorRepository, + VendorStatus, VendorWorktree, +}; + +use crate::cli; + +type Result> = std::result::Result; + +pub struct Io { + pub out: Box, + pub err: Box, +} + +impl Io { + pub fn stdio() -> Self { + Io { + out: Box::new(std::io::stdout()), + err: Box::new(std::io::stderr()), } + } +} - main_index - .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; +#[derive(Debug)] +pub struct ConflictExit; +impl std::fmt::Display for ConflictExit { + fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Ok(()) } +} + +impl std::error::Error for ConflictExit {} + +pub struct Executor(pub gix::Repository); + +impl Executor { + pub fn discover() -> Result { + Ok(Self(gix::discover(".")?)) + } - fn checkout_vendor_conflicted( + pub fn run(&self, cli: cli::Cli, io: &mut Io) -> Result<()> { + match cli.command { + cli::Command::Add { + url, + name, + ref_name, + prefix, + patterns, + squash, + dry_run, + message, + } => self.add( + name, url, ref_name, prefix, patterns, squash, dry_run, message, io, + ), + cli::Command::Update { + name, + message, + force, + no_fetch, + dry_run, + } => self.update(name, message, force, no_fetch, dry_run, io), + cli::Command::Status { name, fetch } => self.status(name, fetch, io), + cli::Command::Remove { name, keep_files } => self.remove(name, keep_files, io), + cli::Command::List => self.list(io), + } + } + + #[allow(clippy::too_many_arguments)] + fn add( &self, - entry: &VendorEntry, - merge: &VendorMerge, - ) -> Result<(), Error> { - use gix::bstr::ByteSlice as _; - - // Write the result tree (with conflict markers) to the working copy. - self.checkout_vendor(entry, merge.result_tree)?; - - // Reopen the index we just wrote so we can splice in unmerged stages. - let mut main_index = self.open_index().map_err(|e| Error::Gix(Box::new(e)))?; - main_index.set_path(self.git_dir().join("index")); - - for conflict in &merge.conflicts { - let path_bytes = gix::bstr::BString::from(conflict.path.as_bytes()); - let path_bstr = path_bytes.as_bstr(); - - // Remove the stage-0 entry for this path. - main_index.remove_entries(|_, p, _| p == path_bstr); - - // Insert stage 1/2/3 entries for each present stage. - for (stage_idx, stage_variant) in [ - (0usize, gix::index::entry::Stage::Base), - (1usize, gix::index::entry::Stage::Ours), - (2usize, gix::index::entry::Stage::Theirs), - ] { - if let Some((tree_mode, oid)) = conflict.stages[stage_idx] { - let mode = gix::index::entry::Mode::from(tree_mode); - let flags = gix::index::entry::Flags::from_stage(stage_variant); - main_index.dangerously_push_entry( - gix::index::entry::Stat::default(), - oid, - flags, - mode, - path_bstr, - ); + name: Option, + url: String, + ref_name: Option, + prefix: Option, + patterns: Vec, + squash: bool, + dry_run: bool, + message: Option, + io: &mut Io, + ) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let name = match name { + Some(n) => n, + None => name_from_url(&url).ok_or_else(|| { + format!("cannot derive a vendor name from URL {url:?}; pass a name explicitly") + })?, + }; + let vendor_name = VendorName::new(&name)?; + if config.get(vendor_name.as_str())?.is_some() { + return Err(format!( + "vendor {name:?} already exists; use `git vendor update {name}` or remove it first" + ) + .into()); + } + let mode = if squash { + VendorMode::Squash + } else { + VendorMode::default() + }; + let patterns: Vec = if patterns.is_empty() { + let dest = prefix.map_or_else( + || format!("vendor/{name}/"), + |p| { + if p.ends_with('/') { p } else { format!("{p}/") } + }, + ); + vec![format!("**:{dest}")] + } else { + patterns + }; + let mut entry = VendorEntry { + name: vendor_name, + url, + ref_name, + base: None, + patterns: patterns.iter().map(|p| PatternMapping::parse(p)).collect(), + mode, + }; + + // Fetch before touching config — a failed fetch leaves no side effects. + writeln!(io.err, "Fetching {name}…")?; + let upstream = repo.fetch_vendor(&entry)?; + + if dry_run { + writeln!(io.err, "Would add vendor {name} at {upstream}.")?; + return Ok(()); + } + + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: add {name}")); + + match head_oid { + Some(ours) => { + let merge = repo.merge_vendor(&entry, ours, upstream)?; + let conflicted = self.apply_merge( + &mut config, + &cfg_path, + &mut entry, + &merge, + &[], + &msg, + false, + io, + )?; + if conflicted { + return Err(ConflictExit.into()); } + writeln!(io.err, "Staged; run `git commit` to complete.")?; + } + None => { + let tree = repo.upstream_tree(&entry, upstream)?; + let new_paths = tree_paths(repo, tree)?; + let path_refs: Vec<&gix::bstr::BStr> = + new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + let _full_tree = repo.checkout_vendor(&entry, tree)?; + repo.track_vendor(&entry, &path_refs)?; + + entry.base = Some(upstream); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + writeln!(io.err, "Staged; run `git commit` to complete.")?; } } - main_index.sort_entries(); - main_index - .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; - Ok(()) } - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - let gitattributes = workdir.join(".gitattributes"); + fn update( + &self, + name: Option, + message: Option, + force: bool, + no_fetch: bool, + dry_run: bool, + io: &mut Io, + ) -> Result<()> { + if no_fetch { + return self.update_no_fetch(name, force, io); + } - let existing: Vec = if gitattributes.exists() { - std::fs::read(&gitattributes)? - } else { - Vec::new() - }; + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; - let attr_value = format!("vendor={}", entry.name.as_str()); - let attr_bytes = attr_value.as_bytes(); - - let already_tracked: std::collections::HashSet<&[u8]> = existing - .lines() - .filter_map(|line| { - let i = line.iter().position(|&b| b == b' ')?; - let attr = line[i + 1..].trim(); - if attr == attr_bytes { - Some(&line[..i]) - } else { - None + let entries = resolve_entries(&config, name.as_deref())?; + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } + + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD: {e}"))?; + + for mut entry in entries { + let n = entry.name.as_str().to_owned(); + writeln!(io.err, "Fetching {n}…")?; + let upstream = repo.fetch_vendor(&entry)?; + + let status = repo.vendor_status(&entry)?; + match status { + VendorStatus::UpToDate => { + writeln!(io.err, "{n}: already up to date")?; + continue; + } + VendorStatus::ForcePushed { .. } if !force => { + writeln!( + io.err, + "{n}: upstream was force-pushed; re-run with --force to accept" + )?; + continue; } - }) - .collect(); - - let mut out = existing.clone(); - if !out.is_empty() && out.last() != Some(&b'\n') { - out.push(b'\n'); - } - for path in paths { - if !already_tracked.contains(path.as_bytes()) { - out.extend_from_slice(path.as_bytes()); - out.push(b' '); - out.extend_from_slice(attr_bytes); - out.push(b'\n'); + _ => {} } - } - if out != existing { - std::fs::write(&gitattributes, &out)?; + if dry_run { + writeln!(io.err, "Would update {n} to {upstream}.")?; + continue; + } + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: update {n}")); + + let old_paths: Vec = repo.vendor_paths(&entry, head_oid)?; + let merge = repo.merge_vendor(&entry, head_oid, upstream)?; + let conflicted = self.apply_merge( + &mut config, + &cfg_path, + &mut entry, + &merge, + &old_paths, + &msg, + true, + io, + )?; + if conflicted { + return Err(ConflictExit.into()); + } + writeln!(io.err, "Updated {n}. Run `git commit` to record the merge.")?; + + // `prepare_merge` overwrites MERGE_HEAD rather than accumulating an + // octopus merge, so a second vendor's pending merge in the same run + // would silently clobber this one's. Stop here; re-running `update` + // after the commit picks up the rest. + break; } Ok(()) } - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - let gitattributes = workdir.join(".gitattributes"); + /// Validate and stage a merge result shared by `add` and `update`: checks + /// that upstream paths are trackable, checks out the merged tree + /// (conflicted or clean), reconciles `.gitattributes`, records the new + /// base, and stages `.gitvendors`. Returns `Ok(true)` if the merge left + /// conflicts, having already printed the conflict message — the caller + /// should return `Err(ConflictExit)`. `require_reconcile` controls + /// whether an unresolved `.gitattributes` conflict on a clean merge is + /// itself treated as an error (true for `update`, which has old paths to + /// reconcile against; false for `add`'s first-merge case, which has none). + #[allow(clippy::too_many_arguments)] + fn apply_merge( + &self, + config: &mut VendorConfig, + cfg_path: &Path, + entry: &mut VendorEntry, + merge: &git_vendor::VendorMerge, + old_paths: &[gix::bstr::BString], + msg: &str, + require_reconcile: bool, + io: &mut Io, + ) -> Result { + let repo = &self.0; + let new_paths = tree_paths(repo, merge.result_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(entry, merge)?; + reconcile_tracked_paths(repo, entry, old_paths, &new_paths, io)?; + entry.base = Some(merge.upstream_commit); + config.insert(entry)?; + let config_str = save_config(config, cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(entry, merge, msg)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + writeln!(io.err, "{}: conflict in {}", entry.name, paths.join(", "))?; + writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + return Ok(true); + } + + let _full_tree = repo.checkout_vendor(entry, merge.result_tree)?; + let reconciled = reconcile_tracked_paths(repo, entry, old_paths, &new_paths, io)?; + if require_reconcile { + reconciled + .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; + } + + entry.base = Some(merge.upstream_commit); + config.insert(entry)?; + let config_str = save_config(config, cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(entry, merge, msg)?; + Ok(false) + } - if !gitattributes.exists() { + /// Rebuild vendored files from `.gitvendors` without fetching (`update + /// --no-fetch`). Use after editing a vendor's `pattern` entries to move + /// or refilter its files. Refuses a modified vendor unless `force`. + fn update_no_fetch(&self, name: Option, force: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + + let entries = resolve_entries(&config, name.as_deref())?; + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; return Ok(()); } - let existing: Vec = std::fs::read(&gitattributes)?; - let attr_value = format!("vendor={}", entry.name.as_str()); - let attr_bytes = attr_value.as_bytes(); + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD: {e}"))?; + + // Patterns as last committed, for the local-modification check: a vendor + // whose ours tree differs from the pristine upstream tree of its recorded + // base carries patches that re-materializing would discard. + let old_config = config_at(repo, head_oid)?; + + let config_str = save_config(&config, &cfg_path)?; + + for entry in entries { + let n = entry.name.as_str().to_owned(); + let Some(base) = entry.base else { + writeln!( + io.err, + "{n}: no recorded base; run `git vendor update {n}` first" + )?; + continue; + }; + + let modified = locally_modified_paths(repo, old_config.as_ref(), &entry, head_oid)?; + if !modified.is_empty() && !force { + writeln!( + io.err, + "{n}: vendored files have local modifications ({}); \ + re-run with --force to discard them", + modified.join(", ") + )?; + continue; + } + + let new_tree = repo.upstream_tree(&entry, base)?; + let old_paths: Vec = repo.vendor_paths(&entry, head_oid)?; + let new_paths = tree_paths(repo, new_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + let _full_tree = repo.checkout_vendor(&entry, new_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? + .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; + stage_gitvendors(repo, config_str.as_bytes())?; + + writeln!( + io.err, + "Updated {n}. Run `git commit` to record the change." + )?; + } + + Ok(()) + } + + fn status(&self, name: Option, fetch: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; - let remove: std::collections::HashSet<&[u8]> = paths.iter().map(|b| b.as_bytes()).collect(); + let entries = resolve_entries(&config, name.as_deref())?; + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } - let mut filtered: Vec = Vec::with_capacity(existing.len()); - for line in existing.lines() { - let keep = if let Some(i) = line.iter().position(|&b| b == b' ') { - let attr = line[i + 1..].trim(); - !(attr == attr_bytes && remove.contains(&line[..i])) - } else { - true + for entry in &entries { + if fetch { + repo.fetch_vendor(entry)?; + } + let status = repo.vendor_status(entry)?; + let label = match &status { + VendorStatus::NotFetched => "not fetched".to_owned(), + VendorStatus::UpToDate => "up to date".to_owned(), + VendorStatus::UpdateAvailable { upstream } => { + format!("update available ({})", upstream.to_hex()) + } + VendorStatus::ForcePushed { upstream } => { + format!("force-pushed upstream ({})", upstream.to_hex()) + } }; - if keep { - filtered.extend_from_slice(line); - filtered.push(b'\n'); + writeln!(io.out, "{}\t{}\t{label}", entry.name, entry.url)?; + } + + Ok(()) + } + + fn remove(&self, name: String, keep_files: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let entry = require_entry(&config, &name)?; + + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + let paths = match head_oid { + Some(oid) => repo.vendor_paths(&entry, oid)?, + // No commit yet: resolve from the staged index instead, since + // there is no tree to read paths from (e.g. right after `add`). + None => git_vendor::resolve_vendor_paths_uncommitted(repo, &entry)?, + }; + + { + use gix::bstr::ByteSlice as _; + let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); + + if !keep_files { + let workdir = repo.workdir().ok_or("not a working-copy repository")?; + for p in &paths { + let abs = workdir.join(gix::path::from_bstr(p).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + remove_empty_ancestors(&abs, workdir); + } + } + + repo.untrack_vendor(&entry, &path_refs)?; + + if !keep_files { + let mut index = repo.open_index().map_err(|e| format!("{e}"))?; + for p in &path_refs { + let pb = p.as_bytes(); + index.remove_entries(|_, path, _| path == pb.as_bstr()); + } + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| format!("{e}"))?; } } - if filtered != existing { - std::fs::write(&gitattributes, &filtered)?; + config.remove(&name)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + writeln!(io.err, "Removed vendor {name}.")?; + Ok(()) + } + + fn list(&self, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + let entries = config.entries()?; + + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } + + for entry in &entries { + let ref_label = entry.ref_name.as_deref().unwrap_or("HEAD"); + let mode_label = entry.mode.as_str(); + writeln!( + io.out, + "{}\t{}\t{ref_label}\t{mode_label}", + entry.name, entry.url + )?; } Ok(()) } } + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn config_path(repo: &gix::Repository) -> Result { + let workdir = repo.workdir().ok_or("not a working-copy repository")?; + Ok(workdir.join(".gitvendors")) +} + +fn load_config(path: &Path) -> Result { + if path.exists() { + Ok(VendorConfig::open(path)?) + } else { + Ok(VendorConfig::parse("")?) + } +} + +/// Write `config` to `path` and return the serialized bytes for blob staging. +fn save_config(config: &VendorConfig, path: &Path) -> Result { + let s = config.to_string(); + std::fs::write(path, &s)?; + Ok(s) +} + +fn require_entry(config: &VendorConfig, name: &str) -> Result { + config + .get(name)? + .ok_or_else(|| format!("no vendor named {name:?}").into()) +} + +/// Remove `path`'s parent directory and each ancestor above it, as long as +/// they're empty and still inside `workdir`. Stops at the first non-empty or +/// out-of-bounds directory. +fn remove_empty_ancestors(path: &Path, workdir: &Path) { + let mut dir = path.parent(); + while let Some(d) = dir { + if d == workdir || !d.starts_with(workdir) { + break; + } + if std::fs::remove_dir(d).is_err() { + break; + } + dir = d.parent(); + } +} + +/// Resolve `name` to a single-entry list, or all configured vendors if omitted. +fn resolve_entries(config: &VendorConfig, name: Option<&str>) -> Result> { + match name { + Some(n) => Ok(vec![require_entry(config, n)?]), + None => Ok(config.entries()?), + } +} + +/// Local-modification guard for `update --no-fetch`: compares `entry`'s +/// current working tree against the pristine upstream tree of its last +/// recorded base, returning the sorted list of differing paths (empty if +/// unmodified or if there's no prior recorded base to compare against). +fn locally_modified_paths( + repo: &gix::Repository, + old_config: Option<&VendorConfig>, + entry: &VendorEntry, + head_oid: gix::ObjectId, +) -> Result> { + let pristine = old_config + .and_then(|c| c.get(entry.name.as_str()).ok().flatten()) + .and_then(|old| old.base.map(|b| (old, b))) + .map(|(old, b)| repo.upstream_tree(&old, b)) + .transpose()?; + let Some(pristine) = pristine else { + return Ok(Vec::new()); + }; + + let ours = repo.ours_tree(entry, head_oid)?; + if ours == pristine { + return Ok(Vec::new()); + } + + let pristine_blobs = tree_blobs(repo, pristine)?; + let our_blobs = tree_blobs(repo, ours)?; + let mut modified: Vec = our_blobs + .iter() + .filter(|(p, oid)| pristine_blobs.get(*p) != Some(oid)) + .map(|(p, _)| p.to_string()) + .collect(); + modified.extend( + pristine_blobs + .keys() + .filter(|p| !our_blobs.contains_key(*p)) + .map(|p| p.to_string()), + ); + modified.sort(); + Ok(modified) +} + +fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| e.path(&index).into()) + .collect()) +} + +fn tree_blobs( + repo: &gix::Repository, + tree_id: gix::ObjectId, +) -> Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| (e.path(&index).into(), e.id)) + .collect()) +} + +fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result> { + let tree = repo + .find_commit(commit) + .map_err(|e| format!("{e}"))? + .tree() + .map_err(|e| format!("{e}"))?; + let Some(entry) = tree + .lookup_entry_by_path(".gitvendors") + .map_err(|e| format!("{e}"))? + else { + return Ok(None); + }; + let blob = entry.object().map_err(|e| format!("{e}"))?; + let s = String::from_utf8_lossy(&blob.data).into_owned(); + Ok(Some(VendorConfig::parse(&s)?)) +} + +/// Whether `path` currently has any unmerged (non-zero-stage) entry in the +/// index — i.e. it is itself part of an unresolved conflict. +fn has_unmerged_stages(repo: &gix::Repository, path: &gix::bstr::BStr) -> Result { + let index = repo.open_index().map_err(|e| format!("{e}"))?; + Ok(index.entries().iter().any(|e| { + e.path(&index) == path && e.flags.stage() != gix::index::entry::Stage::Unconflicted + })) +} + +/// Update `.gitattributes` tracking for a vendor's path set. +/// +/// A vendor whose destination pattern maps onto `.gitattributes` itself can +/// leave that path with unmerged stages after `checkout_vendor_conflicted` +/// spliced in a genuine merge conflict on it. Writing new tracking lines in +/// that case would silently collapse the conflict to a single resolved +/// stage-0 entry, so `git commit` would succeed despite the unresolved +/// conflict. When that happens, this leaves the unmerged stages untouched and +/// returns `None` instead. +fn reconcile_tracked_paths( + repo: &gix::Repository, + entry: &VendorEntry, + old_paths: &[gix::bstr::BString], + new_paths: &[gix::bstr::BString], + io: &mut Io, +) -> Result> { + use gix::bstr::{BStr, ByteSlice as _}; + + if has_unmerged_stages(repo, b".gitattributes".as_bstr())? { + writeln!( + io.err, + "{}: .gitattributes itself is part of this conflict; resolve it \ + manually, including the vendor={} tracking lines, before committing", + entry.name, entry.name, + )?; + return Ok(None); + } + + let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + let attrs_oid = repo.track_vendor(entry, &track)?; + + let new_set: std::collections::HashSet<&[u8]> = + new_paths.iter().map(|b| b.as_slice()).collect(); + let removed: Vec<&BStr> = old_paths + .iter() + .filter(|b| !new_set.contains(b.as_slice())) + .map(|b| b.as_ref()) + .collect(); + if !removed.is_empty() + && let Some(oid) = repo.untrack_vendor(entry, &removed)? + { + return Ok(Some(oid)); + } + Ok(Some(attrs_oid)) +} + +/// Write `content` as a blob, upsert the `.gitvendors` index entry, and return +/// the blob OID so callers can include it in a commit tree. +fn stage_gitvendors(repo: &gix::Repository, content: &[u8]) -> Result { + use gix::bstr::ByteSlice as _; + let blob_oid = repo + .write_object(gix::objs::BlobRef { data: content }) + .map_err(|e| format!("{e}"))? + .detach(); + let mut index = repo.open_index().map_err(|e| format!("{e}"))?; + index.remove_entries(|_, path, _| path == b".gitvendors".as_bstr()); + index.dangerously_push_entry( + gix::index::entry::Stat::default(), + blob_oid, + gix::index::entry::Flags::empty(), + gix::index::entry::Mode::FILE, + b".gitvendors".as_bstr(), + ); + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| format!("{e}"))?; + Ok(blob_oid) +} + +fn name_from_url(url: &str) -> Option { + let stem = url + .trim_end_matches('/') + .rsplit(['/', ':']) + .find(|s| !s.is_empty())?; + let stem = stem + .strip_suffix(".git") + .or_else(|| stem.strip_suffix(".bundle")) + .unwrap_or(stem); + if stem.is_empty() { + None + } else { + Some(stem.to_owned()) + } +} + +#[cfg(test)] +#[path = "exe_tests.rs"] +mod tests; diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/exe_tests.rs new file mode 100644 index 0000000..c0e64f2 --- /dev/null +++ b/crates/git-vendor/src/exe_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use std::path::Path; + +fn git(args: &[&str], dir: &Path) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git"); + assert!( + output.status.success(), + "git {args:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn test_entry() -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: Vec::new(), + mode: VendorMode::Merge, + } +} + +/// `reconcile_tracked_paths` must not collapse a genuine unresolved merge +/// conflict on `.gitattributes` itself into a single resolved stage-0 +/// entry: doing so would let `git commit` silently record the +/// conflict-marker text as if it were normal content. Regression: the +/// function always called `track_vendor`, which reads and rewrites +/// `.gitattributes`, then `stage_gitattributes` unconditionally replaced +/// whatever stages were there with one stage-0 entry. +#[test] +fn skips_write_when_gitattributes_itself_is_conflicted() { + use git_vendor::{ConflictStages, VendorMerge}; + + let dir = tempfile::tempdir().unwrap(); + git(&["init", "-q", "-b", "main"], dir.path()); + git(&["config", "user.email", "t@example.com"], dir.path()); + git(&["config", "user.name", "T"], dir.path()); + std::fs::write(dir.path().join(".gitattributes"), "* text=auto\n").unwrap(); + git(&["add", "."], dir.path()); + git(&["commit", "-q", "-m", "init"], dir.path()); + + let repo = gix::open(dir.path()).expect("gix open"); + let entry = test_entry(); + + let base_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\n", + }) + .expect("write base blob") + .detach(); + let ours_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\nours=1\n", + }) + .expect("write ours blob") + .detach(); + let theirs_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\ntheirs=1\n", + }) + .expect("write theirs blob") + .detach(); + let conflict_marker_blob = repo + .write_object(gix::objs::BlobRef { + data: b"<<<<<<< ours\nours=1\n=======\ntheirs=1\n>>>>>>> theirs\n", + }) + .expect("write conflict-marker blob") + .detach(); + + let blob_mode = gix::objs::tree::EntryMode::from(gix::objs::tree::EntryKind::Blob); + let head_tree = repo + .head_commit() + .expect("head") + .tree_id() + .expect("tree") + .detach(); + let mut editor = repo + .find_tree(head_tree) + .expect("find tree") + .edit() + .expect("edit"); + editor + .upsert( + ".gitattributes", + gix::objs::tree::EntryKind::Blob, + conflict_marker_blob, + ) + .expect("upsert conflicted .gitattributes"); + let result_tree = editor.write().expect("write tree").detach(); + + let merge = VendorMerge { + upstream_commit: repo.head_commit().expect("head").id().detach(), + ancestor_tree: None, + result_tree, + conflicts: vec![ConflictStages { + path: ".gitattributes".to_owned(), + stages: [ + Some((blob_mode, base_blob)), + Some((blob_mode, ours_blob)), + Some((blob_mode, theirs_blob)), + ], + }], + }; + + repo.checkout_vendor_conflicted(&entry, &merge) + .expect("checkout_vendor_conflicted"); + + let new_paths = tree_paths(&repo, merge.result_tree).expect("tree_paths"); + let mut io = Io { + out: Box::new(Vec::new()), + err: Box::new(Vec::new()), + }; + let result = reconcile_tracked_paths(&repo, &entry, &[], &new_paths, &mut io) + .expect("reconcile_tracked_paths"); + assert!( + result.is_none(), + "must skip and return None when .gitattributes itself is conflicted", + ); + + let index = repo.open_index().expect("open_index"); + use gix::bstr::ByteSlice as _; + let stages: Vec<_> = index + .entries() + .iter() + .filter(|e| e.path(&index) == b".gitattributes".as_bstr()) + .map(|e| e.flags.stage()) + .collect(); + assert_eq!( + stages.len(), + 3, + "all three unmerged stages must survive, got {stages:?}", + ); + assert!( + stages + .iter() + .all(|s| *s != gix::index::entry::Stage::Unconflicted), + "no stage should have been collapsed to stage 0, got {stages:?}", + ); +} diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index 84ff3fd..aba23be 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -3,10 +3,8 @@ mod error; mod vendor; -pub mod exe; - pub use error::Error; -use gix::bstr::ByteSlice as _; +use gix::bstr::{BStr, ByteSlice as _}; use gix::remote::fetch::{Status, refs::update::Mode}; pub use vendor::{ ConflictStages, PatternMapping, VendorConfig, VendorEntry, VendorMerge, VendorMode, VendorName, @@ -36,9 +34,9 @@ fn is_unsafe_local_path(path: &gix::bstr::BStr) -> bool { impl VendorRepository for gix::Repository { /// Fetches `entry.tracking_ref()` from `entry.url` into `refs/vendor/` - /// and returns the *peeled* tip OID. When the tracked ref is an annotated - /// tag, the returned id is the tag's ultimate target, not the tag object - /// stored at `refs/vendor/`. + /// and returns the *peeled* tip OID. `refs/vendor/` is always written + /// to point directly at that same peeled OID, even when the tracked ref is + /// an annotated tag — the tag object itself is never stored there. /// /// If the local ref is already up to date, the ref tip's existing object hash /// is returned. @@ -57,14 +55,7 @@ impl VendorRepository for gix::Repository { | gix::url::Scheme::Http | gix::url::Scheme::Ssh | gix::url::Scheme::Git => {} - gix::url::Scheme::File => { - if !self.is_bare() { - return Err(Error::InvalidUrl(format!( - "{}: refusing transport `{:?}`; local transports are not yet supported", - entry.url, url.scheme - ))); - } - } + gix::url::Scheme::File => {} ref other => { return Err(Error::InvalidUrl(format!( "{}: refusing transport `{other:?}`; plug-in transports are not supported", @@ -128,8 +119,61 @@ impl VendorRepository for gix::Repository { } } - let mut reference = self.find_reference(&entry.vendor_ref())?; - let id = reference.peel_to_id()?.detach(); + // Read the upstream OID from the refmap rather than by re-reading the + // local vendor ref. When the remote advertises HEAD as a symbolic ref + // (e.g. `HEAD → refs/heads/main`) and the local repo happens to have a + // branch of the same name, gix writes `refs/vendor/` as a symref + // pointing to that local branch; `peel_to_id()` would then silently + // return the *local* HEAD instead of the upstream tip. The refmap + // carries the actual upstream OID directly before any local ref + // resolution, so keying on it sidesteps the bug entirely. + // Upstream: https://github.com/GitoxideLabs/gitoxide/issues/2613 + let vendor_ref = entry.vendor_ref(); + let id = outcome + .ref_map + .mappings + .iter() + .find(|m| { + m.local + .as_deref() + .map(|l| l == vendor_ref.as_bytes()) + .unwrap_or(false) + }) + .and_then(|m| m.remote.peeled_id()) + .map(gix::oid::to_owned) + .ok_or_else(|| { + Error::Fetch(format!( + "remote has no ref matching `{}` for vendor `{}`", + entry.tracking_ref(), + entry.name + )) + })?; + + // `peeled_id()` only returns a peeled value when the ref advertisement + // carried one; fetching an annotated tag by its own object SHA + // (`Source::ObjectId`) has no such advertisement, so `id` may still be + // the tag object itself. Peel explicitly so callers always get a commit. + let id = self + .find_object(id) + .map_err(|e| Error::Gix(Box::new(e)))? + .peel_to_commit() + .map_err(|e| Error::Gix(Box::new(e)))? + .id() + .detach(); + + // Force `refs/vendor/` to point directly at `id`, overwriting + // whatever gix wrote for it (see the gix#2613 note above: it may be a + // symref into the local branch namespace rather than a direct ref to + // the fetched commit). This keeps `vendor_tip`/`vendor_status`, which + // read the ref directly, from resolving the corrupted symref. + self.reference( + entry.vendor_ref(), + id, + gix::refs::transaction::PreviousValue::Any, + format!("fetch {}", entry.tracking_ref()), + ) + .map_err(|e| Error::Gix(Box::new(e)))?; + Ok(id) } @@ -515,3 +559,415 @@ fn resolve_vendor_paths( } Ok(paths) } + +/// Like [`resolve_vendor_paths`], but resolves against the current on-disk +/// index instead of a commit's tree. For use on an unborn `HEAD`, where +/// staged entries exist (e.g. right after `add`) but there is no commit yet +/// to read a tree from. Index entries are always files, so no tree-vs-blob +/// filtering is needed. +pub fn resolve_vendor_paths_uncommitted( + repo: &gix::Repository, + entry: &VendorEntry, +) -> Result, Error> { + let index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + let mut stack = repo.attributes_only( + &index, + gix::worktree::stack::state::attributes::Source::IdMapping, + )?; + let mut outcome = stack.selected_attribute_matches(["vendor"]); + + let mut paths = Vec::new(); + for e in index.entries() { + let path = e.path(&index).to_owned(); + let platform = stack.at_entry(path.as_bstr(), None)?; + outcome.reset(); + platform.matching_attributes(&mut outcome); + let is_ours = outcome.iter_selected().any(|m| { + matches!( + m.assignment.state, + gix::attrs::StateRef::Value(v) + if v.as_bstr() == entry.name.as_bytes().as_bstr() + ) + }); + if is_ours { + paths.push(path); + } + } + Ok(paths) +} + +// ── worktree impl ──────────────────────────────────────────────────────────── + +impl VendorWorktree for gix::Repository { + fn checkout_vendor( + &self, + entry: &VendorEntry, + tree: gix::ObjectId, + ) -> Result { + // IMPORTANT + // This is the trust boundary where upstream content (carried verbatim + // through `upstream_tree`, including symlink and gitlink modes, + // mirroring git-subtree/submodule) reaches the working copy. Like + // core git's `verify_path`/checkout, projection MUST refuse to write + // through a symlinked leading path and reject `..`/absolute + // components — use gix-worktree's checked checkout, never naive + // `std::fs` writes. See the `upstream_tree` adversarial review (#5). + // NOTE + // Path-traversal safety (e.g. `../` components, symlinked leading + // paths) is delegated to gix and is not covered by automated tests. + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + + let head_id = self.head_commit().ok().map(|c| c.id().detach()); + + let old_paths: std::collections::BTreeSet = head_id + .and_then(|id| resolve_vendor_paths(self, entry, id).ok()) + .into_iter() + .flatten() + .collect(); + + let mut vendor_index = self.index_from_tree(&tree)?; + + let new_paths: std::collections::BTreeSet = vendor_index + .entries() + .iter() + .map(|e| e.path(&vendor_index).to_owned()) + .collect(); + + let opts = self + .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping) + .map_err(|e| Error::Gix(Box::new(e)))?; + let progress = gix::progress::Discard; + gix::worktree::state::checkout( + &mut vendor_index, + workdir, + self.objects.clone().into_arc().map_err(Error::Io)?, + &progress, + &progress, + &gix::interrupt::IS_INTERRUPTED, + gix::worktree::state::checkout::Options { + overwrite_existing: true, + ..opts + }, + ) + .map_err(|e| Error::Gix(Box::new(e)))?; + + for removed in old_paths.difference(&new_paths) { + let abs = workdir.join(gix::path::from_bstr(removed).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + } + + // The tree returned to callers still overlays the vendor tree onto the + // full HEAD tree (an unborn HEAD has no base commit, so the vendor + // tree is itself the whole tree) — callers need this OID to mint + // commits regardless of what the on-disk index looks like. + let full_tree = match head_id { + Some(id) => self.vendor_overlay(entry, id, tree)?, + None => tree, + }; + + // The on-disk index, however, must not be derived from `full_tree`: + // that overlay reads from HEAD, never the index, so any entry staged + // but not yet committed (an addition or modification) would be + // silently dropped. Instead, start from the actual current index — + // or an empty one if none exists yet — and surgically apply only this + // vendor's own path changes, leaving every other entry untouched. + let mut main_index = match self.open_index() { + Ok(idx) => idx, + Err(_) => self.index_from_tree(&gix::ObjectId::empty_tree(self.object_hash()))?, + }; + main_index.set_path(self.git_dir().join("index")); + + for removed in old_paths.difference(&new_paths) { + main_index.remove_entries(|_, p, _| p == removed.as_bstr()); + } + for e in vendor_index.entries() { + let path = e.path(&vendor_index).to_owned(); + main_index.remove_entries(|_, p, _| p == path.as_bstr()); + main_index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, path.as_bstr()); + } + main_index.sort_entries(); + // `remove_entries`/`dangerously_push_entry` don't update the index's + // cached-tree extension, so a stale one would make a native `git + // commit` skip rehashing changed subtrees and record the wrong tree. + main_index.remove_tree(); + + main_index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(full_tree) + } + + fn checkout_vendor_conflicted( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + ) -> Result<(), Error> { + use gix::bstr::ByteSlice as _; + + self.checkout_vendor(entry, merge.result_tree)?; + + // Reopen the index we just wrote so we can splice in unmerged stages. + let mut main_index = self.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + main_index.set_path(self.git_dir().join("index")); + + for conflict in &merge.conflicts { + let path_bytes = gix::bstr::BString::from(conflict.path.as_bytes()); + let path_bstr = path_bytes.as_bstr(); + + main_index.remove_entries(|_, p, _| p == path_bstr); + + for (stage_idx, stage_variant) in [ + (0usize, gix::index::entry::Stage::Base), + (1usize, gix::index::entry::Stage::Ours), + (2usize, gix::index::entry::Stage::Theirs), + ] { + if let Some((tree_mode, oid)) = conflict.stages[stage_idx] { + let mode = gix::index::entry::Mode::from(tree_mode); + let flags = gix::index::entry::Flags::from_stage(stage_variant); + main_index.dangerously_push_entry( + gix::index::entry::Stat::default(), + oid, + flags, + mode, + path_bstr, + ); + } + } + } + + main_index.sort_entries(); + main_index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(()) + } + + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result { + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + let gitattributes = workdir.join(".gitattributes"); + + let existing: Vec = if gitattributes.exists() { + std::fs::read(&gitattributes)? + } else { + Vec::new() + }; + + let attr_value = format!("vendor={}", entry.name.as_str()); + let attr_bytes = attr_value.as_bytes(); + + for path in paths { + check_attr_pattern(path.as_bytes())?; + } + + let already_tracked: std::collections::HashSet> = existing + .lines() + .filter_map(|line| { + let (pattern, attr) = split_attr_line(line)?; + if attr == attr_bytes { + Some(unescape_attr_pattern(&pattern)) + } else { + None + } + }) + .collect(); + + let mut out = existing.clone(); + if !out.is_empty() && out.last() != Some(&b'\n') { + out.push(b'\n'); + } + for path in paths { + if !already_tracked.contains(path.as_bytes()) { + out.extend_from_slice(&escape_attr_pattern(path.as_bytes())); + out.push(b' '); + out.extend_from_slice(attr_bytes); + out.push(b'\n'); + } + } + + if out != existing { + std::fs::write(&gitattributes, &out)?; + } + + stage_gitattributes(self, &out) + } + + fn untrack_vendor( + &self, + entry: &VendorEntry, + paths: &[&BStr], + ) -> Result, Error> { + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + let gitattributes = workdir.join(".gitattributes"); + + if !gitattributes.exists() { + return Ok(None); + } + + let existing: Vec = std::fs::read(&gitattributes)?; + let attr_value = format!("vendor={}", entry.name.as_str()); + let attr_bytes = attr_value.as_bytes(); + + let remove: std::collections::HashSet<&[u8]> = paths.iter().map(|b| b.as_bytes()).collect(); + + let mut filtered: Vec = Vec::with_capacity(existing.len()); + for line in existing.lines() { + let keep = match split_attr_line(line) { + Some((pattern, attr)) => { + !(attr == attr_bytes + && remove.contains(unescape_attr_pattern(&pattern).as_slice())) + } + None => true, + }; + if keep { + filtered.extend_from_slice(line); + filtered.push(b'\n'); + } + } + + if filtered != existing { + std::fs::write(&gitattributes, &filtered)?; + } + + Ok(Some(stage_gitattributes(self, &filtered)?)) + } + + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error> { + let git_dir = self.git_dir(); + if entry.mode == VendorMode::Squash { + std::fs::write(git_dir.join("SQUASH_MSG"), message.as_bytes())?; + } else { + std::fs::write( + git_dir.join("MERGE_HEAD"), + format!("{}\n", merge.upstream_commit), + )?; + std::fs::write(git_dir.join("MERGE_MSG"), message.as_bytes())?; + } + Ok(()) + } +} + +/// Check that every path in `paths` can be written as a plain (unquoted) +/// `.gitattributes` pattern. Callers that will later checkout files and +/// mutate the working tree/index should validate paths with this *before* +/// doing so, so an invalid path aborts cleanly instead of leaving a +/// half-applied checkout behind — see [`check_attr_pattern`] for what's +/// rejected. +pub fn validate_trackable_paths(paths: &[&BStr]) -> Result<(), Error> { + for p in paths { + check_attr_pattern(p.as_bytes())?; + } + Ok(()) +} + +/// Return `Err` if `path` contains characters that require C-style quoting in +/// `.gitattributes` (space, tab, `#`, `"`, `\`, or control characters). +/// Git source paths from tree objects never contain these in practice. +fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { + let needs_quoting = |b: u8| b.is_ascii_control() || matches!(b, b' ' | b'#' | b'"' | b'\\'); + if path.iter().copied().any(needs_quoting) { + return Err(Error::InvalidPath( + String::from_utf8_lossy(path).into_owned(), + )); + } + Ok(()) +} + +/// Escape glob metacharacters (`*`, `?`, `[`) and a pattern-initial `!` or `#` +/// with a backslash, so `path` matches only itself as a `.gitattributes` +/// pattern. `check_attr_pattern` already rejects a raw `\` in the input, so +/// every backslash in the result is unambiguously one we inserted here. +fn escape_attr_pattern(path: &[u8]) -> Vec { + let mut out = Vec::with_capacity(path.len()); + for (i, &b) in path.iter().enumerate() { + if matches!(b, b'*' | b'?' | b'[') || (i == 0 && matches!(b, b'!' | b'#')) { + out.push(b'\\'); + } + out.push(b); + } + out +} + +/// Inverse of [`escape_attr_pattern`]. +fn unescape_attr_pattern(pattern: &[u8]) -> Vec { + let mut out = Vec::with_capacity(pattern.len()); + let mut i = 0; + while i < pattern.len() { + if pattern[i] == b'\\' + && i + 1 < pattern.len() + && (matches!(pattern[i + 1], b'*' | b'?' | b'[') + || (i == 0 && matches!(pattern[i + 1], b'!' | b'#'))) + { + i += 1; + } + out.push(pattern[i]); + i += 1; + } + out +} + +/// Parse one `.gitattributes` line into `(unquoted_pattern, trimmed_attrs)`. +/// +/// Returns `None` for blank lines, comment lines, or lines with no attribute +/// separator. Handles both plain and C-style-quoted patterns using +/// [`gix_quote::ansi_c::undo`]. +fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { + if line.is_empty() || line[0] == b'#' { + return None; + } + if line.starts_with(b"\"") { + let (pattern, consumed) = gix_quote::ansi_c::undo(line.as_bstr()).ok()?; + let rest = line.get(consumed..)?; + if rest.first().is_some_and(|&b| b == b' ' || b == b'\t') { + let owned: Vec = pattern.as_ref().to_vec(); + Some((std::borrow::Cow::Owned(owned), rest[1..].trim())) + } else { + None + } + } else { + let pos = line.iter().position(|&b| b == b' ' || b == b'\t')?; + if pos == 0 { + return None; + } + Some(( + std::borrow::Cow::Borrowed(&line[..pos]), + line[pos + 1..].trim(), + )) + } +} + +#[cfg(test)] +#[path = "attr_tests.rs"] +mod tests; + +/// Write `content` as a blob into the object database, upsert the +/// `.gitattributes` index entry to point at it, and return the blob OID. +fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result { + let blob_oid = repo + .write_object(gix::objs::BlobRef { data: content })? + .detach(); + + let mut index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + index.remove_entries(|_, path, _| path == b".gitattributes".as_bstr()); + index.dangerously_push_entry( + gix::index::entry::Stat::default(), + blob_oid, + gix::index::entry::Flags::empty(), + gix::index::entry::Mode::FILE, + b".gitattributes".as_bstr(), + ); + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(blob_oid) +} diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 209b9b6..6394950 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -1,2 +1,21 @@ -// TODO -fn main() {} +#![allow(missing_docs)] + +mod cli; +mod exe; + +use clap::Parser as _; + +type Result> = std::result::Result; + +fn main() { + if let Err(e) = run() { + if e.downcast_ref::().is_none() { + eprintln!("error: {e}"); + } + std::process::exit(1); + } +} + +fn run() -> Result<()> { + exe::Executor::discover()?.run(cli::Cli::parse(), &mut exe::Io::stdio()) +} diff --git a/crates/git-vendor/src/vendor.rs b/crates/git-vendor/src/vendor.rs index 9fb9a03..7157cfd 100644 --- a/crates/git-vendor/src/vendor.rs +++ b/crates/git-vendor/src/vendor.rs @@ -601,7 +601,14 @@ pub trait VendorWorktree { /// /// Only paths owned by this vendor are written; unrelated files are left /// untouched. - fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error>; + /// Returns the full overlaid tree OID (the result of splicing `tree` into + /// the parent commit's tree), needed by callers that want to mint a commit + /// from the same tree without re-running the overlay. + fn checkout_vendor( + &self, + entry: &VendorEntry, + tree: gix::ObjectId, + ) -> Result; /// Project a conflicted merge onto the working copy for manual resolution. /// @@ -624,14 +631,32 @@ pub trait VendorWorktree { ) -> Result<(), Error>; /// Add the given paths to the vendor's local content filter by writing - /// `vendor=` entries into the working-copy `.gitattributes`. + /// `vendor=` entries into the working-copy `.gitattributes` and + /// staging the updated file into the index. /// /// This authors local-side membership (read back by /// [`VendorRepository::vendor_paths`](crate::VendorRepository::vendor_paths)); /// it is independent of the upstream pattern filter. - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result; /// Remove the given paths from the vendor's content filter, deleting their - /// `vendor=` entries from the working-copy `.gitattributes`. - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + /// `vendor=` entries from the working-copy `.gitattributes` and + /// staging the updated file into the index. + fn untrack_vendor( + &self, + entry: &VendorEntry, + paths: &[&BStr], + ) -> Result, Error>; + + /// Stage the merge result for a subsequent `git commit`. + /// + /// Writes `MERGE_HEAD` + `MERGE_MSG` (merge mode) or `SQUASH_MSG` (squash + /// mode) so that the user's own `git commit` produces the right commit + /// shape and has the default message pre-filled. + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error>; } diff --git a/crates/git-vendor/tests/checkout_vendor/table.rs b/crates/git-vendor/tests/checkout_vendor/table.rs index 29daddf..c5ed1e5 100644 --- a/crates/git-vendor/tests/checkout_vendor/table.rs +++ b/crates/git-vendor/tests/checkout_vendor/table.rs @@ -273,3 +273,48 @@ fn index_entries_are_sorted_after_checkout() { sorted.sort(); assert_eq!(paths, sorted, "index entries must be sorted after checkout"); } + +/// A file staged but not yet committed must survive `checkout_vendor`. +/// Regression: the on-disk index was rebuilt from `full_tree`, which overlays +/// the vendor tree onto *HEAD's* committed tree — never the index — so any +/// staged-but-uncommitted addition was silently dropped from the index. +#[test] +fn staged_uncommitted_addition_is_preserved() { + let b = build(); + let workdir = b.repo.workdir().unwrap().to_owned(); + write(&workdir, "newfile.txt", b"staged\n"); + git(&["add", "newfile.txt"], &workdir); + + let tree = build_tree(&b.repo, &[("vendor/keep.txt", b"v2\n")]); + b.repo.checkout_vendor(&entry(), tree).expect("checkout"); + + let paths = index_paths(&b.repo); + assert!( + paths.contains(&"newfile.txt".to_owned()), + "staged addition must survive checkout: {paths:?}", + ); +} + +/// The same regression on an unborn `HEAD`: staging a file before the first +/// commit, then running `checkout_vendor`, must not wipe out the entire +/// pre-existing index (there is no committed tree to fall back to at all in +/// this case, so the old behavior dropped every staged entry). +#[test] +fn staged_uncommitted_addition_survives_unborn_head_checkout() { + let dir = tempfile::tempdir().unwrap(); + init(dir.path()); + write(dir.path(), "README", b"local readme\n"); + git(&["add", "README"], dir.path()); + // No commit — HEAD is unborn. + let repo = gix::open(dir.path()).unwrap(); + + let tree = build_tree(&repo, &[("vendor/new.txt", b"new\n")]); + repo.checkout_vendor(&entry(), tree) + .expect("checkout into unborn HEAD"); + + let paths = index_paths(&repo); + assert!( + paths.contains(&"README".to_owned()), + "pre-existing staged entry must survive checkout: {paths:?}", + ); +} diff --git a/crates/git-vendor/tests/cli.rs b/crates/git-vendor/tests/cli.rs new file mode 100644 index 0000000..34d86ec --- /dev/null +++ b/crates/git-vendor/tests/cli.rs @@ -0,0 +1,6 @@ +mod support; +mod cli { + mod add; + mod remove; + mod update; +} diff --git a/crates/git-vendor/tests/cli/add.rs b/crates/git-vendor/tests/cli/add.rs new file mode 100644 index 0000000..3de90f7 --- /dev/null +++ b/crates/git-vendor/tests/cli/add.rs @@ -0,0 +1,51 @@ +//! End-to-end tests for `git-vendor add`. + +use crate::support::{git, git_capture, init, vendor, write}; + +/// `add` must validate that every upstream path can be written as an +/// unquoted `.gitattributes` pattern *before* checking out any files, so an +/// unquotable path (e.g. containing a space) aborts cleanly instead of +/// leaving a half-applied checkout: vendored files present on disk and +/// staged in the index, but no `.gitvendors` entry and no `.gitattributes` +/// tracking. Regression: the check ran inside `track_vendor`, which is +/// called only *after* `checkout_vendor` has already mutated the working +/// tree and index. +#[test] +fn add_rejects_unquotable_path_without_partial_mutation() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + init(upstream.path()); + write(upstream.path(), "read me.txt", b"hello\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "c"], upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + let out = vendor(&["add", url, "mylib"], local.path()); + assert!( + !out.status.success(), + "add must fail when an upstream path cannot be written as an unquoted \ + .gitattributes pattern", + ); + + assert!( + !local.path().join("vendor").exists(), + "no partial checkout should be left behind on disk", + ); + + let status = String::from_utf8(git_capture(&["status", "--porcelain"], local.path())).unwrap(); + assert!( + status.is_empty(), + "working tree/index must be untouched after the rejected add, but status was:\n{status}", + ); + + assert!( + !local.path().join(".gitvendors").exists(), + ".gitvendors must not be created by a rejected add", + ); +} diff --git a/crates/git-vendor/tests/cli/remove.rs b/crates/git-vendor/tests/cli/remove.rs new file mode 100644 index 0000000..4b6bb63 --- /dev/null +++ b/crates/git-vendor/tests/cli/remove.rs @@ -0,0 +1,124 @@ +//! End-to-end tests for `git-vendor remove`. + +use crate::support::{git, git_capture, init, make_upstream, vendor, write}; + +/// Run a vendor subcommand and assert it exited zero, surfacing stderr on +/// failure. +fn vendor_ok(args: &[&str], dir: &std::path::Path) { + let out = vendor(args, dir); + assert!( + out.status.success(), + "git-vendor {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// `remove` must stage the rewritten `.gitvendors` so the user's next commit +/// records the deletion. Regression: `cmd_remove` wrote `.gitvendors` to the +/// working tree but never staged it, leaving the index pointing at the old +/// blob that still listed the removed vendor. +#[test] +fn remove_stages_updated_gitvendors() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + // `add` leaves the merge staged with MERGE_HEAD set; seal it. + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + let committed = + String::from_utf8(git_capture(&["show", "HEAD:.gitvendors"], local.path())).unwrap(); + assert!( + committed.contains("mylib"), + "precondition: committed .gitvendors must list the vendor:\n{committed}", + ); + + vendor_ok(&["remove", "mylib"], local.path()); + + let staged = String::from_utf8(git_capture(&["show", ":.gitvendors"], local.path())).unwrap(); + assert!( + !staged.contains("mylib"), + "staged .gitvendors must not reference the removed vendor, but was:\n{staged}", + ); +} + +/// `remove --keep-files` must still untrack the vendor's `.gitattributes` +/// entries, even though it leaves the files themselves on disk. Regression: +/// `untrack_vendor` was called only in the `!keep_files` branch, so +/// `--keep-files` left stale `vendor=` lines behind. +#[test] +fn remove_keep_files_still_untracks_gitattributes() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + vendor_ok(&["remove", "--keep-files", "mylib"], local.path()); + + assert!( + local.path().join("vendor/mylib/hello.txt").exists(), + "--keep-files must leave the vendored files on disk", + ); + + let staged = + String::from_utf8(git_capture(&["show", ":.gitattributes"], local.path())).unwrap(); + assert!( + !staged.contains("vendor=mylib"), + "staged .gitattributes must not reference the removed vendor, but was:\n{staged}", + ); +} + +/// `remove` on an unborn HEAD (before the first commit) must actually remove +/// the vendor, not just its `.gitvendors` entry. Regression: file deletion, +/// `untrack_vendor`, and index cleanup were all gated on `repo.head_commit()` +/// succeeding, so on an unborn HEAD `remove` reported success while leaving +/// vendored files on disk, in the index, and in `.gitattributes`. +#[test] +fn remove_before_first_commit_removes_vendor() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + init(local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + + vendor_ok(&["remove", "mylib"], local.path()); + + assert!( + !local.path().join("vendor/mylib/hello.txt").exists(), + "remove must delete vendored files even on an unborn HEAD", + ); + + let tracked = String::from_utf8(git_capture(&["ls-files"], local.path())).unwrap(); + assert!( + !tracked.lines().any(|l| l.starts_with("vendor/mylib/")), + "no vendor/mylib/* path should remain in the index, but ls-files was:\n{tracked}", + ); + + if local.path().join(".gitattributes").exists() { + let contents = std::fs::read_to_string(local.path().join(".gitattributes")).unwrap(); + assert!( + !contents.contains("vendor=mylib"), + ".gitattributes must not reference the removed vendor, but was:\n{contents}", + ); + } +} diff --git a/crates/git-vendor/tests/cli/update.rs b/crates/git-vendor/tests/cli/update.rs new file mode 100644 index 0000000..df619b1 --- /dev/null +++ b/crates/git-vendor/tests/cli/update.rs @@ -0,0 +1,76 @@ +//! End-to-end tests for `git-vendor update`. + +use crate::support::{git, git_capture, init, vendor, write}; + +/// Run a vendor subcommand and assert it exited zero, surfacing stderr on +/// failure. +fn vendor_ok(args: &[&str], dir: &std::path::Path) { + let out = vendor(args, dir); + assert!( + out.status.success(), + "git-vendor {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// A conflicting `update` must still record `.gitattributes` tracking for the +/// vendor paths it introduces, so that once the user resolves the conflict and +/// commits, later `status`/`update`/`remove` can still find the vendor's files. +/// +/// Regression: the conflict branch of `cmd_update` checked out the conflicted +/// tree and prepared the merge but skipped `reconcile_tracked_paths`, so a +/// newly vendored file landing alongside a conflict was never written to +/// `.gitattributes`. +#[test] +fn conflicting_update_tracks_new_paths() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + // Upstream v1: a single file that the local side will diverge from. + init(upstream.path()); + write(upstream.path(), "hello.txt", b"v1\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "v1"], upstream.path()); + + // Local: vendor it (default prefix vendor/mylib/) and seal the add. + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + // Local patch: diverge the vendored file so the next update conflicts. + write(local.path(), "vendor/mylib/hello.txt", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "local patch"], local.path()); + + // Upstream v2: change the same file (forcing a conflict) and add a brand + // new file (the path that must still get tracked). + write(upstream.path(), "hello.txt", b"v2\n"); + write(upstream.path(), "extra.txt", b"new\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "v2"], upstream.path()); + + // Update must conflict (exit non-zero) and leave the merge in progress. + let out = vendor(&["update", "mylib"], local.path()); + assert!( + !out.status.success(), + "update should report the conflict via non-zero exit:\n{}", + String::from_utf8_lossy(&out.stderr), + ); + + // Resolve the conflict and complete the merge the user is told to make. + write(local.path(), "vendor/mylib/hello.txt", b"resolved\n"); + git(&["add", "vendor/mylib/hello.txt"], local.path()); + git(&["commit", "--no-edit"], local.path()); + + let attrs = + String::from_utf8(git_capture(&["show", "HEAD:.gitattributes"], local.path())).unwrap(); + assert!( + attrs.contains("vendor/mylib/extra.txt"), + "the newly vendored path must be tracked in .gitattributes, but was:\n{attrs}", + ); +} diff --git a/crates/git-vendor/tests/fetch_vendor/table.rs b/crates/git-vendor/tests/fetch_vendor/table.rs index 14d6f3f..623294e 100644 --- a/crates/git-vendor/tests/fetch_vendor/table.rs +++ b/crates/git-vendor/tests/fetch_vendor/table.rs @@ -111,8 +111,9 @@ fn fetch_force_updates_on_upstream_rewrite() { ); } -/// Fetching an annotated tag stores the tag object at `refs/vendor/` but -/// returns the tag's ultimate (peeled) target, per the documented contract. +/// Fetching an annotated tag returns the tag's ultimate (peeled) target and +/// stores that same peeled commit at `refs/vendor/` — never the tag +/// object itself, per the documented contract. #[test] fn fetch_peels_annotated_tag() { let upstream = tempfile::tempdir().unwrap(); @@ -132,8 +133,35 @@ fn fetch_peels_annotated_tag() { let reference = repo.find_reference(&entry.vendor_ref()).expect("find ref"); assert_eq!( reference.id().detach(), - tag_obj, - "stored ref must point at the tag object itself", + commit, + "stored ref must point directly at the peeled commit, not the tag object", + ); +} + +/// Fetching by the annotated tag's own object SHA (`Source::ObjectId`, as +/// opposed to fetching it by name) must still return the peeled commit, not +/// the raw tag object. +#[test] +fn fetch_by_oid_peels_annotated_tag() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + let commit = make_upstream(upstream.path()); + git(&["tag", "-a", "v1", "-m", "release one"], upstream.path()); + let tag_obj = rev_parse(upstream.path(), "v1"); + assert_ne!(tag_obj, commit, "annotated tag must be its own object"); + + let repo = make_local(local.path()); + let entry = make_entry( + upstream.path().to_str().unwrap(), + Some(&tag_obj.to_string()), + vec![], + ); + + let got = repo.fetch_vendor(&entry).expect("fetch_vendor"); + assert_eq!( + got, commit, + "returned id must be the peeled commit, not the tag object fetched by SHA" ); } @@ -254,11 +282,7 @@ fn fetch_errors_when_ref_missing() { /// and is unaffected; this test pins the non-bare case so the regression is /// hard to reintroduce once fixed. /// -/// Marked `#[should_panic]` so CI passes while the upstream gix bug stands; -/// when the bug is fixed this flips to a regular failure and the attribute -/// is removed. #[test] -#[should_panic] fn fetch_returns_upstream_tip_into_non_bare_local() { let upstream = tempfile::tempdir().unwrap(); let local = tempfile::tempdir().unwrap(); @@ -323,4 +347,17 @@ fn fetch_returns_upstream_tip_into_non_bare_local() { "fetched tree lacks the upstream-only `up/marker.txt`: upstream \ objects were not actually brought into the local odb", ); + + // 3. `vendor_tip` (and by extension `vendor_status`) must read the same + // corrected value back from `refs/vendor/`, not the + // gix#2613-corrupted symref pointing at the local branch. + let tip = repo + .vendor_tip(&entry) + .expect("vendor_tip") + .expect("some tip"); + assert_eq!( + tip, upstream_head, + "vendor_tip must not resolve the gix#2613-corrupted symref to the \ + local branch", + ); } diff --git a/crates/git-vendor/tests/prepare_merge.rs b/crates/git-vendor/tests/prepare_merge.rs new file mode 100644 index 0000000..b83dc26 --- /dev/null +++ b/crates/git-vendor/tests/prepare_merge.rs @@ -0,0 +1,4 @@ +mod support; +mod prepare_merge { + mod table; +} diff --git a/crates/git-vendor/tests/prepare_merge/table.rs b/crates/git-vendor/tests/prepare_merge/table.rs new file mode 100644 index 0000000..864c7c8 --- /dev/null +++ b/crates/git-vendor/tests/prepare_merge/table.rs @@ -0,0 +1,134 @@ +//! Tests for `prepare_merge`. +//! +//! `prepare_merge` writes the git-dir files that put the working copy into +//! merge state so the user can run `git commit` to seal the vendor merge. +//! In squash mode it writes `SQUASH_MSG`; in merge mode it writes `MERGE_HEAD` +//! and `MERGE_MSG`. + +use git_vendor::{ + PatternMapping, VendorEntry, VendorMerge, VendorMode, VendorName, VendorWorktree as _, +}; + +use crate::support::{git, init, write}; + +fn null_oid() -> gix::ObjectId { + gix::ObjectId::from_hex(b"0000000000000000000000000000000000000000").unwrap() +} + +fn make_merge() -> VendorMerge { + VendorMerge { + upstream_commit: null_oid(), + ancestor_tree: None, + result_tree: null_oid(), + conflicts: vec![], + } +} + +fn entry(mode: VendorMode) -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: vec![PatternMapping { + glob: "up/**".to_owned(), + destination: Some("vendor/".to_owned()), + }], + mode, + } +} + +struct Built { + _dir: tempfile::TempDir, + repo: gix::Repository, +} + +fn build_repo() -> Built { + let dir = tempfile::tempdir().unwrap(); + init(dir.path()); + write(dir.path(), "README", b"hello\n"); + git(&["add", "-A"], dir.path()); + git(&["commit", "-m", "initial"], dir.path()); + let repo = gix::open(dir.path()).expect("gix open"); + Built { _dir: dir, repo } +} + +/// Merge mode writes `MERGE_HEAD` containing the upstream commit OID followed +/// by a newline, and `MERGE_MSG` containing the message. +#[test] +fn merge_mode_writes_merge_head_and_merge_msg() { + let b = build_repo(); + let merge = make_merge(); + let message = "vendor: update mylib\n"; + + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, message) + .expect("prepare_merge"); + + let git_dir = b.repo.git_dir(); + + let merge_head = std::fs::read_to_string(git_dir.join("MERGE_HEAD")).unwrap(); + assert_eq!( + merge_head, + format!("{}\n", null_oid()), + "MERGE_HEAD must be \\n", + ); + + let merge_msg = std::fs::read_to_string(git_dir.join("MERGE_MSG")).unwrap(); + assert_eq!( + merge_msg, message, + "MERGE_MSG must equal the supplied message" + ); + + assert!( + !git_dir.join("SQUASH_MSG").exists(), + "SQUASH_MSG must not be written in merge mode", + ); +} + +/// Squash mode writes `SQUASH_MSG` containing the message and does not write +/// `MERGE_HEAD` or `MERGE_MSG`. +#[test] +fn squash_mode_writes_squash_msg() { + let b = build_repo(); + let merge = make_merge(); + let message = "vendor: add mylib (squash)\n"; + + b.repo + .prepare_merge(&entry(VendorMode::Squash), &merge, message) + .expect("prepare_merge"); + + let git_dir = b.repo.git_dir(); + + let squash_msg = std::fs::read_to_string(git_dir.join("SQUASH_MSG")).unwrap(); + assert_eq!( + squash_msg, message, + "SQUASH_MSG must equal the supplied message", + ); + + assert!( + !git_dir.join("MERGE_HEAD").exists(), + "MERGE_HEAD must not be written in squash mode", + ); + assert!( + !git_dir.join("MERGE_MSG").exists(), + "MERGE_MSG must not be written in squash mode", + ); +} + +/// Calling `prepare_merge` a second time overwrites the previous state files. +#[test] +fn second_call_overwrites_previous_files() { + let b = build_repo(); + let merge = make_merge(); + + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, "first\n") + .expect("first call"); + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, "second\n") + .expect("second call"); + + let merge_msg = std::fs::read_to_string(b.repo.git_dir().join("MERGE_MSG")).unwrap(); + assert_eq!(merge_msg, "second\n"); +} diff --git a/crates/git-vendor/tests/support/mod.rs b/crates/git-vendor/tests/support/mod.rs index f052956..c52025f 100644 --- a/crates/git-vendor/tests/support/mod.rs +++ b/crates/git-vendor/tests/support/mod.rs @@ -45,6 +45,19 @@ pub fn git_capture(args: &[&str], dir: &Path) -> Vec { output.stdout } +/// Run the compiled `git-vendor` binary in `dir` with isolated config and +/// return its captured [`Output`](std::process::Output) without asserting +/// success, so tests can inspect both exit status and streams. +pub fn vendor(args: &[&str], dir: &Path) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_git-vendor")) + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git-vendor") +} + /// `git rev-parse ` in `dir`, return the resolved OID. pub fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId { let out = git_capture(&["rev-parse", rev], dir); diff --git a/crates/git-vendor/tests/track_vendor/table.rs b/crates/git-vendor/tests/track_vendor/table.rs index ce648dc..252ce2f 100644 --- a/crates/git-vendor/tests/track_vendor/table.rs +++ b/crates/git-vendor/tests/track_vendor/table.rs @@ -133,6 +133,20 @@ fn preserves_unrelated_lines() { ); } +/// A path containing a space is rejected with `Error::InvalidPath`. +#[test] +fn path_with_space_returns_invalid_path_error() { + let b = build_without_attributes(); + let err = b + .repo + .track_vendor(&entry(), &[b"vendor/a b.txt".as_bstr()]) + .unwrap_err(); + assert!( + matches!(err, git_vendor::Error::InvalidPath(_)), + "expected InvalidPath, got {err:?}", + ); +} + /// A bare repo returns `Error::NoWorkdir`. #[test] fn bare_repo_returns_no_workdir_error() { @@ -144,3 +158,44 @@ fn bare_repo_returns_no_workdir_error() { .unwrap_err(); assert!(matches!(err, git_vendor::Error::NoWorkdir), "{err:?}"); } + +/// A path containing a glob metacharacter (`*`) is written with it escaped, +/// so the pattern matches only that literal path rather than becoming a live +/// glob. Regression: `track_vendor` wrote the raw path bytes verbatim, so a +/// file named `a*.txt` produced the pattern `a*.txt`, matching unrelated +/// siblings too. +#[test] +fn glob_metacharacter_is_escaped_in_written_pattern() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("track_vendor"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + assert_eq!(content, "vendor/a\\*.txt vendor=mylib\n"); +} + +/// Tracking the same glob-metacharacter path twice does not duplicate the +/// line: dedup must compare against the *unescaped* path, not the raw +/// written pattern. +#[test] +fn glob_metacharacter_path_dedup_survives_escaping() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("first call"); + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("second call"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + let count = content + .lines() + .filter(|l| *l == "vendor/a\\*.txt vendor=mylib") + .count(); + assert_eq!(count, 1, "line must appear exactly once: {content:?}"); +} diff --git a/crates/git-vendor/tests/vendor_paths/table.rs b/crates/git-vendor/tests/vendor_paths/table.rs index 5b120e0..1185c13 100644 --- a/crates/git-vendor/tests/vendor_paths/table.rs +++ b/crates/git-vendor/tests/vendor_paths/table.rs @@ -10,8 +10,10 @@ use std::collections::BTreeSet; use std::path::Path; -use git_vendor::{PatternMapping, VendorEntry, VendorMode, VendorName, VendorRepository as _}; -use gix::bstr::BString; +use git_vendor::{ + PatternMapping, VendorEntry, VendorMode, VendorName, VendorRepository as _, VendorWorktree as _, +}; +use gix::bstr::{BString, ByteSlice as _}; use rstest::rstest; use crate::support::{commit, git, init, write}; @@ -747,3 +749,35 @@ fn empty_tree_selects_nothing() { .is_empty() ); } + +// ── Glob-metacharacter paths ──────────────────────────────────────────────── + +/// A tracked path containing a glob metacharacter (`*`) must resolve to only +/// itself, not to unrelated siblings the raw (unescaped) glob would also +/// match. Regression: `track_vendor` wrote the raw path bytes verbatim as the +/// `.gitattributes` pattern, so a file literally named `a*` produced the live +/// glob `vendor/mylib/a*`, matching any sibling starting with `a`. +#[test] +fn glob_metacharacter_path_does_not_leak_to_siblings() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + init(p); + write(p, "vendor/mylib/a*", b"literal"); + write(p, "vendor/mylib/aXtxt", b"sibling"); + git(&["add", "-A"], p); + git(&["commit", "-m", "initial"], p); + let repo = gix::open(p).expect("gix open"); + + repo.track_vendor(&entry("mylib", vec![]), &[b"vendor/mylib/a*".as_bstr()]) + .expect("track_vendor"); + git(&["add", "-A"], p); + git(&["commit", "-m", "track"], p); + + let ours = repo.head_commit().expect("head commit").id().detach(); + assert_eq!( + repo.vendor_paths(&entry("mylib", vec![]), ours) + .expect("vendor_paths"), + &[BString::from("vendor/mylib/a*")], + "only the literal path must be selected, not the unrelated sibling", + ); +}