diff --git a/.changepacks/changepack_log_UqRbOiM392brEM3PxJZAD.json b/.changepacks/changepack_log_UqRbOiM392brEM3PxJZAD.json new file mode 100644 index 0000000..f3c3ef7 --- /dev/null +++ b/.changepacks/changepack_log_UqRbOiM392brEM3PxJZAD.json @@ -0,0 +1 @@ +{"changes":{"crates/core/Cargo.toml":"Minor","crates/docker/Cargo.toml":"Minor","crates/github/Cargo.toml":"Minor","crates/cli/Cargo.toml":"Minor","crates/rust/Cargo.toml":"Minor","crates/node/Cargo.toml":"Minor","crates/python/Cargo.toml":"Minor","bridge/node/package.json":"Minor","bridge/python/pyproject.toml":"Minor"},"note":"Support container image updates","date":"2026-08-21T13:49:17.731483400Z"} diff --git a/.changepacks/changepack_log_zErkhS3DltTOtQertZFtX.json b/.changepacks/changepack_log_zErkhS3DltTOtQertZFtX.json new file mode 100644 index 0000000..ba557d2 --- /dev/null +++ b/.changepacks/changepack_log_zErkhS3DltTOtQertZFtX.json @@ -0,0 +1 @@ +{"changes":{"crates/core/Cargo.toml":"Patch","crates/rust/Cargo.toml":"Patch","bridge/node/package.json":"Patch","crates/node/Cargo.toml":"Patch","crates/python/Cargo.toml":"Patch","crates/cli/Cargo.toml":"Patch","bridge/python/pyproject.toml":"Patch","crates/github/Cargo.toml":"Patch"},"note":"Fix local version issue","date":"2026-06-25T14:48:49.415649700Z"} \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index be472e5..7287e22 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,16 +35,16 @@ jobs: - windows-latest - macos-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v8.2.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: false - name: Install maturin @@ -72,7 +72,7 @@ jobs: image: xd009642/tarpaulin:develop-nightly options: --security-opt seccomp=unconfined steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Install uses: dtolnay/rust-toolchain@stable @@ -96,7 +96,7 @@ jobs: cargo fmt cargo tarpaulin --out Lcov Stdout --engine llvm --fail-under 100 - name: Upload to codecov.io - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true @@ -114,7 +114,7 @@ jobs: - test - coverage-test steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: changepacks/action@main id: changepacks with: @@ -156,9 +156,9 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "10.13" CARGO_INCREMENTAL: "1" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 22 - uses: oven-sh/setup-bun@v2 @@ -262,7 +262,7 @@ jobs: target: aarch64 os: macos steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Build wheels uses: PyO3/maturin-action@main with: @@ -293,7 +293,7 @@ jobs: - node-build if: ${{ contains(needs.changepacks.outputs.changepacks, 'bridge/node/package.json') }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -394,7 +394,7 @@ jobs: binary_name: dependency-check-updates-darwin-arm64 dcu_binary_name: dcu-darwin-arm64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/check-deps.yml b/.github/workflows/check-deps.yml index 1f583a7..c49605d 100644 --- a/.github/workflows/check-deps.yml +++ b/.github/workflows/check-deps.yml @@ -28,7 +28,7 @@ jobs: name: Check (dcu -d -e 2) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable diff --git a/Cargo.lock b/Cargo.lock index b769717..93ef856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,13 +19,28 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -76,12 +91,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -98,6 +107,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "backtrace" version = "0.3.76" @@ -130,18 +145,18 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", - "serde", + "serde_core", ] [[package]] @@ -158,15 +173,21 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.63" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex", @@ -178,11 +199,38 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -190,9 +238,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -202,14 +250,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -234,6 +282,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -269,11 +329,46 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -281,24 +376,30 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "ctor" -version = "1.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "deadpool" @@ -320,16 +421,17 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "dependency-check-updates" -version = "0.1.14" +version = "0.1.15" dependencies = [ "clap", "dependency-check-updates-core", + "dependency-check-updates-docker", "dependency-check-updates-github", "dependency-check-updates-node", "dependency-check-updates-python", "dependency-check-updates-rust", "futures", - "miette", + "indicatif", "owo-colors", "rstest", "rustls", @@ -343,8 +445,10 @@ dependencies = [ [[package]] name = "dependency-check-updates-core" -version = "0.1.14" +version = "0.1.15" dependencies = [ + "criterion", + "futures", "ignore", "miette", "node-semver", @@ -352,19 +456,35 @@ dependencies = [ "reqwest", "rstest", "semver", + "tempfile", + "thiserror 2.0.20", + "toml_edit", + "tracing", +] + +[[package]] +name = "dependency-check-updates-docker" +version = "0.1.15" +dependencies = [ + "dependency-check-updates-core", + "futures", + "node-semver", + "reqwest", + "rstest", + "rustls", "serde", "serde_json", - "tempfile", - "thiserror 2.0.18", "tokio", "tracing", + "wiremock", ] [[package]] name = "dependency-check-updates-github" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dependency-check-updates-core", + "dependency-check-updates-docker", "futures", "node-semver", "reqwest", @@ -372,7 +492,6 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", "tokio", "tracing", "wiremock", @@ -386,13 +505,13 @@ dependencies = [ "napi", "napi-build", "napi-derive", - "tokio", ] [[package]] name = "dependency-check-updates-node" -version = "0.1.14" +version = "0.1.15" dependencies = [ + "criterion", "dependency-check-updates-core", "node-semver", "reqwest", @@ -400,7 +519,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -409,7 +528,7 @@ dependencies = [ [[package]] name = "dependency-check-updates-python" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dependency-check-updates-core", "pep440_rs", @@ -418,7 +537,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "toml_edit", "tracing", @@ -436,7 +555,7 @@ dependencies = [ [[package]] name = "dependency-check-updates-rust" -version = "0.1.14" +version = "0.1.15" dependencies = [ "dependency-check-updates-core", "reqwest", @@ -445,7 +564,8 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "tempfile", + "thiserror 2.0.20", "tokio", "toml_edit", "tracing", @@ -455,15 +575,27 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -491,15 +623,15 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fnv" @@ -507,12 +639,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -524,9 +650,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -539,9 +665,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -549,15 +675,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -566,32 +692,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -601,9 +727,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -629,15 +755,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", ] [[package]] @@ -648,15 +772,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -667,9 +791,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -685,12 +809,14 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "foldhash", + "cfg-if", + "crunchy", + "zerocopy", ] [[package]] @@ -713,9 +839,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "http" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -723,9 +849,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -733,9 +859,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -758,9 +884,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -899,12 +1025,6 @@ dependencies = [ "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" @@ -928,9 +1048,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" dependencies = [ "crossbeam-deque", "globset", @@ -949,16 +1069,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_ci" @@ -972,6 +1103,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -990,7 +1130,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -1005,7 +1145,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -1024,18 +1164,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1045,17 +1184,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -1075,15 +1208,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchers" @@ -1096,9 +1229,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "miette" @@ -1127,7 +1260,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1153,9 +1286,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1164,13 +1297,14 @@ dependencies = [ [[package]] name = "napi" -version = "3.9.0" +version = "3.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2" +checksum = "459197f1592f4c3dbbf9c1b13f5a4599a343e4ef66b96bc340e2a518b36a6662" dependencies = [ "bitflags", "ctor", "futures", + "libc", "napi-build", "napi-sys", "nohash-hasher", @@ -1180,42 +1314,42 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.3.2" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" +checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab" [[package]] name = "napi-derive" -version = "3.5.6" +version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42" +checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af" dependencies = [ "convert_case", "ctor", "napi-derive-backend", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "napi-derive-backend" -version = "5.0.4" +version = "6.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf" +checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae" dependencies = [ "convert_case", "proc-macro2", "quote", "semver", - "syn", + "syn 2.0.119", ] [[package]] name = "napi-sys" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" dependencies = [ "libloading", ] @@ -1258,6 +1392,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1289,6 +1432,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1301,6 +1450,16 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pep440_rs" version = "0.7.3" @@ -1326,22 +1485,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "potential_utf" -version = "0.1.5" +name = "plotters" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ - "zerovec", + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "plotters-backend" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ - "proc-macro2", - "syn", + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", ] [[package]] @@ -1355,18 +1538,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1377,11 +1560,31 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1391,9 +1594,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1402,9 +1605,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -1490,21 +1693,21 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.119", "unicode-ident", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1530,9 +1733,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -1557,9 +1760,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -1593,9 +1796,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -1604,9 +1807,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1657,9 +1860,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1667,29 +1870,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -1716,9 +1919,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -1738,15 +1941,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1793,9 +1996,20 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1819,7 +2033,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1850,7 +2064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1887,11 +2101,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -1902,44 +2116,54 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1952,13 +2176,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1973,13 +2197,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -1995,9 +2220,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", @@ -2008,18 +2233,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -2085,7 +2310,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2164,10 +2389,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unit-prefix" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "unscanny" @@ -2236,29 +2461,11 @@ 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.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen 0.46.0", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2269,9 +2476,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -2279,9 +2486,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2289,78 +2496,70 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "web-sys" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ - "leb128fmt", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "rustls-pki-types", ] [[package]] -name = "web-sys" -version = "0.3.99" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "js-sys", - "wasm-bindgen", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "webpki-root-certs" -version = "1.0.7" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" @@ -2371,6 +2570,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2490,9 +2695,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -2521,126 +2726,52 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "writeable" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "anyhow", - "prettyplease", "proc-macro2", "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "syn 2.0.119", + "synstructure", ] [[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" +name = "zerocopy" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "zerocopy-derive", ] [[package]] -name = "yoke-derive" -version = "0.8.2" +name = "zerocopy-derive" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.119", ] [[package]] @@ -2660,21 +2791,21 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2683,9 +2814,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2694,17 +2825,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 7647126..0f024d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/*", "bridge/*"] # parallel linker processes to race on the same output file (LNK1104). # `members` still contains bridge/* so workspace-wide commands # (`cargo test --workspace`, `cargo clippy --workspace`) keep covering it. -default-members = ["crates/cli", "crates/core", "crates/node", "crates/rust", "crates/python", "crates/github", "bridge/node"] +default-members = ["crates/cli", "crates/core", "crates/node", "crates/rust", "crates/python", "crates/github", "crates/docker", "bridge/node"] [workspace.package] edition = "2024" @@ -29,6 +29,7 @@ dependency-check-updates-node = { path = "crates/node", version = "0.1.15" } dependency-check-updates-rust = { path = "crates/rust", version = "0.1.15" } dependency-check-updates-python = { path = "crates/python", version = "0.1.15" } dependency-check-updates-github = { path = "crates/github", version = "0.1.15" } +dependency-check-updates-docker = { path = "crates/docker", version = "0.1.15" } dependency-check-updates = { path = "crates/cli", version = "0.1.15" } # External @@ -37,7 +38,7 @@ thiserror = "2.0" miette = { version = "7.6", features = ["fancy"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } -tokio = { version = "1.52", features = ["rt", "macros"] } +tokio = { version = "1.53", features = ["rt", "macros"] } reqwest = { version = "0.13", default-features = false, features = [ "json", "rustls-no-provider", @@ -52,18 +53,22 @@ rustls = { version = "0.23", default-features = false, features = [ "tls12", ] } node-semver = "2.2" +semver = "1.0" +pep440_rs = "0.7.3" ignore = "0.4" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } toml_edit = "0.25" +indicatif = "0.18" -napi = { version = "3.9", features = ["tokio_rt"] } -napi-derive = "3.5" +napi = { version = "3.12", features = ["tokio_rt"] } +napi-derive = "3.6" futures = "0.3" owo-colors = "4.3" tempfile = "3.27" wiremock = "0.6" rstest = "0.26" +criterion = "0.8" [workspace.lints.rust] unsafe_code = "warn" diff --git a/README.md b/README.md index c10500c..5382f51 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ Checking .github/workflows/CI.yml actions/checkout v4 -> v5 actions/setup-node v4 -> v5 +Checking Dockerfile + node 20-alpine -> 22-alpine + Run dcu -u to upgrade ``` @@ -63,8 +66,8 @@ All four accept the same flags described in [Usage](#usage). ## Features -- **Multi-ecosystem** — `package.json`, `Cargo.toml`, `pyproject.toml`, and `.github/workflows/*.yml` all handled by a single binary -- **Format-preserving** — surgical byte-range patching for JSON / YAML; `toml_edit` for TOML. Your indentation, comments, trailing newlines, and key ordering stay intact +- **Multi-ecosystem** — `package.json`, `Cargo.toml`, `pyproject.toml`, `.github/workflows/*.yml`, `Dockerfile`, and `compose.yaml` all handled by a single binary +- **Format-preserving** — surgical byte-range patching for JSON / YAML / Dockerfiles; `toml_edit` for TOML. Your indentation, comments, trailing newlines, and key ordering stay intact - **Fast** — concurrent registry lookups across all manifests via `futures::join_all` - **Smart range checking** — skips false positives where the resolved version already satisfies the current range (`^3` already covers `3.5.1`) - **Deep scan** — `-d` recursively finds manifests in monorepos, respecting `.gitignore` @@ -80,6 +83,7 @@ All four accept the same flags described in [Usage](#usage). | Rust | `Cargo.toml` | [crates.io](https://crates.io/) | [`dependency-check-updates`](https://crates.io/crates/dependency-check-updates) | | Python | `pyproject.toml` | [PyPI](https://pypi.org/) | [`dependency-check-updates`](https://pypi.org/project/dependency-check-updates/) | | GitHub Actions | `.github/workflows/*.yml`, `action.yml` | [GitHub Tags API](https://docs.github.com/rest/repos/repos#list-repository-tags) | *(built-in)* | +| Containers | `Dockerfile`, `compose.yaml` | [OCI Distribution](https://distribution.github.io/distribution/spec/api/) (Docker Hub, ghcr.io, quay.io, …) | *(built-in)* | ### GitHub Actions specifics @@ -90,6 +94,37 @@ All four accept the same flags described in [Usage](#usage). - **Rate limit**: unauthenticated runs use GitHub's 60 req/hr ceiling. Hitting it produces an explicit error pointing to the fix — set `GITHUB_TOKEN` (or `GH_TOKEN`) in your environment to raise the limit to 5 000 req/hr. - Tag fetch is bounded to the **first 100 tags** per action (newest-first). This comfortably covers every mainstream action; deliberately not paginating keeps API consumption predictable so deep scans don't spike into the rate-limit ceiling. +### Container image specifics + +Scans Dockerfile `FROM` instructions and the `image:` key of Compose services. Workflow job `container:` / `services:` images are picked up too — a single `.github/workflows/CI.yml` can have its `uses:` refs resolved against GitHub and its `image:` pins against a container registry in the same run. + +**Build variants are never crossed.** A container tag is a version *plus* a variant, and bumping `node:20-alpine` to `node:22` would silently swap Alpine for Debian. Candidate tags are grouped by the verbatim suffix after the leading numeric run, and only tags in the same group are ever considered: + +``` +node:20-alpine → node:22-alpine (not node:22) +python:3.12-slim → python:3.13-slim +postgres:16.0 → postgres:16.15 (-t minor) +``` + +Your pin precision is preserved as long as a real tag backs it: `node:20` becomes `node:22`, not `node:22.3.0`. If the registry never published the shorter form, the tag is escalated to the shortest one that actually exists, so the emitted tag always pulls. + +**Registries.** Any OCI Distribution registry works from the same code path — Docker Hub, `ghcr.io`, `quay.io`, `mcr.microsoft.com`, `public.ecr.aws`, or a self-hosted `localhost:5000` (plain HTTP for `localhost` / `127.0.0.1`, HTTPS otherwise). Public images authenticate through the registry's anonymous Bearer-token exchange automatically; private repositories are reported as an error rather than guessed at. + +**Left untouched on purpose** — each of these means you opted out of tag tracking: + +| Pin | Why it is skipped | +|---|---| +| `FROM node` · `image: redis` | No tag: an implicit `latest`, a moving target | +| `:latest` · `:bookworm` · `:stable` | Not a version | +| `node:20@sha256:…` | The digest decides what is pulled; moving the tag alone changes nothing | +| `node:${NODE_VERSION}` · `app:${TAG}` | The real value lives in a build arg or `.env` | +| `FROM builder` | A multi-stage build stage, not an image | +| `app:1a2b3c4` | A build hash, same heuristic that skips commit SHAs in workflows | + +Discovery covers `Dockerfile`, `Dockerfile.`, `.Dockerfile`, `compose.y(a)ml`, and `docker-compose.y(a)ml` including profile variants (`docker-compose.override.yml`). Plain `dcu` probes the canonical names at the root; `-d` finds the rest anywhere in the tree. + +`-t newest` falls back to `greatest`: the OCI tag list carries no publish dates, and recovering them would cost one manifest fetch per tag. + ## Installation Every distribution below ships the exact same binary. Pick whichever matches your toolchain. @@ -149,7 +184,7 @@ pipx run dependency-check-updates [flags] ## Usage -Run from a directory containing at least one of `package.json`, `Cargo.toml`, `pyproject.toml`, or `.github/workflows/*.yml`. Every supported manifest in the current directory is auto-detected. +Run from a directory containing at least one of `package.json`, `Cargo.toml`, `pyproject.toml`, `.github/workflows/*.yml`, `Dockerfile`, or `compose.yaml`. Every supported manifest in the current directory is auto-detected. All examples below use the short `dcu` alias. The long form `dependency-check-updates` works identically. @@ -223,6 +258,8 @@ dcu actions # only actions/checkout, actions/setup-node, … dcu --manifest path/to/Cargo.toml dcu --manifest apps/web/package.json dcu --manifest .github/workflows/CI.yml +dcu --manifest services/api/Dockerfile +dcu --manifest docker-compose.override.yml # Machine-readable output for scripting/CI dcu --format json @@ -250,7 +287,8 @@ dcu -u --remove-installed # installed only, keep lockfile # package.json → bun.lock, bun.lockb, package-lock.json, pnpm-lock.yaml, # yarn.lock, node_modules/ # Cargo.toml → Cargo.lock, target/ -# pyproject.toml → uv.lock, poetry.lock, Pipfile.lock, .venv/, venv/ +# pyproject.toml → uv.lock, poetry.lock, Pipfile.lock, .venv/, venv/, +# __pypackages__/, .tox/, .nox/ # GitHub Actions: pin a higher rate limit by exporting a token GITHUB_TOKEN=ghp_xxx dcu -d -u @@ -284,7 +322,8 @@ Follows the [changepacks](https://github.com/changepacks/changepacks) pattern │ ├── node/ # Node.js: package.json parser + npm registry │ ├── rust/ # Rust: Cargo.toml parser (toml_edit) + crates.io │ ├── python/ # Python: pyproject.toml parser (toml_edit) + PyPI -│ └── github/ # GitHub Actions: workflow YAML parser + GitHub Tags API +│ ├── github/ # GitHub Actions: workflow YAML parser + GitHub Tags API +│ └── docker/ # Containers: Dockerfile / Compose scanners + OCI registry ├── bridge/ │ ├── node/ # napi-rs N-API binding → npm: @dependency-check-updates/cli │ └── python/ # maturin bin binding → PyPI: dependency-check-updates @@ -296,7 +335,8 @@ Follows the [changepacks](https://github.com/changepacks/changepacks) pattern - **JSON** (`package.json`): Surgical byte-range replacement — finds exact byte offsets of version values and replaces only those bytes. Indent, line endings, trailing newline, and key ordering are preserved byte-for-byte. - **TOML** (`Cargo.toml`, `pyproject.toml`): `toml_edit` document model preserves comments, table ordering, inline-table formatting, and whitespace. -- **YAML** (`.github/workflows/*.yml`, `action.yml`): Line-based `uses:` scanning with byte-range replacement of only the `@ref` portion. Anchors, comments, blank lines, and unrelated `@main` / `@` pins are never touched. +- **YAML** (`.github/workflows/*.yml`, `action.yml`, `compose.yaml`): Line-based `uses:` / `image:` scanning with byte-range replacement of only the `@ref` or `:tag` portion. Anchors, comments, blank lines, quoting style, and unrelated `@main` / `:latest` / digest pins are never touched. +- **Dockerfile**: Line-based `FROM` scanning with byte-range replacement of only the tag. `--platform` flags, `AS ` tails, and the `# syntax=` directive survive byte-for-byte. ### Shared Traits diff --git a/bridge/node/Cargo.toml b/bridge/node/Cargo.toml index 1f2ddaf..ebaac65 100644 --- a/bridge/node/Cargo.toml +++ b/bridge/node/Cargo.toml @@ -16,10 +16,9 @@ crate-type = ["cdylib"] napi.workspace = true napi-derive.workspace = true dependency-check-updates.workspace = true -tokio = { version = "1.52" } [build-dependencies] -napi-build = "2.3" +napi-build = "2.4" [lints] workspace = true diff --git a/bridge/node/package.json b/bridge/node/package.json index 37e2d7f..27dce55 100644 --- a/bridge/node/package.json +++ b/bridge/node/package.json @@ -58,9 +58,9 @@ "build:debug": "napi build --platform", "lint": "echo 'lint'", "prepublishOnly": "napi prepublish -t npm", - "test": "echo 'test'" + "test": "bun run build:debug && bun test" }, "devDependencies": { - "@napi-rs/cli": "^3.7" + "@napi-rs/cli": "^3.8" } } diff --git a/bridge/node/test/main.test.js b/bridge/node/test/main.test.js new file mode 100644 index 0000000..db1fbea --- /dev/null +++ b/bridge/node/test/main.test.js @@ -0,0 +1,49 @@ +const { describe, expect, test } = require("bun:test"); +const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const repoPackageDir = join(__dirname, ".."); +const mainJs = join(repoPackageDir, "main.js"); + +describe("node CLI bridge", () => { + test("updates PEP 735 dependency-groups in pyproject.toml", () => { + const root = mkdtempSync(join(tmpdir(), "dcu-node-pyproject-")); + try { + const projectDir = join(root, "py-test"); + require("node:fs").mkdirSync(projectDir); + const pyprojectPath = join(projectDir, "pyproject.toml"); + + writeFileSync( + pyprojectPath, + `[project] +name = "braillify-test" +version = "0.1.0" +description = "" +authors = [{ name = "owjs3901", email = "owjs3901@gmail.com" }] +readme = "README.md" +requires-python = ">=3.13" +dependencies = ["braillify"] + +[tool.uv.sources] +braillify = { workspace = true } + +[dependency-groups] +dev = ["pytest>=9.0.3"] +`, + ); + + const result = spawnSync("node", [mainJs, "-d", "-u", "--rm"], { + cwd: root, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("pytest"); + expect(readFileSync(pyprojectPath, "utf8")).toContain('dev = ["pytest>=9.1.1"]'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/bridge/python/src/dcu.rs b/bridge/python/src/dcu.rs index 1b4fa67..f216419 100644 --- a/bridge/python/src/dcu.rs +++ b/bridge/python/src/dcu.rs @@ -5,6 +5,6 @@ #[tokio::main(flavor = "current_thread")] #[cfg(not(tarpaulin_include))] -async fn main() -> Result<(), dependency_check_updates::DcuError> { - dependency_check_updates::main(&std::env::args().collect::>()).await +async fn main() -> std::process::ExitCode { + dependency_check_updates::run_cli().await } diff --git a/bridge/python/src/main.rs b/bridge/python/src/main.rs index 80c3b72..66d59a5 100644 --- a/bridge/python/src/main.rs +++ b/bridge/python/src/main.rs @@ -6,6 +6,6 @@ #[tokio::main(flavor = "current_thread")] #[cfg(not(tarpaulin_include))] -async fn main() -> Result<(), dependency_check_updates::DcuError> { - dependency_check_updates::main(&std::env::args().collect::>()).await +async fn main() -> std::process::ExitCode { + dependency_check_updates::run_cli().await } diff --git a/bun.lock b/bun.lock index b56648e..d1eef3a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -10,170 +10,170 @@ }, "bridge/node": { "name": "@dependency-check-updates/cli", - "version": "0.1.13", + "version": "0.1.15", "bin": { "dcu": "./main.js", "dependency-check-updates": "./main.js", }, "devDependencies": { - "@napi-rs/cli": "^3.7", + "@napi-rs/cli": "^3.8", }, }, }, "packages": { "@dependency-check-updates/cli": ["@dependency-check-updates/cli@workspace:bridge/node"], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], + "@inquirer/checkbox": ["@inquirer/checkbox@5.2.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^12.0.0", "@inquirer/figures": "^2.0.8", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Y5/bAScMy5Y+9isCx0SKbyJebMCaXXX5em0kxkj115eZNscgV9srOHrgyfS0e5xAVymIfOh9piYBKDILktsMMg=="], - "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], + "@inquirer/confirm": ["@inquirer/confirm@6.2.0", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-SKXarWrYhtpqOEctf9XGCGy29QjsvJAM0Aq9ZR9z4Ns94OmpqudOly+aSEfNqUf9SwsQaUgY9+Z8hyzG0xX8fw=="], - "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], + "@inquirer/core": ["@inquirer/core@12.0.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.8", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+nnvFEXIB08CZNVXpvW3B+zHW96QXvGUjNKJ8NJIPqAZi5Kd4WhYt2S3C234ReepG1qw2HOlEUbjYVHBowXObA=="], - "@inquirer/editor": ["@inquirer/editor@5.2.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/external-editor": "^3.0.3", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg=="], + "@inquirer/editor": ["@inquirer/editor@5.3.0", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/external-editor": "^3.0.4", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nnsP/IdJ8s83q7ZuObmgn12QM+uLCkab9E0Oordojbn62WUg1c+v9Ou/F/057pgh0ppX0W+Hj5bO/Dp5hsxQtA=="], - "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], + "@inquirer/expand": ["@inquirer/expand@5.1.2", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-OWIH1IyyWqEKIyqC9Xy+Bnga7NkGMovFdo4atYZMUOTRqf6rO2WCv9E/1MyzvOErDBCxs+9UFliRUDc50xs/jw=="], - "@inquirer/external-editor": ["@inquirer/external-editor@3.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA=="], + "@inquirer/external-editor": ["@inquirer/external-editor@3.0.4", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA=="], - "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], + "@inquirer/figures": ["@inquirer/figures@2.0.8", "", {}, "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q=="], - "@inquirer/input": ["@inquirer/input@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg=="], + "@inquirer/input": ["@inquirer/input@5.1.3", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-F/BZHtyEzP+HO+IGVd4AjBRgvX/ywm42bx8S0+dENk2YclzE9tJ3X/15THwtT6ehApmKvdYDMsVTuyyDod0gOQ=="], - "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], + "@inquirer/number": ["@inquirer/number@4.2.0", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ew+fSDijsQ/WhD4TV3XLb+if400cDuzTzHfGR8sTNBXkK9CYDWoGE8fhaO8GbT312pNv1AJEOsDxy/z/HVettA=="], - "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], + "@inquirer/password": ["@inquirer/password@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^12.0.0", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nSdufycW8xynEVssFkNQEYIzTySilog0UlfOVRwh3pXzPSk4frXUT2jZWjHnKae6RU9PaoF9wfy1pGwewQuqGw=="], - "@inquirer/prompts": ["@inquirer/prompts@8.5.2", "", { "dependencies": { "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/editor": "^5.2.2", "@inquirer/expand": "^5.1.1", "@inquirer/input": "^5.1.2", "@inquirer/number": "^4.1.1", "@inquirer/password": "^5.1.1", "@inquirer/rawlist": "^5.3.1", "@inquirer/search": "^4.2.1", "@inquirer/select": "^5.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g=="], + "@inquirer/prompts": ["@inquirer/prompts@8.6.0", "", { "dependencies": { "@inquirer/checkbox": "^5.2.2", "@inquirer/confirm": "^6.2.0", "@inquirer/editor": "^5.3.0", "@inquirer/expand": "^5.1.2", "@inquirer/input": "^5.1.3", "@inquirer/number": "^4.2.0", "@inquirer/password": "^5.1.2", "@inquirer/rawlist": "^5.3.2", "@inquirer/search": "^4.3.0", "@inquirer/select": "^5.2.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-WgBVDRy3IQ4v9XMCpQ1YDGpso2PcMUxYJzZdH4Nt4t0eoXhEPmOCh5iZbXbR4GTbdUB9VPWBbJB12rkjbaGDCw=="], - "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], + "@inquirer/rawlist": ["@inquirer/rawlist@5.3.2", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-oPSKrYK1X1bMkjXDzIKHUkJp195LFSfgbnVtXnjSKGFjrCbS6I+wyvfAZTwKE9BSt3HwWgfD7JfsXALBgCogzQ=="], - "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], + "@inquirer/search": ["@inquirer/search@4.3.0", "", { "dependencies": { "@inquirer/core": "^12.0.0", "@inquirer/figures": "^2.0.8", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-HFxXE5w727ctSUcAwrDquftJGjMgu36OeV5SHEXMlr2j/ahzmRX9xSEeVolV8tzYnTf45cg6vGkdMMRdm3RPhQ=="], - "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], + "@inquirer/select": ["@inquirer/select@5.2.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^12.0.0", "@inquirer/figures": "^2.0.8", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RkI8dRHWt+bh04oLixvF1kFzKC7e5rqJoHKkzcqSHATebBXFC6GmrT8ddbVkgSzLV0HnHs2cPFuBINr8otij8Q=="], "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], - "@napi-rs/cli": ["@napi-rs/cli@3.7.0", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.0.1", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "emnapi": "^1.10.0", "es-toolkit": "^1.41.0", "js-yaml": "^4.1.0", "obug": "^2.0.0", "semver": "^7.7.3", "typanion": "^3.14.0" }, "peerDependencies": { "@emnapi/runtime": "^1.7.1" }, "optionalPeers": ["@emnapi/runtime"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-3d3+rmxlOIV/G1zPWeX4PCxuYnhcCQM2BvY9rtimC8RO0dFR9gtYP+Grov+WoduZtfWRj5N1XvytWeRxxCk5zw=="], + "@napi-rs/cli": ["@napi-rs/cli@3.8.6", "", { "dependencies": { "@inquirer/prompts": "^8.5.2", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.1.0", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "es-toolkit": "^1.47.0", "js-yaml": "^4.2.0", "obug": "^2.1.2", "semver": "^7.8.2", "typanion": "^3.14.0", "typescript": "^6.0.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4", "emnapi": "^1.7.1 || ^2.0.0-alpha.4" }, "optionalPeers": ["@emnapi/core", "@emnapi/runtime", "emnapi"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-FnJ9fghsV9Q4zh2aJGPSvQiUlJRC27B6KhzAXcIW2rlSD8keak3mhXw4tJYa3KJkP9whETfsPwqp/DJRnQg5ng=="], "@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="], - "@napi-rs/lzma": ["@napi-rs/lzma@1.4.5", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.4.5", "@napi-rs/lzma-android-arm64": "1.4.5", "@napi-rs/lzma-darwin-arm64": "1.4.5", "@napi-rs/lzma-darwin-x64": "1.4.5", "@napi-rs/lzma-freebsd-x64": "1.4.5", "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", "@napi-rs/lzma-linux-arm64-musl": "1.4.5", "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-musl": "1.4.5", "@napi-rs/lzma-wasm32-wasi": "1.4.5", "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg=="], + "@napi-rs/lzma": ["@napi-rs/lzma@1.5.1", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.5.1", "@napi-rs/lzma-android-arm64": "1.5.1", "@napi-rs/lzma-darwin-arm64": "1.5.1", "@napi-rs/lzma-darwin-x64": "1.5.1", "@napi-rs/lzma-freebsd-x64": "1.5.1", "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", "@napi-rs/lzma-linux-arm64-musl": "1.5.1", "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@napi-rs/lzma-linux-x64-musl": "1.5.1", "@napi-rs/lzma-wasm32-wasi": "1.5.1", "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", "@napi-rs/lzma-win32-x64-msvc": "1.5.1" } }, "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g=="], - "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.4.5", "", { "os": "android", "cpu": "arm" }, "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q=="], + "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.5.1", "", { "os": "android", "cpu": "arm" }, "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ=="], - "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.4.5", "", { "os": "android", "cpu": "arm64" }, "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ=="], + "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA=="], - "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA=="], + "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g=="], - "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw=="], + "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg=="], - "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.4.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw=="], + "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA=="], - "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.4.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw=="], + "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw=="], - "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg=="], + "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw=="], - "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w=="], + "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ=="], - "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.4.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ=="], + "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.5.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A=="], - "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.4.5", "", { "os": "linux", "cpu": "none" }, "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA=="], + "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.5.1", "", { "os": "linux", "cpu": "none" }, "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ=="], - "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.4.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA=="], + "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.5.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw=="], - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw=="], + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], - "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ=="], + "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg=="], - "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.4.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA=="], + "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.5.1", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA=="], - "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg=="], + "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg=="], - "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg=="], + "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA=="], - "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q=="], + "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA=="], - "@napi-rs/tar": ["@napi-rs/tar@1.1.0", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.0", "@napi-rs/tar-android-arm64": "1.1.0", "@napi-rs/tar-darwin-arm64": "1.1.0", "@napi-rs/tar-darwin-x64": "1.1.0", "@napi-rs/tar-freebsd-x64": "1.1.0", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", "@napi-rs/tar-linux-arm64-gnu": "1.1.0", "@napi-rs/tar-linux-arm64-musl": "1.1.0", "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", "@napi-rs/tar-linux-s390x-gnu": "1.1.0", "@napi-rs/tar-linux-x64-gnu": "1.1.0", "@napi-rs/tar-linux-x64-musl": "1.1.0", "@napi-rs/tar-wasm32-wasi": "1.1.0", "@napi-rs/tar-win32-arm64-msvc": "1.1.0", "@napi-rs/tar-win32-ia32-msvc": "1.1.0", "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ=="], + "@napi-rs/tar": ["@napi-rs/tar@1.1.1", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.1", "@napi-rs/tar-android-arm64": "1.1.1", "@napi-rs/tar-darwin-arm64": "1.1.1", "@napi-rs/tar-darwin-x64": "1.1.1", "@napi-rs/tar-freebsd-x64": "1.1.1", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", "@napi-rs/tar-linux-arm64-gnu": "1.1.1", "@napi-rs/tar-linux-arm64-musl": "1.1.1", "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", "@napi-rs/tar-linux-s390x-gnu": "1.1.1", "@napi-rs/tar-linux-x64-gnu": "1.1.1", "@napi-rs/tar-linux-x64-musl": "1.1.1", "@napi-rs/tar-wasm32-wasi": "1.1.1", "@napi-rs/tar-win32-arm64-msvc": "1.1.1", "@napi-rs/tar-win32-ia32-msvc": "1.1.1", "@napi-rs/tar-win32-x64-msvc": "1.1.1" } }, "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA=="], - "@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA=="], + "@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.1", "", { "os": "android", "cpu": "arm" }, "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA=="], - "@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw=="], + "@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.1", "", { "os": "android", "cpu": "arm64" }, "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ=="], - "@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw=="], + "@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ=="], - "@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA=="], + "@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg=="], - "@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g=="], + "@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw=="], - "@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg=="], + "@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.1", "", { "os": "linux", "cpu": "arm" }, "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA=="], - "@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA=="], + "@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ=="], - "@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ=="], + "@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA=="], - "@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ=="], + "@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q=="], - "@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ=="], + "@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ=="], - "@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww=="], + "@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ=="], - "@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ=="], + "@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA=="], - "@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw=="], + "@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.1", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q=="], - "@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg=="], + "@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA=="], - "@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ=="], + "@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw=="], - "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="], + "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], - "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="], + "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.1.0", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", "@napi-rs/wasm-tools-android-arm64": "1.1.0", "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", "@napi-rs/wasm-tools-darwin-x64": "1.1.0", "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" } }, "sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w=="], - "@napi-rs/wasm-tools-android-arm-eabi": ["@napi-rs/wasm-tools-android-arm-eabi@1.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw=="], + "@napi-rs/wasm-tools-android-arm-eabi": ["@napi-rs/wasm-tools-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA=="], - "@napi-rs/wasm-tools-android-arm64": ["@napi-rs/wasm-tools-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg=="], + "@napi-rs/wasm-tools-android-arm64": ["@napi-rs/wasm-tools-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ=="], - "@napi-rs/wasm-tools-darwin-arm64": ["@napi-rs/wasm-tools-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g=="], + "@napi-rs/wasm-tools-darwin-arm64": ["@napi-rs/wasm-tools-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA=="], - "@napi-rs/wasm-tools-darwin-x64": ["@napi-rs/wasm-tools-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A=="], + "@napi-rs/wasm-tools-darwin-x64": ["@napi-rs/wasm-tools-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A=="], - "@napi-rs/wasm-tools-freebsd-x64": ["@napi-rs/wasm-tools-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg=="], + "@napi-rs/wasm-tools-freebsd-x64": ["@napi-rs/wasm-tools-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg=="], - "@napi-rs/wasm-tools-linux-arm64-gnu": ["@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q=="], + "@napi-rs/wasm-tools-linux-arm64-gnu": ["@napi-rs/wasm-tools-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg=="], - "@napi-rs/wasm-tools-linux-arm64-musl": ["@napi-rs/wasm-tools-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ=="], + "@napi-rs/wasm-tools-linux-arm64-musl": ["@napi-rs/wasm-tools-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw=="], - "@napi-rs/wasm-tools-linux-x64-gnu": ["@napi-rs/wasm-tools-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw=="], + "@napi-rs/wasm-tools-linux-x64-gnu": ["@napi-rs/wasm-tools-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg=="], - "@napi-rs/wasm-tools-linux-x64-musl": ["@napi-rs/wasm-tools-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ=="], + "@napi-rs/wasm-tools-linux-x64-musl": ["@napi-rs/wasm-tools-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA=="], - "@napi-rs/wasm-tools-wasm32-wasi": ["@napi-rs/wasm-tools-wasm32-wasi@1.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA=="], + "@napi-rs/wasm-tools-wasm32-wasi": ["@napi-rs/wasm-tools-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg=="], - "@napi-rs/wasm-tools-win32-arm64-msvc": ["@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ=="], + "@napi-rs/wasm-tools-win32-arm64-msvc": ["@napi-rs/wasm-tools-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA=="], - "@napi-rs/wasm-tools-win32-ia32-msvc": ["@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw=="], + "@napi-rs/wasm-tools-win32-ia32-msvc": ["@napi-rs/wasm-tools-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ=="], - "@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ=="], + "@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ=="], "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], - "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + "@octokit/core": ["@octokit/core@7.0.7", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", "@octokit/request": "^10.0.13", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA=="], - "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="], - "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + "@octokit/graphql": ["@octokit/graphql@9.0.4", "", { "dependencies": { "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg=="], - "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], @@ -181,21 +181,21 @@ "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], - "@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="], + "@octokit/request": ["@octokit/request@10.0.15", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^3.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-3CBg9aJ0hO9Pjyij8LbK/xYtEaPws9SW7xKz67daPNxQB1q5Y9OMA7DDOG0A6Hwf9ygGu3tvzusg0LXQ8/wAjA=="], - "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="], "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], - "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -203,13 +203,11 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "content-type": ["content-type@3.0.0", "", {}, "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "emnapi": ["emnapi@1.10.0", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-swoyZjupDvLoe/KC3HZ4SY1JUN+tviT6eOZ3Px28TZAYdBHtRIiMWWrIUUH+2/9CYY4fNTID1YhYZ+kdFHszHg=="], - - "es-toolkit": ["es-toolkit@1.47.0", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="], + "es-toolkit": ["es-toolkit@1.51.0", "", {}, "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw=="], "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], @@ -219,21 +217,21 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], - "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + "json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], - "obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -241,6 +239,28 @@ "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "@napi-rs/lzma-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@napi-rs/lzma-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@napi-rs/tar-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@napi-rs/tar-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@napi-rs/lzma-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@napi-rs/tar-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..0723dac --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +[test] +coverage = true +coverageSkipTestFiles = true +coverageThreshold = 1 +coverageReporter = ["text", "lcov"] +coveragePathIgnorePatterns = "**/*.js" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 54947a7..55a3b17 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -30,16 +30,17 @@ dependency-check-updates-node.workspace = true dependency-check-updates-rust.workspace = true dependency-check-updates-python.workspace = true dependency-check-updates-github.workspace = true +dependency-check-updates-docker.workspace = true rustls.workspace = true clap.workspace = true serde_json.workspace = true -miette.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true futures.workspace = true owo-colors.workspace = true -semver = "1.0" +semver.workspace = true +indicatif.workspace = true [dev-dependencies] rstest.workspace = true diff --git a/crates/cli/src/bin/dcu.rs b/crates/cli/src/bin/dcu.rs index e0cea2b..6dec65b 100644 --- a/crates/cli/src/bin/dcu.rs +++ b/crates/cli/src/bin/dcu.rs @@ -1,27 +1,14 @@ //! `dcu` short-alias binary. Behaviour is identical to -//! `dependency-check-updates`; both call into the same library entry point. -//! The duplication is a 20-line stub — keeping it as a separate source file -//! avoids the Cargo warning emitted when two `[[bin]]` targets share a path. +//! `dependency-check-updates`; both binaries delegate to +//! `dependency_check_updates::run_cli`. This file exists separately from +//! `src/main.rs` only to avoid Cargo's "file present in multiple build +//! targets" warning when two `[[bin]]` targets would otherwise share a +//! path. use std::process::ExitCode; #[tokio::main(flavor = "current_thread")] #[cfg(not(tarpaulin_include))] async fn main() -> ExitCode { - let cli = dependency_check_updates::parse_args(); - let error_level = cli.error_level; - - match dependency_check_updates::run(&cli).await { - Ok(has_updates) => { - if error_level >= 2 && has_updates { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } - } - Err(e) => { - eprintln!("Error: {e}"); - ExitCode::FAILURE - } - } + dependency_check_updates::run_cli().await } diff --git a/crates/cli/src/cleanup.rs b/crates/cli/src/cleanup.rs index d0dfa65..8376efc 100644 --- a/crates/cli/src/cleanup.rs +++ b/crates/cli/src/cleanup.rs @@ -1,12 +1,5 @@ -use std::path::Path; - -use tracing::warn; - use dependency_check_updates_core::ManifestKind; -use crate::cli::Cli; -use crate::run::ManifestJob; - /// Lockfiles that sit next to a manifest of the given kind. /// /// These are the files `--remove-lockfile` clears. The intent is to force the @@ -26,13 +19,16 @@ pub(crate) fn lockfiles_for(kind: ManifestKind) -> &'static [&'static str] { ], ManifestKind::CargoToml => &["Cargo.lock"], ManifestKind::PyProjectToml => &["uv.lock", "poetry.lock", "Pipfile.lock"], - // Workflow files have no companion lockfile. - ManifestKind::GitHubWorkflow => &[], + // Workflow and container manifests have no companion lockfile: the + // resolved digest lives in the registry, not in the working tree. + ManifestKind::GitHubWorkflow | ManifestKind::Dockerfile | ManifestKind::DockerCompose => { + &[] + } } } -/// Installed-dependency directories that sit next to a manifest of the given -/// kind. +/// Installed-dependency or generated environment directories that sit next to a +/// manifest of the given kind. /// /// `--remove-installed` wipes these so the package manager performs a clean /// install. Without this step, an already-installed copy of a transitive @@ -43,90 +39,14 @@ pub(crate) fn installed_dirs_for(kind: ManifestKind) -> &'static [&'static str] match kind { ManifestKind::PackageJson => &["node_modules"], ManifestKind::CargoToml => &["target"], - ManifestKind::PyProjectToml => &[".venv", "venv"], - ManifestKind::GitHubWorkflow => &[], - } -} - -/// Delete sibling lockfiles and/or installed-dep directories next to a -/// manifest. Missing entries are silently skipped — the goal is idempotency, -/// not strictness. -/// -/// Returns the display names (lockfiles as-is, directories with a trailing -/// `/`) of every entry actually removed, in the order they were processed. -/// The caller uses this list to print a per-manifest summary. -pub(crate) fn cleanup_manifest_siblings( - manifest_path: &Path, - kind: ManifestKind, - remove_lockfile: bool, - remove_installed: bool, -) -> Vec { - let mut removed = Vec::new(); - - if !remove_lockfile && !remove_installed { - return removed; - } - - let Some(dir) = manifest_path.parent() else { - return removed; - }; - - if remove_lockfile { - for name in lockfiles_for(kind) { - let path = dir.join(name); - match std::fs::remove_file(&path) { - Ok(()) => removed.push((*name).to_owned()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - warn!(path = %path.display(), error = %e, "failed to remove lockfile"); - } - } - } - } - - if remove_installed { - for name in installed_dirs_for(kind) { - let path = dir.join(name); - match std::fs::remove_dir_all(&path) { - Ok(()) => removed.push(format!("{name}/")), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - warn!(path = %path.display(), error = %e, "failed to remove installed directory"); - } - } + ManifestKind::PyProjectToml => &[".venv", "venv", "__pypackages__", ".tox", ".nox"], + // Nothing is installed next to a workflow or container manifest. + // Removing an image's local layers is `docker image prune`'s job, and + // wiping it here would silently force a multi-gigabyte re-pull. + ManifestKind::GitHubWorkflow | ManifestKind::Dockerfile | ManifestKind::DockerCompose => { + &[] } } - - removed -} - -/// Render the " Removed \n" lines for a list of deleted siblings. -/// Returns an empty string when nothing was removed so the caller can print -/// it unconditionally without producing a stray blank line. -#[must_use] -pub(crate) fn render_removed(removed: &[String]) -> String { - let mut out = String::new(); - for name in removed { - let _ = std::fmt::Write::write_fmt(&mut out, format_args!(" Removed {name}\n")); - } - out -} - -/// Convenience wrapper: run the sibling cleanup for a job and render the -/// resulting summary string in one call. -/// -/// Reads the effective removal flags via [`Cli::remove_lockfile_requested`] -/// and [`Cli::remove_installed_requested`] so the `--rm` shortcut and the -/// granular flags share one OR-semantics implementation. -#[cfg(not(tarpaulin_include))] -pub(crate) fn cleanup_and_render(job: &ManifestJob, cli: &Cli) -> String { - let removed = cleanup_manifest_siblings( - &job.manifest_ref.path, - job.manifest_ref.kind, - cli.remove_lockfile_requested(), - cli.remove_installed_requested(), - ); - render_removed(&removed) } #[cfg(test)] @@ -148,6 +68,8 @@ mod tests { &["uv.lock", "poetry.lock", "Pipfile.lock"], )] #[case::github_workflow(ManifestKind::GitHubWorkflow, &[])] + #[case::dockerfile(ManifestKind::Dockerfile, &[])] + #[case::docker_compose(ManifestKind::DockerCompose, &[])] fn lockfiles_for_cases(#[case] kind: ManifestKind, #[case] expected: &[&str]) { let got = lockfiles_for(kind); for needle in expected { @@ -164,8 +86,13 @@ mod tests { #[rstest] #[case::package_json(ManifestKind::PackageJson, &["node_modules"])] #[case::cargo_toml(ManifestKind::CargoToml, &["target"])] - #[case::pyproject_toml(ManifestKind::PyProjectToml, &[".venv", "venv"])] + #[case::pyproject_toml( + ManifestKind::PyProjectToml, + &[".venv", "venv", "__pypackages__", ".tox", ".nox"] + )] #[case::github_workflow(ManifestKind::GitHubWorkflow, &[])] + #[case::dockerfile(ManifestKind::Dockerfile, &[])] + #[case::docker_compose(ManifestKind::DockerCompose, &[])] fn installed_dirs_for_cases(#[case] kind: ManifestKind, #[case] expected: &[&str]) { let got = installed_dirs_for(kind); for needle in expected { @@ -178,195 +105,4 @@ mod tests { assert!(got.is_empty(), "{got:?} should be empty for {kind:?}"); } } - - #[rstest] - // (entries handed to `render_removed`, exact expected output) - #[case::empty_returns_empty_string(&[], "")] - #[case::formats_each_entry_on_its_own_line( - &["Cargo.lock", "target/"], - " Removed Cargo.lock\n Removed target/\n", - )] - fn render_removed_cases(#[case] entries: &[&str], #[case] expected: &str) { - let owned: Vec = entries.iter().map(|s| (*s).to_owned()).collect(); - assert_eq!(render_removed(&owned), expected); - } - - // -------- cleanup_manifest_siblings scenarios -------- - - /// `(remove_lockfile, remove_installed)` flag pair handed to - /// [`cleanup_manifest_siblings`]. Bundled as a tuple alias so the - /// parametrized test stays under `clippy::too_many_arguments`'s threshold - /// (7) while keeping the individual case rows readable. - type CleanupFlags = (bool, bool); - - #[rstest] - // Cargo manifest, both flags off → nothing touched, Cargo.lock survives. - #[case::cargo_both_flags_off_is_noop( - ManifestKind::CargoToml, "Cargo.toml", - &["Cargo.lock"], &[], - (false, false), - &[], &["Cargo.lock"], - )] - // Cargo manifest, lockfile flag on → Cargo.lock removed. - #[case::cargo_removes_existing_lockfile( - ManifestKind::CargoToml, "Cargo.toml", - &["Cargo.lock"], &[], - (true, false), - &["Cargo.lock"], &[], - )] - // No lockfile present → silently skipped, no removals reported. - #[case::cargo_silently_skips_missing_lockfile( - ManifestKind::CargoToml, "Cargo.toml", - &[], &[], - (true, true), - &[], &[], - )] - // Node manifest, installed flag on → node_modules/ removed recursively. - #[case::node_removes_node_modules( - ManifestKind::PackageJson, "package.json", - &[], &["node_modules"], - (false, true), - &["node_modules/"], &[], - )] - // Both flags on: lockfiles first (in declared order), then installed dirs. - // Exact-equality assertion on `expected_removed` verifies the ordering. - #[case::node_removes_lockfile_and_installed_together( - ManifestKind::PackageJson, "package.json", - &["bun.lock", "yarn.lock"], &["node_modules"], - (true, true), - &["bun.lock", "yarn.lock", "node_modules/"], &[], - )] - // Cargo cleanup must NEVER delete foreign (node) lockfiles or dirs. - #[case::cargo_does_not_touch_unrelated_lockfiles( - ManifestKind::CargoToml, "Cargo.toml", - &["Cargo.lock", "bun.lock"], &["node_modules"], - (true, true), - &["Cargo.lock"], &["bun.lock", "node_modules"], - )] - // GitHub workflows have no companion lockfile/dir — both flags are a noop. - #[case::github_workflow_is_noop( - ManifestKind::GitHubWorkflow, ".github/workflows/CI.yml", - &["bun.lock"], &[], - (true, true), - &[], &["bun.lock"], - )] - fn cleanup_manifest_siblings_cases( - #[case] kind: ManifestKind, - #[case] manifest_rel: &str, - #[case] seed_lockfiles: &[&str], - #[case] seed_install_dirs: &[&str], - #[case] flags: CleanupFlags, - #[case] expected_removed: &[&str], - #[case] expected_surviving: &[&str], - ) { - let (remove_lockfile, remove_installed) = flags; - - let tmp = tempfile::tempdir().unwrap(); - let manifest = tmp.path().join(manifest_rel); - std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); - std::fs::write(&manifest, "").unwrap(); - let parent = manifest.parent().unwrap(); - - for name in seed_lockfiles { - std::fs::write(parent.join(name), "").unwrap(); - } - for name in seed_install_dirs { - // Seed a child file inside so the recursive-remove path is exercised. - let dir = parent.join(name); - std::fs::create_dir_all(dir.join("child")).unwrap(); - std::fs::write(dir.join("child").join("file"), "").unwrap(); - } - - let removed = cleanup_manifest_siblings(&manifest, kind, remove_lockfile, remove_installed); - - let expected_vec: Vec = expected_removed.iter().map(|s| (*s).to_owned()).collect(); - assert_eq!( - removed, expected_vec, - "removed list mismatch (order matters)" - ); - - for name in expected_surviving { - assert!( - parent.join(name).exists(), - "{name} must survive cleanup but is gone" - ); - } - for name in expected_removed { - // `node_modules/` display name maps back to `node_modules` on disk. - let bare = name.trim_end_matches('/'); - assert!( - !parent.join(bare).exists(), - "{bare} should have been removed" - ); - } - } - - // -------- Early-return + warn-arm error paths -------- - - /// Covers the `manifest_path.parent() == None` early-return branch: - /// `Path::new("").parent()` is `None`, so the function returns an empty - /// Vec without touching the filesystem. - #[test] - fn cleanup_returns_empty_when_manifest_has_no_parent() { - let removed = cleanup_manifest_siblings(Path::new(""), ManifestKind::CargoToml, true, true); - assert!( - removed.is_empty(), - "expected empty removal list for parent-less path, got {removed:?}" - ); - } - - /// Covers the `Err(e) => warn!(...)` arm of `remove_file` for a - /// non-`NotFound` error: a *directory* named `Cargo.lock` sits where a - /// lockfile would. `std::fs::remove_file` refuses to delete a directory, - /// returning an error whose kind is not `NotFound`, so the warn arm - /// fires. The directory must survive and must NOT appear in `removed`. - #[test] - fn cleanup_lockfile_warns_when_remove_file_errors() { - let tmp = tempfile::tempdir().unwrap(); - let manifest = tmp.path().join("Cargo.toml"); - std::fs::write(&manifest, "").unwrap(); - - // Lockfile slot occupied by a *directory* — remove_file will fail. - let lock_as_dir = tmp.path().join("Cargo.lock"); - std::fs::create_dir(&lock_as_dir).unwrap(); - - let removed = cleanup_manifest_siblings(&manifest, ManifestKind::CargoToml, true, false); - - assert!( - removed.is_empty(), - "remove_file failure must not push to removed, got {removed:?}" - ); - assert!( - lock_as_dir.exists() && lock_as_dir.is_dir(), - "Cargo.lock directory must survive the failed remove_file" - ); - } - - /// Covers the `Err(e) => warn!(...)` arm of `remove_dir_all` for a - /// non-`NotFound` error: a regular *file* named `target` sits where the - /// installed-deps directory would. `std::fs::remove_dir_all` cannot - /// recurse into a non-directory and returns a non-`NotFound` error, so - /// the warn arm fires. The file must survive and must NOT appear in - /// `removed`. - #[test] - fn cleanup_installed_warns_when_remove_dir_all_errors() { - let tmp = tempfile::tempdir().unwrap(); - let manifest = tmp.path().join("Cargo.toml"); - std::fs::write(&manifest, "").unwrap(); - - // Installed-dir slot occupied by a regular *file* — remove_dir_all fails. - let target_as_file = tmp.path().join("target"); - std::fs::write(&target_as_file, "not a directory").unwrap(); - - let removed = cleanup_manifest_siblings(&manifest, ManifestKind::CargoToml, false, true); - - assert!( - removed.is_empty(), - "remove_dir_all failure must not push to removed, got {removed:?}" - ); - assert!( - target_as_file.exists() && target_as_file.is_file(), - "`target` file must survive the failed remove_dir_all" - ); - } } diff --git a/crates/cli/src/cleanup_progress.rs b/crates/cli/src/cleanup_progress.rs new file mode 100644 index 0000000..761a192 --- /dev/null +++ b/crates/cli/src/cleanup_progress.rs @@ -0,0 +1,566 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use futures::stream::{FuturesUnordered, StreamExt}; +use indicatif::{ProgressBar, ProgressStyle}; +use tracing::warn; + +use crate::cleanup::{installed_dirs_for, lockfiles_for}; +use crate::run::ManifestJob; + +#[derive(Clone, Copy)] +enum CleanupKind { + Lockfile, + InstalledDir, +} + +#[derive(Clone)] +pub(crate) struct CleanupTarget { + path: PathBuf, + label: String, + kind: CleanupKind, +} + +#[derive(Debug)] +struct RemovalOutcome { + label: String, + bytes: u64, +} + +pub(crate) fn targets_for_job( + job: &ManifestJob, + remove_lockfile: bool, + remove_installed: bool, +) -> Vec { + let Some(dir) = job.manifest_ref.path.parent() else { + return Vec::new(); + }; + + let mut targets = Vec::new(); + + if remove_lockfile { + for name in lockfiles_for(job.manifest_ref.kind) { + targets.push(CleanupTarget { + path: dir.join(name), + label: format!("{}:{name}", job.display_path), + kind: CleanupKind::Lockfile, + }); + } + } + + if remove_installed { + for name in installed_dirs_for(job.manifest_ref.kind) { + targets.push(CleanupTarget { + path: dir.join(name), + label: format!("{}:{name}/", job.display_path), + kind: CleanupKind::InstalledDir, + }); + } + } + + targets +} + +pub(crate) async fn cleanup_with_progress(targets: Vec) -> String { + let len = targets.len(); + if len == 0 { + return String::new(); + } + + let pb = ProgressBar::new(len as u64); + if let Ok(style) = ProgressStyle::with_template( + "{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}", + ) { + pb.set_style(style.progress_chars("=>-")); + } + pb.enable_steady_tick(Duration::from_millis(80)); + + pb.set_message("phase 2: removing lockfiles and installed directories"); + + let mut removals = FuturesUnordered::new(); + for target in targets { + removals.push(tokio::task::spawn_blocking(move || remove_target(target))); + } + + let mut removed = Vec::with_capacity(len); + let mut total_bytes = 0_u64; + + while let Some(outcome) = removals.next().await { + if let Some(message) = absorb_outcome(outcome, &mut removed, &mut total_bytes) { + pb.set_message(message); + } + pb.inc(1); + } + + pb.finish_and_clear(); + render_cleanup_summary(&mut removed, total_bytes) +} + +/// Fold one worker's result into the running tally, returning the progress +/// message to display when something was actually removed. +/// +/// Split out of [`cleanup_with_progress`] so every arm is reachable from a +/// test. The `JoinError` arm in particular only arises when a worker panics, +/// which cannot be provoked by driving the public entry point — [`remove_target`] +/// has no panic path — but is trivially constructed by awaiting a task that +/// does panic. +fn absorb_outcome( + outcome: Result>, tokio::task::JoinError>, + removed: &mut Vec, + total_bytes: &mut u64, +) -> Option { + match outcome { + Ok(Some(Ok(outcome))) => { + *total_bytes = total_bytes.saturating_add(outcome.bytes); + let message = format!( + "removed {} ({}, total {})", + outcome.label, + format_bytes(outcome.bytes), + format_bytes(*total_bytes), + ); + removed.push(outcome); + Some(message) + } + Ok(Some(Err(error))) => { + warn!(error = %error, "failed to remove cleanup target"); + None + } + // The target was already gone — nothing removed, nothing to report. + Ok(None) => None, + Err(error) => { + warn!(error = %error, "cleanup worker failed"); + None + } + } +} + +fn remove_target(target: CleanupTarget) -> Option> { + let bytes = match path_size(&target.path) { + Ok(bytes) => bytes, + // A target that vanished between planning and removal is not a + // failure; anything else is. Both readings share one expression so the + // "already gone" case cannot drift from the sizing step below. + Err(error) => return (error.kind() != io::ErrorKind::NotFound).then_some(Err(error)), + }; + + let remove_result = match target.kind { + CleanupKind::Lockfile => fs::remove_file(&target.path), + CleanupKind::InstalledDir => fs::remove_dir_all(&target.path), + }; + + match remove_result { + Ok(()) => Some(Ok(RemovalOutcome { + label: target.label, + bytes, + })), + Err(error) => (error.kind() != io::ErrorKind::NotFound).then_some(Err(error)), + } +} + +fn path_size(path: &Path) -> io::Result { + // `symlink_metadata` deliberately does not follow links: a symlink into a + // directory tree would otherwise be counted twice, or lead outside it. + let metadata = fs::symlink_metadata(path)?; + if metadata.is_dir() { + return dir_size(path); + } + // Regular files report their own length. Everything else a directory can + // contain — symlinks, sockets, device nodes — reclaims no space when the + // entry itself is unlinked, so it contributes nothing. + Ok(if metadata.is_file() { + metadata.len() + } else { + 0 + }) +} + +fn dir_size(path: &Path) -> io::Result { + let mut total = 0_u64; + for entry in fs::read_dir(path)? { + let entry = entry?; + total = total.saturating_add(path_size(&entry.path())?); + } + Ok(total) +} + +fn render_cleanup_summary(removed: &mut [RemovalOutcome], total_bytes: u64) -> String { + if removed.is_empty() { + return String::new(); + } + + removed.sort_unstable_by(|a, b| a.label.cmp(&b.label)); + + let mut output = String::new(); + for outcome in removed { + output.push_str(" Removed "); + output.push_str(&outcome.label); + output.push_str(" ("); + output.push_str(&format_bytes(outcome.bytes)); + output.push_str(")\n"); + } + output.push_str(" Total removed "); + output.push_str(&format_bytes(total_bytes)); + output.push('\n'); + output +} + +fn format_bytes(bytes: u64) -> String { + const GIB: u64 = 1024 * 1024 * 1024; + const MIB: u64 = 1024 * 1024; + const KIB: u64 = 1024; + + if bytes >= GIB { + format_unit(bytes, GIB, "GiB") + } else if bytes >= MIB { + format_unit(bytes, MIB, "MiB") + } else if bytes >= KIB { + format_unit(bytes, KIB, "KiB") + } else { + format!("{bytes} B") + } +} + +fn format_unit(bytes: u64, unit: u64, suffix: &str) -> String { + let whole = bytes / unit; + let rounded_fraction = ((bytes % unit) * 100 + unit / 2) / unit; + if rounded_fraction == 100 { + format!("{}.00 {suffix}", whole + 1) + } else { + format!("{whole}.{rounded_fraction:02} {suffix}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dependency_check_updates_core::{ManifestKind, ManifestRef}; + use tempfile::TempDir; + + /// Write `contents` to `dir/name` and return the path. + fn write_file(dir: &Path, name: &str, contents: &[u8]) -> PathBuf { + let path = dir.join(name); + fs::write(&path, contents).expect("write fixture file"); + path + } + + fn target(path: PathBuf, kind: CleanupKind) -> CleanupTarget { + CleanupTarget { + path, + label: "fixture".to_owned(), + kind, + } + } + + #[test] + fn path_size_reports_a_file_length() { + let dir = TempDir::new().unwrap(); + let file = write_file(dir.path(), "bun.lock", &[0u8; 128]); + assert_eq!(path_size(&file).unwrap(), 128); + } + + #[test] + fn path_size_sums_a_directory_tree_recursively() { + // node_modules is nested, so the size must come from a full walk + // rather than the directory entry's own metadata. + let dir = TempDir::new().unwrap(); + let nested = dir.path().join("node_modules").join("pkg").join("dist"); + fs::create_dir_all(&nested).unwrap(); + write_file(dir.path().join("node_modules").as_path(), "top", &[0u8; 10]); + write_file(&nested, "deep", &[0u8; 25]); + + assert_eq!(path_size(&dir.path().join("node_modules")).unwrap(), 35); + } + + #[test] + fn path_size_reports_zero_for_an_empty_directory() { + let dir = TempDir::new().unwrap(); + let empty = dir.path().join("empty"); + fs::create_dir(&empty).unwrap(); + assert_eq!(path_size(&empty).unwrap(), 0); + } + + #[test] + fn path_size_propagates_a_missing_path() { + let dir = TempDir::new().unwrap(); + let error = path_size(&dir.path().join("absent")).expect_err("missing path must error"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + /// Entries that are neither a regular file nor a directory contribute + /// nothing. Exercised through a dangling symlink, which is the only such + /// entry creatable without elevated privileges — and only on Unix, where + /// `std::os::unix::fs::symlink` needs no special rights. + #[cfg(unix)] + #[test] + fn path_size_ignores_entries_that_are_neither_file_nor_directory() { + let dir = TempDir::new().unwrap(); + let link = dir.path().join("dangling"); + std::os::unix::fs::symlink("nowhere", &link).unwrap(); + assert_eq!(path_size(&link).unwrap(), 0); + } + + #[test] + fn dir_size_propagates_a_missing_directory() { + let dir = TempDir::new().unwrap(); + let error = dir_size(&dir.path().join("absent")).expect_err("missing dir must error"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn remove_target_deletes_a_lockfile_and_reports_its_size() { + let dir = TempDir::new().unwrap(); + let file = write_file(dir.path(), "bun.lock", &[0u8; 64]); + + let outcome = remove_target(target(file.clone(), CleanupKind::Lockfile)) + .expect("an existing target yields an outcome") + .expect("removing a plain file succeeds"); + + assert_eq!(outcome.bytes, 64); + assert_eq!(outcome.label, "fixture"); + assert!(!file.exists(), "the lockfile must be gone"); + } + + #[test] + fn remove_target_deletes_an_installed_directory_tree() { + let dir = TempDir::new().unwrap(); + let installed = dir.path().join("node_modules"); + fs::create_dir_all(installed.join("pkg")).unwrap(); + write_file(installed.join("pkg").as_path(), "index.js", &[0u8; 40]); + + let outcome = remove_target(target(installed.clone(), CleanupKind::InstalledDir)) + .expect("an existing target yields an outcome") + .expect("removing a directory tree succeeds"); + + assert_eq!(outcome.bytes, 40); + assert!(!installed.exists(), "the directory tree must be gone"); + } + + #[test] + fn remove_target_treats_an_absent_target_as_nothing_to_do() { + // Targets are planned before removal runs, so one may legitimately + // vanish in between. That is not a failure to report. + let dir = TempDir::new().unwrap(); + let missing = target(dir.path().join("never-existed.lock"), CleanupKind::Lockfile); + assert!(remove_target(missing).is_none()); + } + + #[test] + fn remove_target_surfaces_a_real_removal_failure() { + // A lockfile-kind target pointing at a directory: sizing succeeds, but + // `remove_file` refuses with something other than "not found". + let dir = TempDir::new().unwrap(); + let not_a_file = dir.path().join("node_modules"); + fs::create_dir(¬_a_file).unwrap(); + + let error = remove_target(target(not_a_file.clone(), CleanupKind::Lockfile)) + .expect("a real failure must be reported") + .expect_err("removing a directory as a file cannot succeed"); + + assert_ne!(error.kind(), io::ErrorKind::NotFound); + assert!(not_a_file.exists(), "the directory must survive"); + } + + #[test] + fn absorb_outcome_accumulates_removals_and_reports_a_running_total() { + let mut removed = Vec::new(); + let mut total = 0_u64; + + let first = absorb_outcome( + Ok(Some(Ok(RemovalOutcome { + label: "a.lock".to_owned(), + bytes: 1024, + }))), + &mut removed, + &mut total, + ); + let second = absorb_outcome( + Ok(Some(Ok(RemovalOutcome { + label: "b.lock".to_owned(), + bytes: 1024, + }))), + &mut removed, + &mut total, + ); + + assert_eq!(total, 2048); + assert_eq!(removed.len(), 2); + assert!(first.unwrap().contains("total 1.00 KiB")); + assert!(second.unwrap().contains("total 2.00 KiB")); + } + + /// The three non-removal arms must all leave the tally untouched and + /// produce no progress message. + #[tokio::test] + async fn absorb_outcome_ignores_every_non_removal_result() { + let mut removed = Vec::new(); + let mut total = 0_u64; + + // A removal that failed. + assert!( + absorb_outcome( + Ok(Some(Err(io::Error::other("disk on fire")))), + &mut removed, + &mut total, + ) + .is_none() + ); + // A target that was already gone. + assert!(absorb_outcome(Ok(None), &mut removed, &mut total).is_none()); + // A worker that panicked. `remove_target` has no panic path, so the + // only way to obtain a real `JoinError` is to await a task that does. + let join_error = tokio::task::spawn_blocking(|| panic!("worker exploded")) + .await + .expect_err("the worker panicked"); + assert!(absorb_outcome(Err(join_error), &mut removed, &mut total).is_none()); + + assert!(removed.is_empty()); + assert_eq!(total, 0); + } + + #[tokio::test] + async fn cleanup_with_progress_is_silent_when_there_is_nothing_to_remove() { + assert_eq!(cleanup_with_progress(Vec::new()).await, ""); + } + + #[tokio::test] + async fn cleanup_with_progress_removes_every_target_and_summarises_once() { + let dir = TempDir::new().unwrap(); + let lockfile = write_file(dir.path(), "bun.lock", &[0u8; 2048]); + let installed = dir.path().join("node_modules"); + fs::create_dir(&installed).unwrap(); + write_file(&installed, "index.js", &[0u8; 1024]); + // A target that is already gone and one that cannot be removed must + // both be tolerated without aborting the run. + let absent = dir.path().join("absent.lock"); + let undeletable = dir.path().join("target"); + fs::create_dir(&undeletable).unwrap(); + + let summary = cleanup_with_progress(vec![ + CleanupTarget { + path: lockfile.clone(), + label: "app:bun.lock".to_owned(), + kind: CleanupKind::Lockfile, + }, + CleanupTarget { + path: installed.clone(), + label: "app:node_modules/".to_owned(), + kind: CleanupKind::InstalledDir, + }, + CleanupTarget { + path: absent, + label: "app:absent.lock".to_owned(), + kind: CleanupKind::Lockfile, + }, + CleanupTarget { + path: undeletable.clone(), + label: "app:target".to_owned(), + kind: CleanupKind::Lockfile, + }, + ]) + .await; + + assert!(!lockfile.exists()); + assert!(!installed.exists()); + assert!(undeletable.exists(), "the failing target must survive"); + + // Only the two successful removals are listed, sorted by label, and + // the total is their sum. + let lines: Vec<&str> = summary.lines().collect(); + assert_eq!(lines.len(), 3, "got: {summary}"); + assert!(lines[0].contains("app:bun.lock")); + assert!(lines[1].contains("app:node_modules/")); + assert!( + lines[2].contains("Total removed 3.00 KiB"), + "got: {summary}" + ); + } + + #[test] + fn targets_for_job_yields_nothing_for_a_manifest_without_a_parent() { + let job = ManifestJob { + manifest_ref: ManifestRef { + // An empty path has no parent directory to clean up beside. + path: PathBuf::new(), + kind: ManifestKind::PackageJson, + }, + display_path: String::new(), + text: String::new(), + handler: &dependency_check_updates_node::NodeHandler, + deps: Vec::new(), + }; + + assert!(targets_for_job(&job, true, true).is_empty()); + } + + #[test] + fn render_cleanup_summary_is_empty_when_nothing_was_removed() { + assert_eq!(render_cleanup_summary(&mut [], 0), ""); + } + + #[test] + fn format_bytes_uses_binary_units() { + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(1536), "1.50 KiB"); + assert_eq!(format_bytes(2 * 1024 * 1024), "2.00 MiB"); + assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.00 GiB"); + } + + #[test] + fn targets_for_job_defers_deletion_but_preserves_requested_entries() { + let job = ManifestJob { + manifest_ref: ManifestRef { + path: PathBuf::from("repo/package.json"), + kind: ManifestKind::PackageJson, + }, + display_path: "package.json".to_owned(), + text: String::new(), + handler: &dependency_check_updates_node::NodeHandler, + deps: Vec::new(), + }; + + let targets = targets_for_job(&job, true, true); + + assert!( + targets + .iter() + .any(|target| target.label == "package.json:node_modules/") + ); + assert!( + targets + .iter() + .any(|target| target.label == "package.json:package-lock.json") + ); + } + + #[test] + fn render_cleanup_summary_sorts_removal_outcomes_lexicographically() { + // Given: removal outcomes in non-alphabetical order + let mut outcomes = vec![ + RemovalOutcome { + label: "zebra.lock".to_owned(), + bytes: 1024, + }, + RemovalOutcome { + label: "apple.lock".to_owned(), + bytes: 2048, + }, + RemovalOutcome { + label: "middle.lock".to_owned(), + bytes: 512, + }, + ]; + + // When: rendering the cleanup summary + let output = render_cleanup_summary(&mut outcomes, 3584); + + // Then: the output lines are sorted lexicographically by label + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 4); // 3 removal lines + 1 total line + assert!(lines[0].contains("apple.lock")); + assert!(lines[1].contains("middle.lock")); + assert!(lines[2].contains("zebra.lock")); + assert!(lines[3].contains("Total removed")); + } +} diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 0754fb0..3053a3f 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -64,7 +64,7 @@ pub struct Cli { /// Directories removed (siblings of each manifest): /// - `package.json` → `node_modules/` /// - `Cargo.toml` → `target/` - /// - `pyproject.toml` → `.venv/`, `venv/` + /// - `pyproject.toml` → `.venv/`, `venv/`, `__pypackages__/`, `.tox/`, `.nox/` #[arg(long = "remove-installed")] pub remove_installed: bool, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 5609202..e0e7288 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -3,6 +3,7 @@ #![warn(missing_docs)] mod cleanup; +mod cleanup_progress; mod cli; mod logging; mod output; @@ -10,7 +11,7 @@ mod pipeline; mod run; pub use cli::{Cli, OutputFormat, parse_args}; -pub use run::{main, run}; +pub use run::{main, run, run_cli}; // Re-exported so bridge crates (napi, maturin) can name the unified error // type without depending on `dependency-check-updates-core` directly. diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3c985c7..c56eb7f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,21 +3,5 @@ use std::process::ExitCode; #[tokio::main(flavor = "current_thread")] #[cfg(not(tarpaulin_include))] async fn main() -> ExitCode { - let cli = dependency_check_updates::parse_args(); - let error_level = cli.error_level; - - match dependency_check_updates::run(&cli).await { - Ok(has_updates) => { - // error_level 2: exit 1 if any updates were found (CI mode) - if error_level >= 2 && has_updates { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - } - } - Err(e) => { - eprintln!("Error: {e}"); - ExitCode::FAILURE - } - } + dependency_check_updates::run_cli().await } diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index dfb91da..2839e73 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -1,8 +1,11 @@ //! ncu-style table output with colored version diffs. +use std::borrow::Cow; use std::fmt::Write; -use dependency_check_updates_core::{BumpType, PlannedUpdate}; +use dependency_check_updates_core::{ + BumpType, PlannedUpdate, split_numeric_head, strip_range_prefix, +}; use owo_colors::OwoColorize; /// Determine the type of version bump by comparing version strings. @@ -20,32 +23,43 @@ pub fn detect_bump_type(from: &str, to: &str) -> BumpType { } } -/// Parse major.minor.patch from a version string, stripping range prefixes. -fn parse_version_parts(v: &str) -> (u64, u64, u64) { - let cleaned = v.trim_start_matches(|c: char| !c.is_ascii_digit()); - let mut parts = cleaned.splitn(3, '.'); +/// Parse major.minor from a version string, stripping range prefixes. +/// +/// Uses [`split_numeric_head`] to peel the borrowed numeric prefix once +/// (`"1.2.3-beta.1"` → `"1.2.3"`), then a uniform `.parse()` over the first +/// two dot-segments. [`detect_bump_type`] only compares major and minor — +/// anything past those classifies as a patch bump — so the patch segment is +/// never parsed. +fn parse_version_parts(v: &str) -> (u64, u64) { + let numeric = split_numeric_head(strip_range_prefix(v)).0; + let mut parts = numeric.split('.'); let major = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let patch = parts - .next() - .and_then(|s| { - // Handle "3-beta.1" -> take digits only - let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); - digits.parse().ok() - }) - .unwrap_or(0); - (major, minor, patch) + (major, minor) +} + +/// Whether colored output is enabled given the raw `NO_COLOR` value. +/// +/// Follows : color is disabled only when `NO_COLOR` +/// is present with a *non-empty* value. Unset or empty keeps color on. +#[must_use] +pub fn color_enabled(no_color: Option) -> bool { + no_color.is_none_or(|value| value.is_empty()) } /// Colorize a version string based on bump type. -fn colorize_version(version: &str, bump: BumpType, use_color: bool) -> String { +/// +/// Returns a `Cow<'_, str>` to avoid allocation when color is disabled: +/// - `!use_color` returns `Cow::Borrowed(version)` (zero-copy) +/// - Colored paths return `Cow::Owned(...)` with ANSI escape codes +fn colorize_version(version: &str, bump: BumpType, use_color: bool) -> Cow<'_, str> { if !use_color { - return version.to_owned(); + return Cow::Borrowed(version); } match bump { - BumpType::Major => format!("{}", version.red()), - BumpType::Minor => format!("{}", version.cyan()), - BumpType::Patch => format!("{}", version.green()), + BumpType::Major => Cow::Owned(format!("{}", version.red())), + BumpType::Minor => Cow::Owned(format!("{}", version.cyan())), + BumpType::Patch => Cow::Owned(format!("{}", version.green())), } } @@ -70,25 +84,30 @@ pub fn render_table(updates: &[PlannedUpdate], use_color: bool) -> String { let unique = dedupe_updates(updates); // Calculate column widths against the deduped set so columns stay tight. - let max_name = unique.iter().map(|u| u.name.len()).max().unwrap_or(0); - let max_from = unique.iter().map(|u| u.from.len()).max().unwrap_or(0); - let max_to = unique.iter().map(|u| u.to.len()).max().unwrap_or(0); + // The final `to` column is never padded — padding it only adds trailing + // whitespace since nothing follows it on the line. + let (max_name, max_from) = unique.iter().fold((0usize, 0usize), |(n, f), u| { + (n.max(u.name.len()), f.max(u.from.len())) + }); - let mut output = String::new(); + let mut output = String::with_capacity(unique.len() * (max_name + max_from + 24)); for update in &unique { - let bump = detect_bump_type(&update.from, &update.to); - let colored_to = colorize_version(&update.to, bump, use_color); + let colored_to = if use_color { + let bump = detect_bump_type(&update.from, &update.to); + colorize_version(&update.to, bump, true) + } else { + Cow::Borrowed(update.to.as_str()) + }; let _ = writeln!( output, - " {:from_w$} -> {:from_w$} -> {}", update.name, update.from, colored_to, name_w = max_name, from_w = max_from, - to_w = max_to, ); } @@ -97,10 +116,11 @@ pub fn render_table(updates: &[PlannedUpdate], use_color: bool) -> String { /// Collapse `updates` by `(name, from, to)` while preserving original order. /// -/// Lifted out of [`render_table`] so [`render_json`] can apply the same dedup -/// without duplicating logic. Returns references so we avoid cloning. +/// Used by [`render_table`] to deduplicate display rows. [`render_json`] dedupes +/// separately via `serde_json::Map` key uniqueness (last-write-wins). Returns +/// references so we avoid cloning. fn dedupe_updates(updates: &[PlannedUpdate]) -> Vec<&PlannedUpdate> { - let mut seen = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::with_capacity(updates.len()); updates .iter() .filter(|u| seen.insert((u.name.as_str(), u.from.as_str(), u.to.as_str()))) @@ -149,8 +169,8 @@ pub fn render_footer(path: &str, upgrading: bool, has_updates: bool, use_color: /// `(name, from, to)` triples, use the table format and parse line-by-line. #[must_use] pub fn render_json(updates: &[PlannedUpdate]) -> String { - let mut map = serde_json::Map::new(); - for update in dedupe_updates(updates) { + let mut map = serde_json::Map::with_capacity(updates.len()); + for update in updates { map.insert( update.name.clone(), serde_json::Value::String(update.to.clone()), @@ -187,6 +207,15 @@ mod tests { } } + #[rstest] + // no-color.org: only a *non-empty* NO_COLOR value disables color. + #[case::unset(None, true)] + #[case::empty(Some(std::ffi::OsString::new()), true)] + #[case::non_empty(Some(std::ffi::OsString::from("1")), false)] + fn color_enabled_cases(#[case] no_color: Option, #[case] expected: bool) { + assert_eq!(color_enabled(no_color), expected); + } + #[rstest] #[case::major("^1.0.0", "^2.0.0", BumpType::Major)] #[case::minor("^1.0.0", "^1.1.0", BumpType::Minor)] @@ -251,7 +280,7 @@ mod tests { #[case::major_with_color("^2.0.0", BumpType::Major, true, "2.0.0", false)] #[case::minor_with_color("^1.1.0", BumpType::Minor, true, "1.1.0", false)] #[case::patch_with_color("^1.0.1", BumpType::Patch, true, "1.0.1", false)] - // No color: the result must equal the input verbatim. + // No color: the result must equal the input verbatim (and be borrowed). #[case::no_color("^2.0.0", BumpType::Major, false, "^2.0.0", true)] fn colorize_version_cases( #[case] version: &str, @@ -266,9 +295,15 @@ mod tests { "{result:?} should contain {expected_substr:?}" ); if expect_equal_input { - assert_eq!(result, version); + assert_eq!(result.as_ref(), version); + // Verify the no-color path returns a borrowed Cow + assert!(matches!(result, Cow::Borrowed(_))); } else { - assert_ne!(result, version, "expected ANSI color codes to be applied"); + assert_ne!( + result.as_ref(), + version, + "expected ANSI color codes to be applied" + ); } } @@ -292,10 +327,9 @@ mod tests { #[test] fn test_parse_version_parts_with_prerelease() { // "3-beta.1" should parse major=3 - let (major, minor, patch) = parse_version_parts("3.0.0-beta.1"); + let (major, minor) = parse_version_parts("3.0.0-beta.1"); assert_eq!(major, 3); assert_eq!(minor, 0); - assert_eq!(patch, 0); } #[test] @@ -334,6 +368,20 @@ mod tests { assert!(output.contains("v5")); } + #[test] + fn test_render_table_rows_have_no_trailing_spaces() { + // Destination versions of different lengths: padding the last column + // to the widest `to` would leave trailing spaces on the shorter row. + let updates = vec![ + upd("react", "^17.0.0", "^18.2.0"), + upd("lodash", "^4.17.0", "^4.17.21"), + ]; + let output = render_table(&updates, false); + for line in output.lines() { + assert!(!line.ends_with(' '), "line has trailing space(s): {line:?}"); + } + } + #[test] fn test_render_json_dedupes() { let updates = vec![ diff --git a/crates/cli/src/pipeline.rs b/crates/cli/src/pipeline.rs index 3ee4cff..e0d2c06 100644 --- a/crates/cli/src/pipeline.rs +++ b/crates/cli/src/pipeline.rs @@ -1,16 +1,26 @@ +use std::borrow::Cow; + use tracing::{debug, trace, warn}; use dependency_check_updates_core::{ - DcuError, DependencySpec, ManifestKind, PlannedUpdate, ResolvedVersion, + DcuError, DependencySection, DependencySpec, PlannedUpdate, ResolvedVersion, + count_numeric_segments, pad_to_three_segments, split_numeric_head, strip_range_prefix, }; /// Filter dependencies by include/exclude patterns. +/// +/// Consumes `deps` so kept specs move into the returned vec — no `String` +/// clones on the default code path where neither filter is active and every +/// dependency survives. pub(crate) fn filter_deps( - deps: &[DependencySpec], + deps: Vec, include: &[String], exclude: &[String], ) -> Vec { - deps.iter() + if include.is_empty() && exclude.is_empty() { + return deps; + } + deps.into_iter() .filter(|dep| { if !include.is_empty() && !include.iter().any(|f| dep.name.contains(f.as_str())) { return false; @@ -20,17 +30,65 @@ pub(crate) fn filter_deps( } true }) - .cloned() .collect() } +/// Re-attach the range prefix from `current_req` onto a new bare version. +/// +/// `current_bare` MUST be the result of `strip_range_prefix(current_req)` — the +/// length difference is the leading non-digit prefix (`^`, `~`, `>=`, +/// `">= "`, …) that needs to be re-glued onto `new_bare`. Centralises the +/// expression previously duplicated in `compute_updates` and `sync_path_dep`, +/// so future range-prefix tightenings land in exactly one place. +fn rewrite_with_range_prefix(current_req: &str, current_bare: &str, new_bare: &str) -> String { + let prefix = ¤t_req[..current_req.len() - current_bare.len()]; + format!("{prefix}{new_bare}") +} + +/// Strip build metadata (`+…` suffix) from a version string. +/// +/// Build metadata has no meaning in version requirements and is dropped by +/// both `truncate_version` and the safety gate in `is_plain_numeric_version`. +/// This helper centralises the extraction so the three callers stay in sync. +fn strip_build_metadata(v: &str) -> &str { + v.split_once('+').map_or(v, |(head, _)| head) +} + +/// Whether a section's registry resolves a ref that must be written verbatim. +/// +/// Registry ecosystems (npm, crates.io, `PyPI`) return a canonical `x.y.z` +/// version, and the pipeline re-shapes it to the user's pin precision. The +/// tag-addressed ecosystems instead resolve a *name that must exist* — a git +/// tag, a container tag — and their registries already did that shaping while +/// checking the candidate against the published list. Reshaping it a second +/// time here can only invent a ref nobody published. +fn resolves_to_an_exact_ref(section: DependencySection) -> bool { + matches!( + section, + DependencySection::GitHubActions | DependencySection::DockerImage + ) +} + +fn plan_update(dep: &DependencySpec, to: String) -> PlannedUpdate { + PlannedUpdate { + name: dep.name.clone(), + section: dep.section, + from: dep.current_req.clone(), + to, + } +} + /// Compute planned updates from resolved versions. +/// +/// The rewrite policy is chosen per dependency via +/// [`resolves_to_an_exact_ref`], not per manifest kind: one workflow file can +/// carry both `uses:` refs and container `image:` pins, and a future manifest +/// may mix a registry ecosystem with a tag-addressed one the same way. pub(crate) fn compute_updates( deps: &[DependencySpec], resolved: &[(usize, Result)], - kind: ManifestKind, ) -> Vec { - let mut updates = Vec::new(); + let mut updates = Vec::with_capacity(resolved.len()); for (idx, result) in resolved { let dep = &deps[*idx]; @@ -50,10 +108,43 @@ pub(crate) fn compute_updates( continue; }; + // Local path dependency: exact-sync the `version` field to the crate + // on disk. The path crate's actual version is the source of truth, so + // this bypasses the never-downgrade safety net below — Cargo requires + // the requirement to be satisfiable by the local crate's version. + if dep.path_version.is_some() { + if let Some(update) = sync_path_dep(dep, selected) { + debug!(name = %update.name, from = %update.from, to = %update.to, "path dep sync"); + updates.push(update); + } else { + trace!(package = %dep.name, version = %dep.current_req, "path dep already in sync"); + } + continue; + } + // Strip range prefix for comparison - let current_bare = dep - .current_req - .trim_start_matches(|c: char| !c.is_ascii_digit()); + let current_bare = strip_range_prefix(&dep.current_req); + + // Skip requirements with no numeric version (e.g., pnpm `catalog:`, + // yarn `portal:`, a stray `^`). These have no resolvable version and + // would corrupt the manifest if rewritten (e.g., `catalog:` → `catalog:18.2.0`). + if current_bare.is_empty() { + continue; + } + + // Compound ranges (`^17 || ^18`, `>=1.0, <2.0`, `>=18 <19`) carry + // multiple clauses; the prefix-reuse rewrite below would keep only + // the first clause and silently drop the rest, violating the + // manifest's format-preservation contract. Leave them untouched. + if is_compound_range(current_bare) { + trace!( + package = %dep.name, + current = %dep.current_req, + selected = %selected, + "skipping: compound version range (OR/AND clauses not supported)" + ); + continue; + } // Safety net: never suggest a downgrade. When both current and selected // can be parsed as semver (after padding short forms like `5` or `5.1` @@ -65,29 +156,33 @@ pub(crate) fn compute_updates( if let (Ok(cur_ver), Ok(sel_ver)) = ( semver::Version::parse(&pad_to_three_segments(current_bare)), semver::Version::parse(&pad_to_three_segments(selected)), - ) && sel_ver <= cur_ver - { - trace!( - package = %dep.name, - current = %dep.current_req, - selected = %selected, - "skipping: selected version is not newer than current" - ); - continue; + ) { + if sel_ver <= cur_ver { + trace!( + package = %dep.name, + current = %dep.current_req, + selected = %selected, + "skipping: selected version is not newer than current" + ); + continue; + } } // Preserve precision: if the user wrote "0.6" (2 segments), truncate the // resolved version to 2 segments before comparing. This respects the user's // intent to pin only at that granularity. // - // GitHub workflow refs are exempt: the GitHub registry already resolved - // the exact, tag-validated ref form (`pick_existing_ref`), so re-running + // Tag-addressed sections are exempt. Their registries already resolved + // an exact, existence-checked ref form — `pick_existing_ref` for GitHub + // Actions, `pick_existing_numeric` for container tags — so re-running // the generic truncation here could re-shorten an escalated ref - // (`v8.1.0` → `v8`) back into a dangling tag. - let selected_truncated = if kind == ManifestKind::GitHubWorkflow { - selected.clone() + // (`v8.1.0` → `v8`) back into a tag that was never published. Container + // tags additionally carry a variant suffix (`22-alpine`) that this + // numeric truncation would silently amputate. + let selected_truncated: Cow<'_, str> = if resolves_to_an_exact_ref(dep.section) { + Cow::Borrowed(selected) } else { - let precision = count_version_segments(current_bare); + let precision = count_numeric_segments(current_bare); if precision < 3 && !is_plain_numeric_version(selected) { trace!( @@ -102,75 +197,100 @@ pub(crate) fn compute_updates( truncate_version(selected, precision) }; - if current_bare == selected_truncated { + if current_bare == selected_truncated.as_ref() { trace!(package = %dep.name, version = %dep.current_req, "already up to date"); continue; } // Preserve the range prefix from the original spec - let prefix_len = dep.current_req.len() - current_bare.len(); - let prefix = &dep.current_req[..prefix_len]; - let new_version = format!("{prefix}{selected_truncated}"); - - updates.push(PlannedUpdate { - name: dep.name.clone(), - section: dep.section, - from: dep.current_req.clone(), - to: new_version, - }); + let new_version = + rewrite_with_range_prefix(&dep.current_req, current_bare, &selected_truncated); + + updates.push(plan_update(dep, new_version)); } updates } -/// Pad a version string to exactly three numeric segments so it can be -/// fed to `semver::Version::parse` for ordering comparisons. +/// Compute the exact-sync update for a local path dependency. /// -/// Preserves any pre-release / build-metadata suffix (`-rc.1`, `+build.7`). +/// The `version` field of a `{ path = "...", version = "..." }` dependency is +/// synced to `local_version` (the version of the crate on disk). Unlike the +/// registry path this allows "downgrades": if the local crate is *older* than +/// the declared requirement the field is lowered to match, because Cargo +/// requires the requirement to be satisfiable by the path crate's version. /// -/// `pad_to_three_segments("5")` → `"5.0.0"` -/// `pad_to_three_segments("5.1")` → `"5.1.0"` -/// `pad_to_three_segments("5.1.0")` → `"5.1.0"` -/// `pad_to_three_segments("5.1.0-rc.1")` → `"5.1.0-rc.1"` -/// `pad_to_three_segments("1.2-beta")` → `"1.2.0-beta"` -fn pad_to_three_segments(v: &str) -> String { - if v.is_empty() { - return v.to_owned(); +/// The range prefix (`^`, `~`, `>=`, …) and the user's pin precision are +/// preserved for plain numeric versions (`0.2` → `0.3`); otherwise the full +/// local version is written (build metadata stripped, pre-release preserved). +/// Returns `None` when the field is already in sync. +fn sync_path_dep(dep: &DependencySpec, local_version: &str) -> Option { + let current_bare = strip_range_prefix(&dep.current_req); + if current_bare.is_empty() { + // e.g. `version = "*"` — already matches any version, nothing to sync. + return None; } - let (numeric, suffix) = v - .find(|c: char| !c.is_ascii_digit() && c != '.') - .map_or((v, ""), |i| v.split_at(i)); - let parts: Vec<&str> = numeric.split('.').filter(|s| !s.is_empty()).collect(); - match parts.len() { - 1 => format!("{}.0.0{}", parts[0], suffix), - 2 => format!("{}.{}.0{}", parts[0], parts[1], suffix), - // 0 (no numeric prefix) or ≥3 (already padded / over-padded): leave - // as-is. `semver::Version::parse` will reject the 0-parts case below. - _ => v.to_owned(), + + let precision = count_numeric_segments(current_bare); + let new_bare: Cow<'_, str> = if precision < 3 && is_plain_numeric_version(local_version) { + truncate_version(local_version, precision) + } else { + // Full version: strip build metadata (`+...`), keep any pre-release. + Cow::Borrowed(strip_build_metadata(local_version)) + }; + + if current_bare == new_bare { + return None; } + + Some(plan_update( + dep, + rewrite_with_range_prefix(&dep.current_req, current_bare, &new_bare), + )) } -/// Count the number of version segments in a bare version string. +/// True when `current_bare` represents a compound version range — multiple +/// clauses joined by `||` (npm OR), `,` (Cargo / `PyPI` AND), or an internal +/// space (npm AND, e.g. `">=18.0.0 <19.0.0"`, or the npm hyphen-range form +/// `"1.2.3 - 1.5.0"` meaning `>=1.2.3 <=1.5.0`). /// -/// "1" → 1 (major only) -/// "1.0" → 2 (major.minor) -/// "1.0.0" → 3 (major.minor.patch) -/// "1.0.0-beta.1" → 3 (pre-release suffix ignored) -fn count_version_segments(bare: &str) -> usize { - // Stop at the first non-digit, non-dot character (e.g., '-' for pre-release) - let numeric_part = bare - .split(|c: char| !c.is_ascii_digit() && c != '.') - .next() - .unwrap_or(""); - if numeric_part.is_empty() { - return 0; +/// Single clauses with a leading-operator space like `">= 1.0.0"` are NOT +/// compound: `strip_range_prefix` already removed the leading non-digit run +/// (including the space), so this helper sees `"1.0.0"` and returns `false`. +/// +/// `compute_updates` cannot rewrite compound ranges without losing user +/// intent — every clause beyond the first would be silently dropped when +/// the registry-resolved version is reprefixed onto the original spec. The +/// safe answer is to leave the manifest byte-identical until a real +/// multi-clause rewriter exists. +fn is_compound_range(current_bare: &str) -> bool { + if current_bare.contains("||") || current_bare.contains(',') { + return true; } - numeric_part.split('.').filter(|s| !s.is_empty()).count() + // npm AND: a space whose left neighbour is a digit and whose right + // neighbour is a clause start (digit or one of `<>=~^!xX*`) — or `-`, the + // npm hyphen-range continuation (`A.B.C - X.Y.Z`). The x/X/* additions + // catch x-range (`1.2 x`) and wildcard (`1.0.0 *`) clause starts. + // Iterating bytes is safe because every character we test against is + // ASCII — a non-ASCII byte cannot equal `b' '` or be a digit / operator anyway. + let bytes = current_bare.as_bytes(); + for i in 1..bytes.len().saturating_sub(1) { + if bytes[i] == b' ' + && bytes[i - 1].is_ascii_digit() + && matches!( + bytes[i + 1], + b'<' | b'>' | b'=' | b'~' | b'^' | b'!' | b'-' | b'x' | b'X' | b'*' | b'0'..=b'9' + ) + { + return true; + } + } + false } /// Whether `version` is a plain numeric version — one or more dot-separated -/// segments that are *all* ASCII digits, with no pre-release (`-…`) or build -/// (`+…`) suffix. +/// segments that are *all* ASCII digits, ignoring any build-metadata tail +/// (`+…`) but still rejecting pre-release tails (`-…`). /// /// Such versions are always safe to truncate to fewer segments (`5.1` → `5`, /// `4.0.0` → `4.0`): there is no pre-release tag that could be silently @@ -178,18 +298,19 @@ fn count_version_segments(bare: &str) -> usize { /// two-segment stables like `5.1` (e.g. Django) — the previous /// exactly-three-segment check rejected them, which made `--target /// greatest/newest/minor/patch` silently skip such packages whenever the user -/// pinned at <3-segment precision. Versions carrying a suffix -/// (`4.0.0-beta.0`, `1.2.3+build`) return `false` so the caller refuses to -/// truncate them. +/// pinned at <3-segment precision. +/// +/// Build metadata is stripped before the digit check so this predicate stays +/// in lock-step with [`truncate_version`], which also drops `+…` before +/// truncating. Without the strip, `0.7.0+build.1` would be rejected here even +/// though the operation this gate guards is provably safe — `0.7.0+build.1` +/// → `0.7`. Pre-release (`-…`) tails are still rejected: silently promoting +/// a prerelease to a stable-looking pin is exactly the surprise this gate +/// guards against. fn is_plain_numeric_version(version: &str) -> bool { - let mut any = false; - for segment in version.split('.') { - if segment.is_empty() || !segment.bytes().all(|b| b.is_ascii_digit()) { - return false; - } - any = true; - } - any + strip_build_metadata(version) + .split('.') + .all(|seg| !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit())) } /// Truncate a version string to the given number of segments. @@ -198,32 +319,46 @@ fn is_plain_numeric_version(version: &str) -> bool { /// in version requirements and causes warnings in Cargo.toml. Pre-release /// suffix (`-beta.1`) is preserved when not truncating patch level. /// -/// `truncate_version("1.2.3`", 2) → "1.2" -/// `truncate_version("1.2.3`", 3) → "1.2.3" -/// `truncate_version("1.2.3+build.1`", 3) → "1.2.3" -/// truncate_version("1.2.3-rc.1", 3) → "1.2.3-rc.1" -/// truncate_version("1.2.3-rc.1", 2) → "1.2" -fn truncate_version(version: &str, segments: usize) -> String { +/// Returns a borrowed reference when no truncation is needed (segments == 0 +/// or already at/below precision), or an owned string when truncation occurs. +/// +/// ```text +/// truncate_version("1.2.3", 2) → "1.2" +/// truncate_version("1.2.3", 3) → "1.2.3" +/// truncate_version("1.2.3+build.1", 3) → "1.2.3" +/// truncate_version("1.2.3-rc.1", 3) → "1.2.3-rc.1" +/// truncate_version("1.2.3-rc.1", 2) → "1.2" +/// ``` +fn truncate_version(version: &str, segments: usize) -> Cow<'_, str> { // Strip build metadata unconditionally (`+...`) - let stripped = version.split('+').next().unwrap_or(version); + let stripped = strip_build_metadata(version); if segments == 0 { - return stripped.to_owned(); + return Cow::Borrowed(stripped); } - // Split numeric.dot prefix from any trailing pre-release (`-...`) - let (numeric, suffix) = stripped - .find(|c: char| !c.is_ascii_digit() && c != '.') - .map_or((stripped, ""), |i| stripped.split_at(i)); + // Bare numeric `1.2.3` head — any non-digit, non-dot byte ends the + // numeric prefix and marks the start of a pre-release tail we drop on + // truncation (the comparison below decides whether truncation happens). + let numeric = split_numeric_head(stripped).0; - let parts: Vec<&str> = numeric.split('.').collect(); - if parts.len() <= segments { - // Already at or below desired precision — keep as-is with any pre-release - return stripped.to_owned(); + if numeric.split('.').count() <= segments { + // Already at or below desired precision — return `stripped` so any + // pre-release tail survives unchanged. + return Cow::Borrowed(stripped); } - // Truncated: drop any pre-release suffix too - let _ = suffix; - parts[..segments].join(".") + + // Truncating: build the result directly from the numeric head without + // a `Vec<&str>` middleman. Any pre-release suffix is dropped by + // construction because we only consume `numeric`. + let mut out = String::with_capacity(numeric.len()); + for (i, part) in numeric.split('.').take(segments).enumerate() { + if i > 0 { + out.push('.'); + } + out.push_str(part); + } + Cow::Owned(out) } #[cfg(test)] @@ -243,6 +378,7 @@ mod tests { name: name.to_owned(), current_req: current_req.to_owned(), section: DependencySection::Dependencies, + path_version: None, } } @@ -284,6 +420,15 @@ mod tests { "0.25.11+spec-1.1.0", Some("0.25.11") )] + // 2-segment pin + selected version with build metadata: the safety gate + // now strips `+build.1` before checking, matching `truncate_version`, so + // the dep correctly truncates to `0.7` instead of being silently dropped. + #[case::truncates_two_segment_with_build_metadata( + "0.6", + "0.7.0+build.1", + "0.7.0+build.1", + Some("0.7") + )] #[case::blocks_downgrade_prerelease_to_stable("2.0.0-rc.37", "1.1.20", "1.1.20", None)] #[case::blocks_downgrade_same_major("2.5.0", "2.4.0", "2.4.0", None)] #[case::allows_prerelease_to_prerelease( @@ -300,6 +445,30 @@ mod tests { )] #[case::allows_prerelease_to_stable("2.0.0-rc.37", "2.0.0", "2.0.0", Some("2.0.0"))] #[case::equal_semver_skipped("1.2.3", "1.2.3", "1.2.3", None)] + // Compound ranges (multiple clauses joined by `||`, `,`, or an internal + // space) are skipped: rewriting them would drop every clause beyond the + // first, silently mangling the user's intent. The manifest stays + // byte-identical until a real multi-clause rewriter exists. + #[case::npm_or_range_skipped("^17.0.0 || ^18.0.0", "18.3.1", "18.3.1", None)] + #[case::npm_space_and_range_skipped(">=18.0.0 <19.0.0", "18.3.1", "18.3.1", None)] + #[case::cargo_comma_and_range_skipped(">=1.0, <2.0", "1.5.0", "1.5.0", None)] + #[case::pypi_comma_and_range_skipped(">=2.28.0,<3.0", "2.31.0", "2.31.0", None)] + #[case::npm_hyphen_range_preserved("1.2.3 - 1.5.0", "2.0.0", "2.0.0", None)] + // Single clause with a leading-operator space (`>= 1.0.0`): the space + // sits before the digit run and `strip_range_prefix` removes it along + // with `>=`, so the helper sees a clean `"1.0.0"` and the dep still + // updates. The prefix on the rewritten value preserves the original + // operator + space exactly. + #[case::single_clause_with_leading_space_still_updates( + ">= 1.0.0", + "1.5.0", + "1.5.0", + Some(">= 1.5.0") + )] + // pnpm `catalog:` protocol has no numeric version; `strip_range_prefix` + // returns empty string. The guard must skip this to avoid corrupting the + // manifest (e.g., `"catalog:"` → `"catalog:18.2.0"`). + #[case::skips_pnpm_catalog_protocol("catalog:", "18.2.0", "18.2.0", None)] fn compute_updates_single( #[case] current: &str, #[case] latest: &str, @@ -307,7 +476,7 @@ mod tests { #[case] expected_to: Option<&str>, ) { let (deps, resolved) = single(current, latest, selected); - let updates = compute_updates(&deps, &resolved, ManifestKind::PackageJson); + let updates = compute_updates(&deps, &resolved); match expected_to { Some(to) => { assert_eq!( @@ -327,7 +496,7 @@ mod tests { #[test] fn compute_updates_sets_package_name() { let (deps, resolved) = single("^17.0.0", "18.2.0", "18.2.0"); - let updates = compute_updates(&deps, &resolved, ManifestKind::PackageJson); + let updates = compute_updates(&deps, &resolved); assert_eq!(updates[0].name, "pkg"); } @@ -341,7 +510,7 @@ mod tests { detail: "not found".to_owned(), }), )]; - assert!(compute_updates(&deps, &resolved, ManifestKind::PackageJson).is_empty()); + assert!(compute_updates(&deps, &resolved).is_empty()); } #[test] @@ -354,7 +523,7 @@ mod tests { selected: None, }), )]; - assert!(compute_updates(&deps, &resolved, ManifestKind::PackageJson).is_empty()); + assert!(compute_updates(&deps, &resolved).is_empty()); } #[test] @@ -365,6 +534,7 @@ mod tests { name: "b".to_owned(), current_req: "~2.0.0".to_owned(), section: DependencySection::DevDependencies, + path_version: None, }, dep("c", "^3.0.0"), ]; @@ -391,7 +561,7 @@ mod tests { }), ), ]; - let updates = compute_updates(&deps, &resolved, ManifestKind::PackageJson); + let updates = compute_updates(&deps, &resolved); // a: ^1.0.0 -> ^1.5.0 (update), b: ~2.0.0 -> ~2.5.0 (update), c: same (no update) assert_eq!(updates.len(), 2); assert_eq!(updates[0].name, "a"); @@ -406,6 +576,7 @@ mod tests { name: "a".to_owned(), current_req: "^1.0.0".to_owned(), section: DependencySection::DevDependencies, + path_version: None, }]; let resolved = vec![( 0, @@ -414,11 +585,57 @@ mod tests { selected: Some("2.0.0".to_owned()), }), )]; - let updates = compute_updates(&deps, &resolved, ManifestKind::PackageJson); + let updates = compute_updates(&deps, &resolved); assert_eq!(updates[0].section, DependencySection::DevDependencies); assert_eq!(updates[0].from, "^1.0.0"); } + /// Container tags carry a build variant that the numeric truncation would + /// destroy, and their registry already picked an existing tag form. Both + /// the variant and the resolved precision must survive verbatim. + #[rstest] + // The headline case: the `-alpine` variant travels with the bump, and the + // 1-segment pin stays 1-segment because `22-alpine` is a published tag. + #[case::keeps_variant_and_precision("20-alpine", "22-alpine", Some("22-alpine"))] + // An escalated tag (no moving `22` published) must NOT be truncated back + // down to the pin's precision — that would name a tag nobody published. + #[case::keeps_escalated_precision("20", "22.3.0", Some("22.3.0"))] + // A `v`-prefixed pin gets its prefix re-glued exactly once. + #[case::reattaches_v_prefix("v3.1.6", "3.2.0", Some("v3.2.0"))] + // Already current → no row. + #[case::already_current("22-alpine", "22-alpine", None)] + // The never-downgrade guard still applies across variant tags. + #[case::rejects_downgrade("22-alpine", "20-alpine", None)] + fn compute_updates_docker_image_cases( + #[case] current: &str, + #[case] selected: &str, + #[case] expected_to: Option<&str>, + ) { + let deps = vec![DependencySpec { + name: "node".to_owned(), + current_req: current.to_owned(), + section: DependencySection::DockerImage, + path_version: None, + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some(selected.to_owned()), + selected: Some(selected.to_owned()), + }), + )]; + + let updates = compute_updates(&deps, &resolved); + match expected_to { + Some(to) => { + assert_eq!(updates.len(), 1, "expected one update, got: {updates:?}"); + assert_eq!(updates[0].to, to); + assert_eq!(updates[0].from, current); + } + None => assert!(updates.is_empty(), "expected no update, got: {updates:?}"), + } + } + #[test] fn compute_updates_github_skips_precision_truncation() { // The GitHub registry already resolved the exact tag form (here an @@ -429,6 +646,7 @@ mod tests { name: "astral-sh/setup-uv".to_owned(), current_req: "v7".to_owned(), section: DependencySection::GitHubActions, + path_version: None, }]; let resolved = vec![( 0, @@ -437,11 +655,69 @@ mod tests { selected: Some("8.1.0".to_owned()), }), )]; - let updates = compute_updates(&deps, &resolved, ManifestKind::GitHubWorkflow); + let updates = compute_updates(&deps, &resolved); assert_eq!(updates.len(), 1); assert_eq!(updates[0].to, "v8.1.0"); } + /// Build a single path-dependency input: `current_req` is what the manifest + /// declares, `local` is the version of the crate on disk (carried via + /// `path_version` and echoed by the short-circuiting registry as `selected`). + fn path_dep_input(current: &str, local: &str) -> (Vec, ResolvedInput) { + let deps = vec![DependencySpec { + name: "hwp".to_owned(), + current_req: current.to_owned(), + section: DependencySection::Dependencies, + path_version: Some(local.to_owned()), + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some(local.to_owned()), + selected: Some(local.to_owned()), + }), + )]; + (deps, resolved) + } + + #[rstest] + // current manifest version, local crate version, expected `to` (None = no update). + #[case::upgrade("0.2.0", "0.3.0", Some("0.3.0"))] + // Exact sync allows a downgrade — the never-downgrade safety net is bypassed + // for path deps because the local crate's version is the source of truth. + #[case::downgrade("0.3.0", "0.2.0", Some("0.2.0"))] + #[case::already_in_sync("0.3.0", "0.3.0", None)] + #[case::preserves_caret("^0.2.0", "0.3.0", Some("^0.3.0"))] + #[case::preserves_tilde("~0.2.0", "0.3.0", Some("~0.3.0"))] + // Pin precision preserved for plain numeric local versions. + #[case::preserves_two_segment_precision("0.2", "0.3.1", Some("0.3"))] + // Same precision-preservation, but the local crate carries build metadata + // (`+build`). The safety gate now strips it before the digit check, so + // the manifest's 2-segment precision is preserved (`0.3` instead of the + // previous fall-through to the full `0.3.0`). + #[case::path_dep_two_segment_local_with_build_metadata("0.2", "0.3.0+build", Some("0.3"))] + #[case::full_version_at_three_segments("0.2.0", "0.3.1", Some("0.3.1"))] + // A wildcard requirement already accepts whatever the local crate is, so + // there is nothing to sync. Rewriting it would silently narrow the + // manifest's intent from "any version" to one pinned number. + #[case::wildcard_requirement_is_left_alone("*", "0.3.0", None)] + fn compute_updates_path_dep_cases( + #[case] current: &str, + #[case] local: &str, + #[case] expected: Option<&str>, + ) { + let (deps, resolved) = path_dep_input(current, local); + let updates = compute_updates(&deps, &resolved); + match expected { + Some(to) => { + assert_eq!(updates.len(), 1, "expected one update, got: {updates:?}"); + assert_eq!(updates[0].to, to); + assert_eq!(updates[0].from, current); + } + None => assert!(updates.is_empty(), "expected no update, got: {updates:?}"), + } + } + #[rstest] // dependency names, include filters, exclude filters, expected surviving names. #[case::no_filters(&["react", "lodash"], &[], &[], &["react", "lodash"])] @@ -458,24 +734,11 @@ mod tests { let deps: Vec = names.iter().map(|n| dep(n, "^1.0.0")).collect(); let include: Vec = include.iter().map(|s| (*s).to_owned()).collect(); let exclude: Vec = exclude.iter().map(|s| (*s).to_owned()).collect(); - let result = filter_deps(&deps, &include, &exclude); + let result = filter_deps(deps, &include, &exclude); let got: Vec<&str> = result.iter().map(|d| d.name.as_str()).collect(); assert_eq!(got, expected); } - #[rstest] - #[case("5", "5.0.0")] - #[case("5.1", "5.1.0")] - #[case("5.1.0", "5.1.0")] - #[case("5.1.2.3", "5.1.2.3")] // 4+ segments left as-is - #[case("5.1.0-rc.1", "5.1.0-rc.1")] - #[case("1.2-beta", "1.2.0-beta")] - #[case("5-beta", "5.0.0-beta")] - #[case("", "")] - fn pad_to_three_segments_cases(#[case] input: &str, #[case] expected: &str) { - assert_eq!(pad_to_three_segments(input), expected); - } - #[rstest] #[case("1", 1)] #[case("1.0", 2)] @@ -483,7 +746,7 @@ mod tests { #[case("1.0.0-beta.1", 3)] #[case("", 0)] fn count_version_segments_cases(#[case] input: &str, #[case] expected: usize) { - assert_eq!(count_version_segments(input), expected); + assert_eq!(count_numeric_segments(input), expected); } #[rstest] @@ -510,10 +773,17 @@ mod tests { #[case("4.2", true)] #[case("4.0.0", true)] #[case("1.2.3.4", true)] - // Pre-release / build suffixes are NOT safe to truncate. + // Build metadata (`+…`) is stripped before checking — the operation this + // gate guards (`truncate_version`) drops it too, so these are safe. + #[case("1.2.3+build", true)] + #[case("4.0.0+build.7", true)] + #[case("5.1+meta-7", true)] + // Pre-release (`-…`) is still rejected; a prerelease must never be + // silently promoted to a stable-looking pin. #[case("4.0.0-beta.0", false)] - #[case("1.2.3+build", false)] #[case("5.1-rc.1", false)] + // Pre-release present even after stripping build metadata: still unsafe. + #[case("4.0.0-beta+build", false)] // Malformed / empty segments. #[case("", false)] #[case("5.", false)] @@ -522,4 +792,39 @@ mod tests { fn is_plain_numeric_version_cases(#[case] input: &str, #[case] expected: bool) { assert_eq!(is_plain_numeric_version(input), expected); } + + #[rstest] + // Compound — multiple clauses joined by `||`, `,`, or an internal space. + #[case::or_clauses_with_spaces("17.0.0 || ^18.0.0", true)] + #[case::or_clauses_tight("17.0.0||18.0.0", true)] + #[case::cargo_comma_and("1.0, <2.0", true)] + #[case::pypi_comma_and("2.28.0,<3.0", true)] + #[case::npm_space_and_lt("18.0.0 <19.0.0", true)] + #[case::npm_space_and_caret("18.0.0 ^19.0.0", true)] + #[case::npm_space_and_tilde("18.0.0 ~19.0.0", true)] + #[case::npm_space_and_eq("18.0.0 =19.0.0", true)] + #[case::npm_space_and_bang("18.0.0 !=19.0.0", true)] + #[case::npm_space_and_digit("18.0.0 19.0.0", true)] + #[case::npm_space_and_lowercase_x("1.2 x 2.3 x", true)] + #[case::npm_space_and_uppercase_x("1.2 X 2.3 X", true)] + #[case::npm_space_and_wildcard("1.0.0 *", true)] + // Single clauses — must NOT be classified as compound. + #[case::single_full("1.2.3", false)] + #[case::single_two("1.2", false)] + #[case::single_major("1", false)] + #[case::single_prerelease("1.2.3-rc.1", false)] + #[case::single_with_build("1.2.3+build.7", false)] + // After `strip_range_prefix` the leading operator (and any space that + // follows it) is already gone, so a permissive `">= 1.0.0"` arrives + // here as `"1.0.0"` and stays a single clause. + #[case::leading_space_stripped("1.0.0", false)] + #[case::empty("", false)] + // npm hyphen ranges (`1.2.3 - 1.5.0` meaning `>=1.2.3 <=1.5.0`): the + // right-of-space byte is `-`, which is treated as a clause-start + // continuation so the dep is left byte-identical instead of being + // silently rewritten to a single bare version. + #[case::npm_hyphen_range_caught("1.2.3 - 1.5.0", true)] + fn is_compound_range_cases(#[case] input: &str, #[case] expected: bool) { + assert_eq!(is_compound_range(input), expected); + } } diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index c2543b6..3488624 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -4,19 +4,38 @@ use tracing::{debug, info, trace}; use dependency_check_updates_core::manifest::ManifestHandler; use dependency_check_updates_core::{ - DcuError, DependencySpec, ManifestKind, ResolvedVersion, Scanner, + DcuError, DependencySection, DependencySpec, ManifestKind, ResolvedVersion, Scanner, + TargetLevel, }; +use dependency_check_updates_docker::{ComposeHandler, DockerRegistry, DockerfileHandler}; use dependency_check_updates_github::{GitHubActionsRegistry, GitHubHandler}; use dependency_check_updates_node::{NodeHandler, NpmRegistry}; use dependency_check_updates_python::{PyPiRegistry, PythonHandler}; use dependency_check_updates_rust::{CratesIoRegistry, RustHandler}; -use crate::cleanup::cleanup_and_render; +use crate::cleanup_progress::{cleanup_with_progress, targets_for_job}; use crate::cli::{Cli, OutputFormat}; use crate::logging::init_tracing; use crate::output; use crate::pipeline::{compute_updates, filter_deps}; +// Per-kind handlers are stateless zero-sized unit structs, so a single +// `&'static` reference per kind suffices for the whole process. The previous +// `Box::new(XHandler)` per manifest performed a heap allocation per discovered +// manifest (boxing even ZSTs round-trips through the global allocator under +// the current `Box` lowering); the static ref keeps the dispatch +// pointer-sized while removing that allocation. +static NODE_HANDLER: NodeHandler = NodeHandler; +static RUST_HANDLER: RustHandler = RustHandler; +static PYTHON_HANDLER: PythonHandler = PythonHandler; +static GITHUB_HANDLER: GitHubHandler = GitHubHandler; +static DOCKERFILE_HANDLER: DockerfileHandler = DockerfileHandler; +static COMPOSE_HANDLER: ComposeHandler = ComposeHandler; + +/// Resolved version batch from a registry, indexed into the dependency slice +/// the registry was handed. +type ResolvedBatch = Vec<(usize, Result)>; + /// Entry point for bridge crates (napi, maturin). /// /// Parses CLI args from the given slice and runs the full pipeline. @@ -38,6 +57,166 @@ pub async fn main(args: &[String]) -> Result<(), DcuError> { Ok(()) } +/// Shared entry-point used by the `dependency-check-updates` and `dcu` +/// binaries. +/// +/// Parses args, runs the pipeline, and translates the outcome into an +/// `ExitCode` so the binary `main` can return it directly. Centralising +/// this here keeps the error-printing and `--error-level` policy in one +/// place instead of duplicating the `match` arms in every entry point. +#[cfg(not(tarpaulin_include))] +pub async fn run_cli() -> std::process::ExitCode { + use std::process::ExitCode; + + let cli = crate::cli::parse_args(); + let error_level = cli.error_level; + + match run(&cli).await { + Ok(has_updates) => { + // error_level 2: exit 1 if any updates were found (CI mode) + if error_level >= 2 && has_updates { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } + } + Err(e) => { + eprintln!("Error: {e}"); + ExitCode::FAILURE + } + } +} + +/// Construct a registry only when at least one non-empty job of the matching +/// kind exists. Returns `Some(R)` if any job has non-empty deps and matching +/// kind, otherwise `None`. +fn registry_for( + jobs: &[ManifestJob], + kind: ManifestKind, + make: impl FnOnce() -> R, +) -> Option { + jobs.iter() + .any(|job| !job.deps.is_empty() && job.manifest_ref.kind == kind) + .then(make) +} + +/// Construct a registry only when at least one collected dependency belongs to +/// `section`. +/// +/// Manifest kind is the wrong gate for the GitHub Actions and container +/// registries: a single workflow file can carry `uses:` directives, `image:` +/// containers, or both, so what decides whether a registry is needed is the +/// section of the dependencies actually found — not the file they came from. +fn registry_for_section( + jobs: &[ManifestJob], + section: DependencySection, + make: impl FnOnce() -> R, +) -> Option { + jobs.iter() + .any(|job| job.deps.iter().any(|dep| dep.section == section)) + .then(make) +} + +/// Resolve a workflow's dependencies, routing each section to the registry +/// that can answer it. +/// +/// A workflow mixes two ecosystems: `uses:` refs resolve against the GitHub +/// Tags API, `image:` containers against an OCI registry. Each sub-batch +/// reports indices into its own slice, so they are mapped back onto the +/// caller's indices and re-sorted into document order before returning. +#[cfg(not(tarpaulin_include))] +async fn resolve_workflow( + deps: &[DependencySpec], + github: Option<&GitHubActionsRegistry>, + docker: Option<&DockerRegistry>, + target: TargetLevel, +) -> ResolvedBatch { + // Fast path: a workflow with no container images — by far the common + // shape — needs no partitioning and therefore no `DependencySpec` clones. + if deps + .iter() + .all(|dep| dep.section == DependencySection::GitHubActions) + { + return resolve_with(github, deps, |r, d| r.resolve_batch(d, target)).await; + } + + let (action_indices, image_indices) = partition_by_section(deps); + let actions: Vec = action_indices.iter().map(|&i| deps[i].clone()).collect(); + let images: Vec = image_indices.iter().map(|&i| deps[i].clone()).collect(); + + let (resolved_actions, resolved_images) = futures::join!( + resolve_with(github, &actions, |r, d| r.resolve_batch(d, target)), + resolve_with(docker, &images, |r, d| r.resolve_batch(d, target)), + ); + + merge_resolved( + &action_indices, + resolved_actions, + &image_indices, + resolved_images, + ) +} + +/// Split a workflow's dependency indices into (`uses:` refs, container images). +/// +/// Returns index lists rather than sub-slices because the two groups are +/// interleaved in the source file, and the caller must map each sub-batch's +/// results back onto the original positions. +fn partition_by_section(deps: &[DependencySpec]) -> (Vec, Vec) { + (0..deps.len()).partition(|&i| deps[i].section == DependencySection::GitHubActions) +} + +/// Map two sub-batches back onto the caller's indices and restore document +/// order. +/// +/// Each registry reports indices into the slice it was handed, so +/// `resolved_actions[i].0` indexes `action_indices`, not `deps`. Sorting at the +/// end means reported rows follow the file rather than the order the two +/// registries happened to be queried in. +fn merge_resolved( + action_indices: &[usize], + resolved_actions: ResolvedBatch, + image_indices: &[usize], + resolved_images: ResolvedBatch, +) -> ResolvedBatch { + let mut results = Vec::with_capacity(resolved_actions.len() + resolved_images.len()); + results.extend( + resolved_actions + .into_iter() + .map(|(i, result)| (action_indices[i], result)), + ); + results.extend( + resolved_images + .into_iter() + .map(|(i, result)| (image_indices[i], result)), + ); + results.sort_unstable_by_key(|(idx, _)| *idx); + results +} + +/// Resolve a batch against `registry`, yielding an empty batch when there is +/// nothing to ask or nobody to ask. +/// +/// The `None` arm is unreachable for a non-empty batch: the gating in [`run`] +/// constructs a registry whenever a dependency of its section exists. +async fn resolve_with<'a, R, F, Fut>( + registry: Option<&'a R>, + deps: &'a [DependencySpec], + call: F, +) -> ResolvedBatch +where + // The lifetimes are named so the future `call` returns may borrow both + // arguments; an elided closure signature would force that future to + // outlive the very references it holds. + F: FnOnce(&'a R, &'a [DependencySpec]) -> Fut, + Fut: std::future::Future, +{ + match registry { + Some(registry) if !deps.is_empty() => call(registry, deps).await, + _ => Vec::new(), + } +} + /// Run the dependency-check-updates CLI with the given configuration. /// /// # Errors @@ -52,7 +231,7 @@ pub async fn run(cli: &Cli) -> Result { init_tracing(cli.verbose); - let use_color = std::env::var("NO_COLOR").is_err(); + let use_color = output::color_enabled(std::env::var_os("NO_COLOR")); let root = std::env::current_dir().map_err(|e| DcuError::Io { path: PathBuf::from("."), source: e, @@ -76,9 +255,9 @@ pub async fn run(cli: &Cli) -> Result { } // 2. Parse all manifests and collect deps (sync — fast, no I/O wait) - let mut manifest_jobs: Vec = Vec::new(); + let mut manifest_jobs: Vec = Vec::with_capacity(manifests.len()); - for manifest_ref in &manifests { + for manifest_ref in manifests { let text = std::fs::read_to_string(&manifest_ref.path).map_err(|e| DcuError::Io { path: manifest_ref.path.clone(), source: e, @@ -92,33 +271,33 @@ pub async fn run(cli: &Cli) -> Result { info!(path = %display_path, kind = %manifest_ref.kind, "processing manifest"); - let handler: Box = match manifest_ref.kind { - ManifestKind::PackageJson => Box::new(NodeHandler), - ManifestKind::CargoToml => Box::new(RustHandler), - ManifestKind::PyProjectToml => Box::new(PythonHandler), - ManifestKind::GitHubWorkflow => Box::new(GitHubHandler), + let handler: &'static (dyn ManifestHandler + Send + Sync) = match manifest_ref.kind { + ManifestKind::PackageJson => &NODE_HANDLER, + ManifestKind::CargoToml => &RUST_HANDLER, + ManifestKind::PyProjectToml => &PYTHON_HANDLER, + ManifestKind::GitHubWorkflow => &GITHUB_HANDLER, + ManifestKind::Dockerfile => &DOCKERFILE_HANDLER, + ManifestKind::DockerCompose => &COMPOSE_HANDLER, }; let parsed = handler.parse(&text, &manifest_ref.path)?; - debug!( - total_deps = parsed.dependencies.len(), - "parsed dependencies" - ); + let total_deps = parsed.dependencies.len(); + debug!(total_deps, "parsed dependencies"); for dep in &parsed.dependencies { trace!(name = %dep.name, version = %dep.current_req, section = %dep.section, "found dependency"); } - let deps = filter_deps(&parsed.dependencies, &cli.filter, &cli.reject); - if deps.len() != parsed.dependencies.len() { + let deps = filter_deps(parsed.dependencies, &cli.filter, &cli.reject); + if deps.len() != total_deps { debug!( - before = parsed.dependencies.len(), + before = total_deps, after = deps.len(), "filtered dependencies" ); } manifest_jobs.push(ManifestJob { - manifest_ref: manifest_ref.clone(), + manifest_ref, display_path, text, handler, @@ -134,26 +313,69 @@ pub async fn run(cli: &Cli) -> Result { total_deps, "resolving all versions concurrently" ); - let npm_registry = NpmRegistry::new(); - let crates_registry = CratesIoRegistry::new(); - let pypi_registry = PyPiRegistry::new(); - let github_registry = GitHubActionsRegistry::new(); + // Construct each registry only when at least one non-empty job of the + // matching kind exists, so a scan touching only (say) Cargo.toml never + // builds the npm/PyPI/GitHub HTTP clients. Each registry is still created + // at most once and shared by reference across every manifest of its kind. + let npm_registry = registry_for(&manifest_jobs, ManifestKind::PackageJson, NpmRegistry::new); + let crates_registry = registry_for( + &manifest_jobs, + ManifestKind::CargoToml, + CratesIoRegistry::new, + ); + let pypi_registry = registry_for( + &manifest_jobs, + ManifestKind::PyProjectToml, + PyPiRegistry::new, + ); + // The last two are gated by dependency section, not manifest kind: a + // workflow can contribute `uses:` refs, container images, or both, and a + // Dockerfile / Compose file contributes only images. + let github_registry = registry_for_section( + &manifest_jobs, + DependencySection::GitHubActions, + GitHubActionsRegistry::new, + ); + let docker_registry = registry_for_section( + &manifest_jobs, + DependencySection::DockerImage, + DockerRegistry::new, + ); - let mut resolve_futures = Vec::new(); + let mut resolve_futures = Vec::with_capacity(manifest_jobs.len()); for (job_idx, job) in manifest_jobs.iter().enumerate() { if !job.deps.is_empty() { - let npm = &npm_registry; - let crates_io = &crates_registry; - let pypi = &pypi_registry; - let github = &github_registry; + let npm = npm_registry.as_ref(); + let crates_io = crates_registry.as_ref(); + let pypi = pypi_registry.as_ref(); + let github = github_registry.as_ref(); + let docker = docker_registry.as_ref(); resolve_futures.push(async move { + // The gating above guarantees the registry matching this job's + // kind is `Some`; the `None` arms are unreachable for a + // non-empty job and return an empty batch without panicking. let resolved = match job.manifest_ref.kind { - ManifestKind::PackageJson => npm.resolve_batch(&job.deps, cli.target).await, - ManifestKind::CargoToml => crates_io.resolve_batch(&job.deps, cli.target).await, - ManifestKind::PyProjectToml => pypi.resolve_batch(&job.deps, cli.target).await, + ManifestKind::PackageJson => match npm { + Some(npm) => npm.resolve_batch(&job.deps, cli.target).await, + None => Vec::new(), + }, + ManifestKind::CargoToml => match crates_io { + Some(crates_io) => crates_io.resolve_batch(&job.deps, cli.target).await, + None => Vec::new(), + }, + ManifestKind::PyProjectToml => match pypi { + Some(pypi) => pypi.resolve_batch(&job.deps, cli.target).await, + None => Vec::new(), + }, + // A workflow can hold both ecosystems, so it fans out to + // both registries and merges the results. ManifestKind::GitHubWorkflow => { - github.resolve_batch(&job.deps, cli.target).await + resolve_workflow(&job.deps, github, docker, cli.target).await } + ManifestKind::Dockerfile | ManifestKind::DockerCompose => match docker { + Some(docker) => docker.resolve_batch(&job.deps, cli.target).await, + None => Vec::new(), + }, }; (job_idx, resolved) }); @@ -171,12 +393,15 @@ pub async fn run(cli: &Cli) -> Result { // 4. Print results and apply updates (sequential — needs ordered output) let mut any_updates = false; + let mut cleanup_targets = Vec::new(); + let remove_lockfile = cli.remove_lockfile_requested(); + let remove_installed = cli.remove_installed_requested(); for (job_idx, job) in manifest_jobs.iter().enumerate() { + cleanup_targets.extend(targets_for_job(job, remove_lockfile, remove_installed)); print!("{}", output::render_header(&job.display_path, cli.upgrade)); if job.deps.is_empty() { - print!("{}", cleanup_and_render(job, cli)); print!( "{}", output::render_footer(&job.display_path, cli.upgrade, false, use_color) @@ -194,7 +419,7 @@ pub async fn run(cli: &Cli) -> Result { "registry resolution complete" ); - let updates = compute_updates(&job.deps, resolved, job.manifest_ref.kind); + let updates = compute_updates(&job.deps, resolved); debug!(updates = updates.len(), "computed planned updates"); for update in &updates { @@ -203,7 +428,6 @@ pub async fn run(cli: &Cli) -> Result { if updates.is_empty() { info!(path = %job.display_path, "all dependencies up to date"); - print!("{}", cleanup_and_render(job, cli)); print!( "{}", output::render_footer(&job.display_path, cli.upgrade, false, use_color) @@ -228,24 +452,228 @@ pub async fn run(cli: &Cli) -> Result { info!(path = %job.display_path, "manifest updated successfully"); } - print!("{}", cleanup_and_render(job, cli)); print!( "{}", output::render_footer(&job.display_path, cli.upgrade, true, use_color) ); } + print!("{}", cleanup_with_progress(cleanup_targets).await); + Ok(any_updates) } -/// Resolved version batch from a registry. -type ResolvedBatch = Vec<(usize, Result)>; - /// Intermediate state for processing a single manifest. pub(crate) struct ManifestJob { pub(crate) manifest_ref: dependency_check_updates_core::ManifestRef, pub(crate) display_path: String, pub(crate) text: String, - pub(crate) handler: Box, + pub(crate) handler: &'static (dyn ManifestHandler + Send + Sync), pub(crate) deps: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + use dependency_check_updates_core::ManifestRef; + use rstest::rstest; + use std::path::PathBuf; + + fn dep(name: &str, section: DependencySection) -> DependencySpec { + DependencySpec { + name: name.to_owned(), + current_req: "1".to_owned(), + section, + path_version: None, + } + } + + fn job(kind: ManifestKind, deps: Vec) -> ManifestJob { + ManifestJob { + manifest_ref: ManifestRef { + path: PathBuf::from("manifest"), + kind, + }, + display_path: "manifest".to_owned(), + text: String::new(), + handler: &NODE_HANDLER, + deps, + } + } + + fn resolved(idx: usize, version: &str) -> (usize, Result) { + ( + idx, + Ok(ResolvedVersion { + latest: Some(version.to_owned()), + selected: Some(version.to_owned()), + }), + ) + } + + /// A registry must be built only when a job of that kind has work — an + /// empty job, or a job of another kind, must not pull an HTTP client into + /// existence. + #[rstest] + #[case::matching_kind_with_deps( + ManifestKind::PackageJson, + vec![dep("react", DependencySection::Dependencies)], + ManifestKind::PackageJson, + true + )] + #[case::matching_kind_but_empty( + ManifestKind::PackageJson, + vec![], + ManifestKind::PackageJson, + false + )] + #[case::other_kind( + ManifestKind::CargoToml, + vec![dep("serde", DependencySection::Dependencies)], + ManifestKind::PackageJson, + false + )] + fn registry_for_cases( + #[case] job_kind: ManifestKind, + #[case] deps: Vec, + #[case] wanted: ManifestKind, + #[case] expected: bool, + ) { + let jobs = vec![job(job_kind, deps)]; + assert_eq!(registry_for(&jobs, wanted, || ()).is_some(), expected); + } + + /// Section gating is what lets one workflow file pull in both registries — + /// and what keeps a Dockerfile from constructing the GitHub client. + #[rstest] + #[case::workflow_actions_only( + vec![dep("actions/checkout", DependencySection::GitHubActions)], + true, + false + )] + #[case::workflow_images_only(vec![dep("node", DependencySection::DockerImage)], false, true)] + #[case::workflow_mixed( + vec![ + dep("actions/checkout", DependencySection::GitHubActions), + dep("node", DependencySection::DockerImage), + ], + true, + true + )] + #[case::neither(vec![dep("react", DependencySection::Dependencies)], false, false)] + #[case::no_deps_at_all(vec![], false, false)] + fn registry_for_section_cases( + #[case] deps: Vec, + #[case] wants_github: bool, + #[case] wants_docker: bool, + ) { + let jobs = vec![job(ManifestKind::GitHubWorkflow, deps)]; + assert_eq!( + registry_for_section(&jobs, DependencySection::GitHubActions, || ()).is_some(), + wants_github + ); + assert_eq!( + registry_for_section(&jobs, DependencySection::DockerImage, || ()).is_some(), + wants_docker + ); + } + + #[rstest] + #[case::interleaved( + &[ + DependencySection::GitHubActions, + DependencySection::DockerImage, + DependencySection::GitHubActions, + ], + vec![0, 2], + vec![1] + )] + #[case::actions_only(&[DependencySection::GitHubActions], vec![0], vec![])] + #[case::images_only(&[DependencySection::DockerImage], vec![], vec![0])] + #[case::empty(&[], vec![], vec![])] + fn partition_by_section_cases( + #[case] sections: &[DependencySection], + #[case] expected_actions: Vec, + #[case] expected_images: Vec, + ) { + let deps: Vec = sections.iter().map(|s| dep("x", *s)).collect(); + assert_eq!( + partition_by_section(&deps), + (expected_actions, expected_images) + ); + } + + /// The merge is where an off-by-one would silently attach one dependency's + /// resolved version to another's row, so it is pinned explicitly: each + /// sub-batch index maps through its own index list, and the output follows + /// the document. + #[test] + fn merge_resolved_remaps_indices_and_restores_document_order() { + // Document order: [0] action, [1] image, [2] action. + let action_indices = vec![0, 2]; + let image_indices = vec![1]; + + let merged = merge_resolved( + &action_indices, + vec![resolved(0, "v5"), resolved(1, "v9")], + &image_indices, + vec![resolved(0, "22-alpine")], + ); + + let rows: Vec<(usize, String)> = merged + .into_iter() + .map(|(idx, result)| (idx, result.unwrap().selected.unwrap())) + .collect(); + assert_eq!( + rows, + vec![ + (0, "v5".to_owned()), + (1, "22-alpine".to_owned()), + (2, "v9".to_owned()), + ] + ); + } + + #[test] + fn merge_resolved_handles_an_empty_side() { + // A workflow whose container registry produced nothing must still + // report its action rows unchanged. + let merged = merge_resolved( + &[0, 1], + vec![resolved(0, "v5"), resolved(1, "v9")], + &[], + vec![], + ); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].0, 0); + assert_eq!(merged[1].0, 1); + } + + /// With no registry — or nothing to ask it — the batch resolves to empty + /// without touching the network. + #[rstest] + #[case::no_registry(None, vec![dep("node", DependencySection::DockerImage)])] + #[case::no_deps(Some(()), vec![])] + #[tokio::test] + async fn resolve_with_short_circuits( + #[case] registry: Option<()>, + #[case] deps: Vec, + ) { + let batch = resolve_with(registry.as_ref(), &deps, |(), _| async { + panic!("registry must not be called") + }) + .await; + assert!(batch.is_empty()); + } + + #[tokio::test] + async fn resolve_with_calls_the_registry_when_there_is_work() { + let deps = vec![dep("node", DependencySection::DockerImage)]; + let batch = resolve_with(Some(&()), &deps, |(), d| { + let count = d.len(); + async move { (0..count).map(|i| resolved(i, "22")).collect() } + }) + .await; + assert_eq!(batch.len(), 1); + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d3f36e1..b577cde 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dependency-check-updates-core" version = "0.1.15" -description = "Shared traits and types for dependency-check-updates (ManifestHandler, RegistryClient, Scanner)" +description = "Shared traits and types for dependency-check-updates (ManifestHandler, Scanner)" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -15,19 +15,23 @@ categories.workspace = true [dependencies] thiserror.workspace = true miette.workspace = true -serde.workspace = true -serde_json.workspace = true ignore.workspace = true tracing.workspace = true -tokio.workspace = true reqwest.workspace = true node-semver.workspace = true -semver = "1.0" -pep440_rs = "0.7.3" +semver.workspace = true +pep440_rs.workspace = true +futures.workspace = true +toml_edit.workspace = true [dev-dependencies] rstest.workspace = true tempfile.workspace = true +criterion.workspace = true + +[[bench]] +name = "hot" +harness = false [lints] workspace = true diff --git a/crates/core/benches/hot.rs b/crates/core/benches/hot.rs new file mode 100644 index 0000000..1a2a0c7 --- /dev/null +++ b/crates/core/benches/hot.rs @@ -0,0 +1,108 @@ +//! Criterion micro-benchmarks for the hottest pure functions in the core crate. +//! +//! These cover the per-tag / per-dependency code that the scan -> resolve -> +//! patch pipeline drives thousands of times per `dcu` run, and that the +//! autonomous improvement loop most often touches: range-prefix stripping, +//! three-segment padding, and the shared `select_version` algorithm. +//! +//! Run with `cargo bench`. Criterion stores a baseline under +//! `target/criterion/`, so a second `cargo bench` after a change prints the +//! per-function delta (e.g. `change: [-6.1% -4.8%] Performance has improved`). + +use std::hint::black_box; +use std::time::Duration; + +use criterion::{Criterion, criterion_group, criterion_main}; +use dependency_check_updates_core::{ + TargetLevel, pad_to_three_segments, select_version, strip_range_prefix, +}; + +/// Representative requirement strings spanning every range operator the +/// stripper handles, plus a wildcard (no digits) and a pre-release tail. +const REQS: &[&str] = &[ + "^1.2.3", + "~2.0.0", + ">=3.4.5", + "=4.0.0", + "1.0.0", + "^0.25.11", + ">2.1", + "*", + "~1.2.3-beta.1", +]; + +/// Representative version strings spanning 1/2/3/4-segment shapes plus +/// pre-release and build-metadata suffixes and the empty input. +const VERSIONS: &[&str] = &[ + "5", + "5.1", + "5.1.0", + "5.1.2.3", + "5.1.0-rc.1", + "1.2-beta", + "18.2.0", + "0.25.11+build.7", + "", +]; + +fn bench_strip_range_prefix(c: &mut Criterion) { + c.bench_function("strip_range_prefix", |b| { + b.iter(|| { + for &req in REQS { + black_box(strip_range_prefix(black_box(req))); + } + }); + }); +} + +fn bench_pad_to_three_segments(c: &mut Criterion) { + c.bench_function("pad_to_three_segments", |b| { + b.iter(|| { + for &v in VERSIONS { + black_box(pad_to_three_segments(black_box(v))); + } + }); + }); +} + +fn bench_select_version(c: &mut Criterion) { + // A realistic, pre-sorted (ascending) candidate list like a registry returns. + let mut candidates: Vec = (0u64..40) + .flat_map(|major| (0u64..5).map(move |minor| semver::Version::new(major, minor, major % 3))) + .collect(); + candidates.sort(); + let current = semver::Version::new(20, 1, 0); + + c.bench_function("select_version_latest", |b| { + b.iter(|| { + black_box(select_version( + black_box(Some(¤t)), + black_box(candidates.as_slice()), + TargetLevel::Latest, + Some("39.4.2"), + Some("39.4.2"), + )) + }); + }); + c.bench_function("select_version_minor", |b| { + b.iter(|| { + black_box(select_version( + black_box(Some(¤t)), + black_box(candidates.as_slice()), + TargetLevel::Minor, + Some("39.4.2"), + Some("39.4.2"), + )) + }); + }); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(60) + .warm_up_time(Duration::from_millis(500)) + .measurement_time(Duration::from_secs(2)); + targets = bench_strip_range_prefix, bench_pad_to_three_segments, bench_select_version +} +criterion_main!(benches); diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 048876c..34a62ed 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -61,22 +61,13 @@ pub enum DcuError { detail: String, }, - /// A version string could not be parsed as semver. - #[error("invalid semver: {input}")] - #[diagnostic(code(dependency_check_updates::semver_error))] - SemverParse { - /// The input that failed to parse. - input: String, - /// Human-readable failure detail. - detail: String, - }, - /// No recognized manifest was found at the given location. #[error("no manifest found in {path}")] #[diagnostic( code(dependency_check_updates::no_manifest), help( - "run dependency-check-updates in a directory containing package.json, or use --manifest" + "run dependency-check-updates in a directory containing package.json, Cargo.toml, \ + pyproject.toml, or .github/workflows/*.yml, or use --manifest" ) )] NoManifest { @@ -120,13 +111,6 @@ mod tests { } } - fn semver_parse_err() -> DcuError { - DcuError::SemverParse { - input: "not.a.version".to_owned(), - detail: "invalid semver format".to_owned(), - } - } - /// Verifies the [`std::fmt::Display`] output for every variant. Variants /// whose message embeds a path use `Path::display()` so the expected /// string is computed at case time to stay correct on every platform. @@ -151,10 +135,6 @@ mod tests { registry_lookup_err(), "registry lookup failed for package `lodash`: connection timeout".to_owned() )] - #[case::semver_parse( - semver_parse_err(), - "invalid semver: not.a.version".to_owned() - )] fn dcu_error_display(#[case] err: DcuError, #[case] expected: String) { assert_eq!(err.to_string(), expected); } diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 4f98e7b..5997e62 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -6,10 +6,14 @@ //! is exposed as a constant because each registry wraps the client in its own //! [`tokio::sync::Semaphore`]. +use std::future::Future; use std::time::Duration; use reqwest::Client; +use crate::error::DcuError; +use crate::types::{DependencySpec, ResolvedVersion}; + /// Default ceiling on concurrent in-flight registry requests. /// /// GitHub's registry uses a lower limit (its unauthenticated rate budget is @@ -17,7 +21,7 @@ use reqwest::Client; pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 10; /// Default per-request timeout, in seconds. -pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; +const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; /// Build the shared `reqwest::Client` used by every registry. /// @@ -40,3 +44,67 @@ pub fn build_client() -> Client { .build() .expect("failed to create HTTP client") } + +/// Send a pre-built request and verify the HTTP status in one step. +/// +/// Centralises the two-step pattern that every registry repeats: +/// +/// 1. `.send().await` — maps a network error to +/// `DcuError::RegistryLookup { package, detail: e.to_string() }`. +/// 2. `!response.status().is_success()` — maps a non-2xx status to +/// `DcuError::RegistryLookup { package, detail: "HTTP " }`. +/// +/// URL construction, headers, and semaphore acquisition remain in each +/// registry's own `fetch_*` method; only the send + status-check is shared. +/// +/// # Errors +/// +/// Returns `DcuError::RegistryLookup` on a network failure or a non-2xx +/// HTTP status. +pub async fn send_checked( + request: reqwest::RequestBuilder, + package: &str, +) -> Result { + let response = request.send().await.map_err(|e| DcuError::RegistryLookup { + package: package.to_owned(), + detail: e.to_string(), + })?; + + let status = response.status(); + if !status.is_success() { + return Err(DcuError::RegistryLookup { + package: package.to_owned(), + detail: format!("HTTP {status}"), + }); + } + + Ok(response) +} + +/// Drive a batch of per-dependency resolutions concurrently while preserving +/// the input ordering. +/// +/// Every per-ecosystem registry whose `resolve_version` is an `async fn` over +/// a single dep funnels through this helper. Centralising the +/// `join_all`-over-`enumerate().map()` pipeline keeps the concurrency model — +/// no `tokio::spawn`, so no per-dep `JoinHandle` allocation and no +/// `DependencySpec`/`Arc` clones (each dep is borrowed for the duration of the +/// `.await`) — defined in one place. Real concurrency still comes from each +/// registry's `Semaphore`-gated HTTP requests, which cooperate via `.await`. +/// +/// `join_all` preserves the source order of the iterator, so callers can rely +/// on `result[i].0 == i` and no post-sort is needed. +pub async fn resolve_batch_concurrent<'a, F, Fut>( + deps: &'a [DependencySpec], + resolve_one: F, +) -> Vec<(usize, Result)> +where + F: Fn(&'a DependencySpec) -> Fut, + Fut: Future>, +{ + futures::future::join_all(deps.iter().enumerate().map(|(idx, dep)| { + let fut = resolve_one(dep); + async move { (idx, fut.await) } + })) + .await +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 97122d0..7d06524 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,7 +2,6 @@ //! //! Defines shared traits that each language crate implements: //! - [`ManifestHandler`] — parse manifests and apply format-preserving updates -//! - [`RegistryClient`] — resolve versions from package registries //! - [`Scanner`] — discover manifest files in a directory #![warn(missing_docs)] @@ -10,19 +9,30 @@ pub mod error; pub mod http; pub mod manifest; -pub mod style; +pub mod patch; +pub mod toml_decor; pub mod types; pub mod util; pub mod version; +pub mod yaml_scan; // Re-export commonly used types pub use error::DcuError; -pub use http::{DEFAULT_MAX_CONCURRENT_REQUESTS, DEFAULT_REQUEST_TIMEOUT_SECS, build_client}; -pub use manifest::{ManifestHandler, ParsedManifest, RegistryClient, ScanResult, Scanner}; -pub use style::{FileStyle, IndentStyle, LineEnding}; +pub use http::{ + DEFAULT_MAX_CONCURRENT_REQUESTS, build_client, resolve_batch_concurrent, send_checked, +}; +pub use manifest::{ManifestHandler, ParsedManifest, Scanner}; +pub use patch::{Patch, apply_byte_patches}; +pub use toml_decor::replace_string_preserving_decor; pub use types::{ BumpType, DependencySection, DependencySpec, ManifestKind, ManifestRef, PlannedUpdate, ResolvedVersion, TargetLevel, }; -pub use util::{collect_task_results, strip_range_prefix}; -pub use version::{SelectableVersion, select_version}; +pub use util::{ + count_numeric_segments, is_version_ref, pad_to_three_segments, split_numeric_head, + strip_range_prefix, +}; +pub use version::{ + SelectableVersion, current_req_is_prerelease, highest_stable, parse_and_select, select_version, +}; +pub use yaml_scan::scalar_value_bounds; diff --git a/crates/core/src/manifest.rs b/crates/core/src/manifest.rs index 12e426d..b06eccd 100644 --- a/crates/core/src/manifest.rs +++ b/crates/core/src/manifest.rs @@ -3,14 +3,12 @@ //! Each language crate (dependency-check-updates-node, dependency-check-updates-rust, dependency-check-updates-python) implements these //! traits for its specific manifest format and registry. -use std::path::{Path, PathBuf}; +use std::path::Path; use tracing::debug; use crate::error::DcuError; -use crate::types::{ - DependencySpec, ManifestKind, ManifestRef, PlannedUpdate, ResolvedVersion, TargetLevel, -}; +use crate::types::{DependencySpec, ManifestKind, ManifestRef, PlannedUpdate}; // --------------------------------------------------------------------------- // ManifestHandler — parse manifests and apply updates @@ -45,36 +43,10 @@ pub trait ManifestHandler { pub struct ParsedManifest { /// Reference to the manifest file. pub manifest_ref: ManifestRef, - /// The original raw text (preserved for patching). - pub original_text: String, /// Collected dependencies. pub dependencies: Vec, } -// --------------------------------------------------------------------------- -// RegistryClient — resolve versions from a package registry -// --------------------------------------------------------------------------- - -/// A client for a package registry (npm, crates.io, `PyPI`). -/// -/// Each language crate provides an implementation. -/// Uses async methods for network I/O. -pub trait RegistryClient: Send + Sync { - /// Resolve the target version for a single dependency. - fn resolve_version( - &self, - dep: &DependencySpec, - target: TargetLevel, - ) -> impl std::future::Future> + Send; - - /// Resolve versions for a batch of dependencies concurrently. - fn resolve_batch( - &self, - deps: &[DependencySpec], - target: TargetLevel, - ) -> impl std::future::Future)>> + Send; -} - // --------------------------------------------------------------------------- // Scanner — discover manifest files // --------------------------------------------------------------------------- @@ -86,10 +58,17 @@ impl Scanner { /// Find manifest files in the given directory (non-recursive). /// /// Returns all recognized manifests at the root level (`package.json`, - /// `Cargo.toml`, `pyproject.toml`, `action.yml` / `action.yaml`) and every - /// `*.yml`/`*.yaml` directly under `.github/workflows/`. The root-level - /// `action.yml` is included so that authors of single-action repos see - /// their own manifest without needing `-d`. + /// `Cargo.toml`, `pyproject.toml`, `action.yml` / `action.yaml`, + /// `Dockerfile`, and the Compose project files) and every `*.yml`/`*.yaml` + /// directly under `.github/workflows/`. The root-level `action.yml` is + /// included so that authors of single-action repos see their own manifest + /// without needing `-d`. + /// + /// Only the canonical Docker file names are probed here. The suffixed + /// forms (`Dockerfile.dev`, `compose.prod.yaml`) are still recognised by + /// [`ManifestKind::from_path`], so `-d` and `--manifest` pick them up — + /// enumerating every possible variant at the root would mean a full + /// `read_dir` on every invocation just to catch a rare layout. #[must_use] pub fn scan_dir(root: &Path) -> Vec { let mut manifests = Vec::new(); @@ -100,6 +79,11 @@ impl Scanner { "pyproject.toml", "action.yml", "action.yaml", + "Dockerfile", + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", ]; for filename in &candidates { @@ -123,11 +107,17 @@ impl Scanner { manifests.push(ManifestRef { path, kind }); } } - // Stable order so output is reproducible across platforms — `read_dir` - // is OS-dependent (NTFS vs ext4 give different orderings). - manifests.sort_by(|a, b| a.path.cmp(&b.path)); } + // Stable order so output is reproducible across platforms regardless + // of whether `.github/workflows/` exists. The static `candidates` + // array order (package.json → Cargo.toml → pyproject.toml → action.{yml,yaml}) + // is NOT alphabetical, and `read_dir` ordering is OS-dependent (NTFS + // vs ext4 give different orderings), so we sort unconditionally here + // to match `scan_deep`'s already-unconditional sort below. + // Paths are unique (each manifest file appears at most once), so stable + // ordering is unobservable; use sort_unstable_by for better performance. + manifests.sort_unstable_by(|a, b| a.path.cmp(&b.path)); manifests } @@ -156,7 +146,8 @@ impl Scanner { /// Recursively find manifest files using the `ignore` crate. /// /// Respects `.gitignore`, `.ignore`, and skips common directories - /// (`node_modules`, `target`, `.venv`, `dist`, `build`, `vendor`). + /// (`node_modules`, `target`, Python local env/package dirs, `dist`, + /// `build`, `vendor`). /// Walks INTO `.github` even though it is a hidden directory because /// workflow YAMLs live there; without this exception deep scan would miss /// every GitHub Actions manifest. @@ -164,14 +155,6 @@ impl Scanner { pub fn scan_deep(root: &Path) -> Vec { use ignore::WalkBuilder; - let manifest_names: &[&str] = &[ - "package.json", - "Cargo.toml", - "pyproject.toml", - "action.yml", - "action.yaml", - ]; - let walker = WalkBuilder::new(root) // `hidden(false)` so `.github/` is traversed. The filter_entry // below still skips other hidden dirs that are not interesting. @@ -190,45 +173,40 @@ impl Scanner { } !matches!( name.as_ref(), - "node_modules" | "target" | "dist" | "build" | "vendor" | "__pycache__" + "node_modules" + | "target" + | "__pypackages__" + | "dist" + | "build" + | "vendor" + | "__pycache__" ) }) .build(); let mut manifests = Vec::new(); + // Single source of truth for what counts as a manifest: + // `ManifestKind::from_path` already encodes the full decision tree + // (the 5 named files + `.github/workflows/*.{yml,yaml}`). Delegating + // here removes the previously-duplicated `manifest_names` list and + // `is_workflow_yaml` parent-traversal block, and drops the per-file + // `to_string_lossy()` allocation in the deep-walk hot path. for entry in walker.flatten() { if !entry.file_type().is_some_and(|ft| ft.is_file()) { continue; } - - let path = entry.path(); - let file_name = entry.file_name().to_string_lossy(); - let is_workflow_yaml = matches!( - path.extension().and_then(|s| s.to_str()), - Some("yml" | "yaml") - ) && path - .parent() - .and_then(|p| p.file_name()) - .and_then(|s| s.to_str()) - == Some("workflows") - && path - .parent() - .and_then(Path::parent) - .and_then(|p| p.file_name()) - .and_then(|s| s.to_str()) - == Some(".github"); - - if manifest_names.contains(&file_name.as_ref()) || is_workflow_yaml { - let path = entry.into_path(); - if let Some(kind) = ManifestKind::from_path(&path) { - debug!(path = %path.display(), kind = %kind, "deep scan: found manifest"); - manifests.push(ManifestRef { path, kind }); - } - } + let Some(kind) = ManifestKind::from_path(entry.path()) else { + continue; + }; + let path = entry.into_path(); + debug!(path = %path.display(), kind = %kind, "deep scan: found manifest"); + manifests.push(ManifestRef { path, kind }); } - manifests.sort_by(|a, b| a.path.cmp(&b.path)); + // Paths are unique (each manifest file appears at most once), so stable + // ordering is unobservable; use sort_unstable_by for better performance. + manifests.sort_unstable_by(|a, b| a.path.cmp(&b.path)); manifests } @@ -270,23 +248,6 @@ impl Scanner { } } -// --------------------------------------------------------------------------- -// ScanResult — output of the scan+resolve pipeline -// --------------------------------------------------------------------------- - -/// Result of scanning and resolving a single manifest file. -#[derive(Debug)] -pub struct ScanResult { - /// The manifest file that was scanned. - pub manifest_ref: ManifestRef, - /// Path to the manifest. - pub path: PathBuf, - /// Updates that can be applied. - pub updates: Vec, - /// Whether the file was actually modified (only true after apply). - pub modified: bool, -} - #[cfg(test)] mod tests { use super::*; @@ -335,6 +296,19 @@ mod tests { "name: test\nruns:\n using: composite\n", ManifestKind::GitHubWorkflow )] + // Docker manifests must surface without `-d` for the common single-service + // repo layout (Dockerfile + compose file at the root). + #[case::root_dockerfile("Dockerfile", "FROM node:20-alpine\n", ManifestKind::Dockerfile)] + #[case::root_compose_yaml( + "compose.yaml", + "services:\n web:\n image: nginx:1.27\n", + ManifestKind::DockerCompose + )] + #[case::root_docker_compose_yml( + "docker-compose.yml", + "services:\n web:\n image: nginx:1.27\n", + ManifestKind::DockerCompose + )] fn scan_dir_finds_single_manifest( tmp: TempDir, #[case] rel_path: &str, @@ -454,6 +428,38 @@ mod tests { assert_eq!(result.unwrap().len(), 2); } + /// Regression: `scan_dir`'s sort used to fire only when + /// `.github/workflows/` existed, leaving non-workflow projects on the + /// static `candidates` array order + /// (`package.json` → `Cargo.toml` → `pyproject.toml` → `action.{yml,yaml}`) + /// — which is NOT alphabetical. `scan_deep` always sorted, so the same + /// layout produced different ordering between `dcu` and `dcu -d`. The + /// CI-consumable `--format json` inherited that inconsistency. This test + /// fails on the pre-fix code (Cargo.toml appears at index 1, package.json + /// at index 0) and passes after the sort moves out of the `if let`. + #[test] + fn test_scan_dir_sorts_when_no_workflows_dir() { + let dir = TempDir::new().unwrap(); + create_temp_manifest(dir.path(), "package.json", "{}"); + create_temp_manifest(dir.path(), "Cargo.toml", "[package]"); + // No `.github/workflows/` directory — the sort must still fire. + assert!(!dir.path().join(".github").join("workflows").exists()); + + let manifests = Scanner::scan_dir(dir.path()); + assert_eq!(manifests.len(), 2); + // Alphabetical: 'C' (0x43) < 'p' (0x70), so Cargo.toml sorts first. + assert!( + manifests[0].path.ends_with("Cargo.toml"), + "Cargo.toml should sort first: {:?}", + manifests.iter().map(|m| &m.path).collect::>() + ); + assert!( + manifests[1].path.ends_with("package.json"), + "package.json should sort second: {:?}", + manifests.iter().map(|m| &m.path).collect::>() + ); + } + #[test] fn test_scan_dir_workflow_files_sorted_alphabetically() { // read_dir order is OS-dependent (NTFS != ext4). Sort guarantees @@ -521,7 +527,7 @@ mod tests { assert!(manifests[0].path.ends_with("CI.yml")); } - /// Deep scan must prune `node_modules` (and friends) yet still descend + /// Deep scan must prune installed/generated directories yet still descend /// into normal nested directories. Exercises the `!matches!` filter /// closure on both branches: `node_modules` → false (pruned), /// `pkgs`/`app` → true (kept). @@ -529,15 +535,23 @@ mod tests { fn test_scan_deep_prunes_excluded_dirs_but_keeps_nested() { let dir = TempDir::new().unwrap(); - // Excluded: node_modules with a manifest inside that must NOT surface. - let nm = dir.path().join("node_modules").join("foo"); - std::fs::create_dir_all(&nm).unwrap(); - create_temp_manifest(&nm, "package.json", "{}"); + // Excluded: dependency/env directories with manifests inside that must + // NOT surface. + for rel in ["node_modules/foo", "__pypackages__/3.13/lib/pkg"] { + let excluded = dir.path().join(rel); + std::fs::create_dir_all(&excluded).unwrap(); + create_temp_manifest(&excluded, "package.json", "{}"); + } // Kept: normal nested workspace member. let app = dir.path().join("pkgs").join("app"); std::fs::create_dir_all(&app).unwrap(); create_temp_manifest(&app, "Cargo.toml", "[package]\nname = \"app\""); + // Ordinary files sit beside manifests everywhere; the walker must skip + // the ones `ManifestKind::from_path` does not recognise instead of + // trying to parse them. + create_temp_manifest(&app, "README.md", "# app"); + create_temp_manifest(&app, "build.gradle", ""); let manifests = Scanner::scan_deep(dir.path()); @@ -550,12 +564,13 @@ mod tests { "expected pkgs/app/Cargo.toml in results: {:?}", manifests.iter().map(|m| &m.path).collect::>() ); - // The excluded node_modules manifest must NOT be found. + // The excluded dependency/env manifests must NOT be found. assert!( - !manifests - .iter() - .any(|m| m.path.to_string_lossy().contains("node_modules")), - "node_modules must be pruned: {:?}", + !manifests.iter().any(|m| matches!( + m.path.to_string_lossy().as_ref(), + p if p.contains("node_modules") || p.contains("__pypackages__") + )), + "dependency/env dirs must be pruned: {:?}", manifests.iter().map(|m| &m.path).collect::>() ); } diff --git a/crates/core/src/patch.rs b/crates/core/src/patch.rs new file mode 100644 index 0000000..0be4347 --- /dev/null +++ b/crates/core/src/patch.rs @@ -0,0 +1,142 @@ +//! Shared byte-range patch primitive used by every text-based ecosystem patcher. +//! +//! Both `crates/github` (workflow YAML) and `crates/node` (`package.json`) +//! previously redeclared the same [`Patch`] struct and the same overlap-checked +//! descending-`replace_range` loop. Centralising the algorithm here keeps the +//! one place where format-preserving updates touch raw bytes a single source of +//! truth; future text ecosystems (additional YAML flavours, INI, etc.) can lean +//! on the same primitive instead of copy-pasting a third time. +//! +//! The primitive only knows about byte offsets — semantic re-validation (e.g. +//! re-parsing the resulting text as JSON in `crates/node`) stays in the +//! ecosystem-specific wrappers. + +/// A patch: replace bytes `[start..end)` with `new_value`. +/// +/// `start` and `end` are byte offsets into the original text and must lie on +/// `char` boundaries for `String::replace_range` to accept them. +#[derive(Debug, Clone)] +pub struct Patch { + /// Inclusive byte offset of the first byte to replace. + pub start: usize, + /// Exclusive byte offset just past the last byte to replace. + pub end: usize, + /// Replacement text inserted in place of `[start..end)`. + pub new_value: String, +} + +/// Errors returned by [`apply_byte_patches`]. +#[derive(Debug, thiserror::Error)] +pub enum PatchError { + /// Two patches resolved to overlapping byte ranges. Should not happen in + /// practice — the per-ecosystem scanners emit disjoint spans — but the + /// check is cheap and prevents silent corruption. + #[error("overlapping patches detected")] + OverlappingPatches, +} + +/// Apply raw byte-range patches to `original`. +/// +/// Patches are applied from highest to lowest byte offset so each replacement +/// leaves the offsets of later (i.e. earlier-in-the-list) patches intact. +/// +/// # Errors +/// +/// Returns [`PatchError::OverlappingPatches`] if any two patches touch the +/// same byte range — a sentinel for upstream scanner bugs. +pub fn apply_byte_patches(original: &str, patches: &[Patch]) -> Result { + if patches.is_empty() { + return Ok(original.to_owned()); + } + + let mut sorted: Vec<&Patch> = patches.iter().collect(); + // Unstable sort is safe here: two patches with the same `start` are + // necessarily overlapping (every patch has `end > start`), so the + // immediately-following overlap check rejects the only case where + // stable-vs-unstable ordering would be observable. The stable sort's + // auxiliary-array allocation and slightly larger constant factor buy + // nothing in that scenario. + sorted.sort_unstable_by_key(|p| std::cmp::Reverse(p.start)); + + for window in sorted.windows(2) { + // sorted descending: window[0].start >= window[1].start, so window[1] + // (the lower-start patch) must end at-or-before window[0] starts. + if window[1].end > window[0].start { + return Err(PatchError::OverlappingPatches); + } + } + + let mut result = original.to_owned(); + for patch in &sorted { + result.replace_range(patch.start..patch.end, &patch.new_value); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_patch_list_is_identity() { + let text = "hello world"; + let result = apply_byte_patches(text, &[]).unwrap(); + assert_eq!(result, text); + } + + #[test] + fn single_patch_replaces_range() { + let text = "hello world"; + let patches = vec![Patch { + start: 6, + end: 11, + new_value: "rust!".to_owned(), + }]; + let result = apply_byte_patches(text, &patches).unwrap(); + assert_eq!(result, "hello rust!"); + } + + #[test] + fn multiple_disjoint_patches_apply_back_to_front() { + // Length-changing patches in arbitrary order: the descending + // application order preserves the lower-offset patch's positions. + let text = "a-bb-ccc"; + let patches = vec![ + Patch { + start: 0, + end: 1, + new_value: "AAAA".to_owned(), + }, + Patch { + start: 5, + end: 8, + new_value: "C".to_owned(), + }, + Patch { + start: 2, + end: 4, + new_value: "BB".to_owned(), + }, + ]; + let result = apply_byte_patches(text, &patches).unwrap(); + assert_eq!(result, "AAAA-BB-C"); + } + + #[test] + fn overlapping_patches_error() { + let patches = vec![ + Patch { + start: 0, + end: 5, + new_value: "a".to_owned(), + }, + Patch { + start: 3, + end: 10, + new_value: "b".to_owned(), + }, + ]; + let result = apply_byte_patches("abcdefghijk", &patches); + assert!(matches!(result, Err(PatchError::OverlappingPatches))); + } +} diff --git a/crates/core/src/style.rs b/crates/core/src/style.rs deleted file mode 100644 index 425b09a..0000000 --- a/crates/core/src/style.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! File formatting detection (indentation, line endings, trailing newline) -//! used to preserve a manifest's original style when rewriting it. - -/// Detected indentation style. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IndentStyle { - /// Indentation by the given number of spaces per level. - Spaces(u8), - /// Indentation by a single tab character per level. - Tab, -} - -impl Default for IndentStyle { - fn default() -> Self { - Self::Spaces(2) - } -} - -/// Detected line ending style. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LineEnding { - /// Unix line endings (`\n`). - #[default] - Lf, - /// Windows line endings (`\r\n`). - CrLf, -} - -/// The detected formatting style of a file. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FileStyle { - /// Detected indentation style. - pub indent: IndentStyle, - /// Detected line-ending style. - pub line_ending: LineEnding, - /// Whether the file ends with a trailing newline. - pub trailing_newline: bool, -} - -impl Default for FileStyle { - fn default() -> Self { - Self { - indent: IndentStyle::default(), - line_ending: LineEnding::default(), - trailing_newline: true, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_file_style_default() { - let style = FileStyle::default(); - assert_eq!(style.indent, IndentStyle::Spaces(2)); - assert_eq!(style.line_ending, LineEnding::Lf); - assert!(style.trailing_newline); - } - - #[test] - fn test_indent_style_default() { - let indent = IndentStyle::default(); - assert_eq!(indent, IndentStyle::Spaces(2)); - } - - #[test] - fn test_line_ending_default() { - let ending = LineEnding::default(); - assert_eq!(ending, LineEnding::Lf); - } -} diff --git a/crates/core/src/toml_decor.rs b/crates/core/src/toml_decor.rs new file mode 100644 index 0000000..a67fc92 --- /dev/null +++ b/crates/core/src/toml_decor.rs @@ -0,0 +1,29 @@ +//! Cross-ecosystem helpers for format-preserving edits of `toml_edit` docs. +//! +//! [`replace_string_preserving_decor`] was previously copy-pasted byte-for-byte +//! into both `crates/rust/src/parser.rs` (`replace_version_string_preserving_decor`) +//! and `crates/python/src/parser.rs` (`replace_string_preserving_decor`) — see +//! the comment block at the top of the python copy that explicitly mirrored +//! the rust copy. Centralising it here keeps the "preserve `Formatted` +//! decor across a value rewrite" invariant in one place so a future `toml_edit` +//! tightening lands in a single function. + +use toml_edit::Formatted; + +/// Replace the inner string of a [`Formatted`] while preserving its +/// surrounding decor (leading/trailing whitespace, attached comments). +/// +/// Both Cargo.toml and pyproject.toml patchers call this when rewriting a +/// dependency `version` value, so the format-preservation guarantees of every +/// TOML-backed manifest in the workspace go through this single helper. A +/// naïve `*s = Formatted::new(new)` would drop the decor and collapse e.g. +/// `version = "1.0"` into `version ="1.0"`, breaking the byte-for-byte +/// preservation tests (`apply_updates_inline_table_preserves_decor_byte_for_byte`, +/// `apply_updates_full_table_preserves_decor_byte_for_byte`, +/// `apply_updates_pep621_preserves_multiline_format`, +/// `apply_updates_patches_pep621_optional_dependencies`, …). +pub fn replace_string_preserving_decor(s: &mut Formatted, new: String) { + let mut next = Formatted::new(new); + std::mem::swap(next.decor_mut(), s.decor_mut()); + *s = next; +} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 2302127..94e3292 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -2,7 +2,6 @@ //! dependency sections, version targets, and the parsed/resolved/planned //! value objects that flow through the scan → resolve → patch pipeline. -use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// The kind of package manifest file. @@ -17,6 +16,12 @@ pub enum ManifestKind { /// GitHub Actions workflow (`.github/workflows/*.yml` /`*.yaml`) or /// composite action definition (`action.yml` / `action.yaml`). GitHubWorkflow, + /// Docker build definition (`Dockerfile`, `Dockerfile.`, + /// `.Dockerfile`). + Dockerfile, + /// Docker Compose project file (`compose.y(a)ml`, + /// `docker-compose.y(a)ml`, and their `..` variants). + DockerCompose, } impl ManifestKind { @@ -25,7 +30,9 @@ impl ManifestKind { /// GitHub workflow detection requires the parent directory context because /// arbitrary `*.yml` files exist throughout repos and only files under /// `.github/workflows/` or named `action.yml`/`action.yaml` are treated as - /// workflow manifests. + /// workflow manifests. Docker manifests, by contrast, are recognised by + /// file name alone — `Dockerfile` and `compose.yaml` mean the same thing + /// wherever they sit in the tree. #[must_use] pub fn from_path(path: &std::path::Path) -> Option { let file_name = path.file_name()?.to_str()?; @@ -35,17 +42,24 @@ impl ManifestKind { "pyproject.toml" => Some(Self::PyProjectToml), "action.yml" | "action.yaml" => Some(Self::GitHubWorkflow), _ => { - // Workflow YAMLs live in `.github/workflows/`. - if matches!( + // Docker file names are position-independent, so they are + // tried before the parent-directory-sensitive workflow check. + if is_dockerfile_name(file_name) { + return Some(Self::Dockerfile); + } + let is_yaml = matches!( path.extension().and_then(|s| s.to_str()), Some("yml" | "yaml") - ) && path - .parent() - .and_then(|p| p.file_name()) - .and_then(|s| s.to_str()) - == Some("workflows") - && path - .parent() + ); + if is_yaml && is_compose_name(file_name) { + return Some(Self::DockerCompose); + } + let parent = path.parent(); + // Workflow YAMLs live in `.github/workflows/`. + if is_yaml + && parent.and_then(|p| p.file_name()).and_then(|s| s.to_str()) + == Some("workflows") + && parent .and_then(std::path::Path::parent) .and_then(|p| p.file_name()) .and_then(|s| s.to_str()) @@ -60,6 +74,37 @@ impl ManifestKind { } } +/// Whether `file_name` names a Docker build definition. +/// +/// Accepts the three conventions Docker tooling itself understands: +/// `Dockerfile`, the `Dockerfile.` form (`Dockerfile.dev`), and the +/// `.Dockerfile` form (`api.Dockerfile`) that editors use for syntax +/// highlighting. Matching is case-sensitive because `docker build` resolves +/// the default file name case-sensitively on Linux, and a lowercase +/// `dockerfile` in a repo is far more likely to be prose than a build file. +fn is_dockerfile_name(file_name: &str) -> bool { + file_name == "Dockerfile" + || file_name.starts_with("Dockerfile.") + || file_name.ends_with(".Dockerfile") +} + +/// Whether `file_name` (already known to carry a `.yml`/`.yaml` extension) +/// names a Compose project file. +/// +/// Covers the modern `compose.yaml` spelling, the legacy `docker-compose.yml` +/// one, and the profile-suffixed variants of both +/// (`docker-compose.override.yml`, `compose.prod.yaml`). The stem check is +/// exact-or-dot-prefixed so unrelated files such as `composer.yml` are not +/// swept in. +fn is_compose_name(file_name: &str) -> bool { + let stem = file_name + .rsplit_once('.') + .map_or(file_name, |(stem, _ext)| stem); + ["compose", "docker-compose"] + .iter() + .any(|base| stem == *base || stem.starts_with(&format!("{base}."))) +} + impl std::fmt::Display for ManifestKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -67,6 +112,8 @@ impl std::fmt::Display for ManifestKind { Self::CargoToml => write!(f, "Cargo.toml"), Self::PyProjectToml => write!(f, "pyproject.toml"), Self::GitHubWorkflow => write!(f, "GitHub workflow"), + Self::Dockerfile => write!(f, "Dockerfile"), + Self::DockerCompose => write!(f, "Docker Compose"), } } } @@ -81,16 +128,15 @@ pub struct ManifestRef { } /// Which dependency section a dependency belongs to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DependencySection { - /// Node.js `dependencies`. + /// Node.js `dependencies` and Poetry `[tool.poetry.dependencies]`. Dependencies, - /// Node.js `devDependencies`. + /// Node.js `devDependencies`, Poetry `[tool.poetry.dev-dependencies]`, and PEP 735 `[dependency-groups]`. DevDependencies, /// Node.js `peerDependencies`. PeerDependencies, - /// Node.js `optionalDependencies`. + /// Node.js `optionalDependencies` and PEP 621 `[project.optional-dependencies]`. OptionalDependencies, /// Rust `[build-dependencies]`. BuildDependencies, @@ -100,6 +146,9 @@ pub enum DependencySection { ProjectDependencies, /// GitHub Actions `uses:` directives in workflows / composite actions. GitHubActions, + /// Container image references: Dockerfile `FROM` instructions and the + /// `image:` key of Compose services / workflow job containers. + DockerImage, } impl DependencySection { @@ -115,6 +164,7 @@ impl DependencySection { Self::WorkspaceDependencies => "workspace.dependencies", Self::ProjectDependencies => "project.dependencies", Self::GitHubActions => "uses", + Self::DockerImage => "image", } } } @@ -126,7 +176,7 @@ impl std::fmt::Display for DependencySection { } /// A dependency found in a manifest file. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct DependencySpec { /// Package name as written in the manifest. pub name: String, @@ -134,6 +184,14 @@ pub struct DependencySpec { pub current_req: String, /// Section the dependency was found in. pub section: DependencySection, + /// For a local **path dependency** that also declares a `version` + /// (e.g. Cargo's `dep = { path = "../dep", version = "0.2.0" }`), this + /// holds the version resolved from the local crate's own manifest — the + /// source of truth for what the `version` field should be synced to. + /// + /// `None` for ordinary registry dependencies, which resolve their target + /// version from the package registry (`crates.io`, `npm`, `PyPI`) instead. + pub path_version: Option, } /// The target level for version updates. @@ -231,7 +289,21 @@ mod tests { #[case::action_yaml("path/to/action.yaml", Some(ManifestKind::GitHubWorkflow))] #[case::nested_workflow("repo/.github/workflows/test.yml", Some(ManifestKind::GitHubWorkflow))] #[case::unknown_extension("unknown.txt", None)] - #[case::unrelated_yml_ignored("docker-compose.yml", None)] + // Docker build definitions are recognised by file name alone, anywhere in + // the tree — all three spellings `docker build -f` accepts. + #[case::dockerfile_plain("Dockerfile", Some(ManifestKind::Dockerfile))] + #[case::dockerfile_suffixed("Dockerfile.dev", Some(ManifestKind::Dockerfile))] + #[case::dockerfile_prefixed("apps/api.Dockerfile", Some(ManifestKind::Dockerfile))] + #[case::dockerfile_nested("services/web/Dockerfile", Some(ManifestKind::Dockerfile))] + // Compose files: both spellings, both extensions, profile variants. + #[case::compose_legacy("docker-compose.yml", Some(ManifestKind::DockerCompose))] + #[case::compose_modern("compose.yaml", Some(ManifestKind::DockerCompose))] + #[case::compose_override("docker-compose.override.yml", Some(ManifestKind::DockerCompose))] + #[case::compose_profile("compose.prod.yaml", Some(ManifestKind::DockerCompose))] + // Guards against over-matching: a lowercase prose file and a similarly + // named-but-unrelated YAML must stay unrecognised. + #[case::lowercase_dockerfile_ignored("dockerfile", None)] + #[case::composer_yml_ignored("composer.yml", None)] fn manifest_kind_from_path_cases(#[case] path: &str, #[case] expected: Option) { assert_eq!( ManifestKind::from_path(std::path::Path::new(path)), @@ -246,6 +318,8 @@ mod tests { #[case::cargo_toml(ManifestKind::CargoToml, "Cargo.toml")] #[case::pyproject_toml(ManifestKind::PyProjectToml, "pyproject.toml")] #[case::github_workflow(ManifestKind::GitHubWorkflow, "GitHub workflow")] + #[case::dockerfile(ManifestKind::Dockerfile, "Dockerfile")] + #[case::docker_compose(ManifestKind::DockerCompose, "Docker Compose")] fn manifest_kind_display_cases(#[case] kind: ManifestKind, #[case] expected: &str) { assert_eq!(kind.to_string(), expected); } @@ -298,6 +372,7 @@ mod tests { )] #[case::project_dependencies(DependencySection::ProjectDependencies, "project.dependencies")] #[case::github_actions(DependencySection::GitHubActions, "uses")] + #[case::docker_image(DependencySection::DockerImage, "image")] fn dependency_section_label_cases(#[case] section: DependencySection, #[case] expected: &str) { assert_eq!(section.label(), expected); } diff --git a/crates/core/src/util.rs b/crates/core/src/util.rs index 7c7b408..daa4487 100644 --- a/crates/core/src/util.rs +++ b/crates/core/src/util.rs @@ -1,10 +1,10 @@ //! Small cross-ecosystem helpers shared by the registry clients. //! //! These were previously copy-pasted byte-for-byte into each ecosystem crate -//! (npm, crates.io, `PyPI`). Centralising them keeps the concurrency and -//! version-string handling in one place. +//! (npm, crates.io, `PyPI`). Centralising them keeps the version-string +//! handling in one place. -use tracing::warn; +use std::borrow::Cow; /// Strip a leading semver range operator from a requirement string, returning /// the bare numeric version portion. @@ -24,21 +24,133 @@ pub fn strip_range_prefix(req_str: &str) -> &str { req_str.trim_start_matches(|c: char| !c.is_ascii_digit()) } -/// Await a set of spawned tasks, collecting their values and logging (then -/// dropping) any that panicked. +/// Split `v` between its numeric head (ASCII digits + `.`) and the rest. /// -/// A `JoinError` means the task panicked or was cancelled; such tasks are -/// omitted from the result rather than aborting the whole batch, so one bad -/// registry lookup never sinks the others. -pub async fn collect_task_results(handles: Vec>) -> Vec { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - match handle.await { - Ok(result) => results.push(result), - Err(e) => warn!("task join error: {e}"), - } +/// Returns `(numeric, rest)`. Borrow-only; no allocation. Used by every site +/// that needs to find where the bare numeric prefix of a version string ends +/// (`1.2.3-beta` → `("1.2.3", "-beta")`, `v5` → `("", "v5")`, `5` → +/// `("5", "")`). Centralises the predicate previously duplicated across +/// `pad_to_three_segments`, `cli::pipeline::{count_version_segments, +/// truncate_version}`, and `github::registry::{tag_numeric_str, ref_precision, +/// pick_existing_ref}` so future tightenings (Unicode digit handling, treating +/// `+` build-metadata bytes as part of the head, etc.) land in one place. +/// +/// ``` +/// use dependency_check_updates_core::split_numeric_head; +/// assert_eq!(split_numeric_head("1.2.3"), ("1.2.3", "")); +/// assert_eq!(split_numeric_head("1.2.3-beta"), ("1.2.3", "-beta")); +/// assert_eq!(split_numeric_head("1.2.3+build"), ("1.2.3", "+build")); +/// assert_eq!(split_numeric_head("v5"), ("", "v5")); +/// assert_eq!(split_numeric_head(""), ("", "")); +/// ``` +#[must_use] +pub fn split_numeric_head(v: &str) -> (&str, &str) { + let i = v + .bytes() + .position(|b| !(b.is_ascii_digit() || b == b'.')) + .unwrap_or(v.len()); + v.split_at(i) +} + +/// Return true if `git_ref` looks like a version we want to track, rather +/// than a moving pointer or a content hash. +/// +/// Rules (both must hold): +/// 1. After stripping an optional leading `v`, the first char is a digit. +/// 2. The ref is NOT a commit SHA — heuristically defined as "all hex digits, +/// length >= 7, no dots", which matches both short and full SHAs while +/// letting `v5`, `v5.1`, `v5.1.0`, `2024.01.01`, `1.0-beta` through. +/// +/// Shared by the GitHub Actions ref scanner (`@main`, `@v5`, +/// `@8e5e7e5…`) and the container-tag scanner (`:latest`, `:20-alpine`, +/// `:1a2b3c4`). Both ecosystems pin against either a moving name or an +/// immutable version string, and both want the moving names left alone, so +/// the same two rules cover them: `main` and `latest` fail rule 1, build-hash +/// tags fail rule 2, and `20-alpine` passes because `l`/`p`/`i`/`n` are not +/// hex digits. +/// +/// ``` +/// use dependency_check_updates_core::is_version_ref; +/// assert!(is_version_ref("v5")); +/// assert!(is_version_ref("20-alpine")); +/// assert!(!is_version_ref("main")); +/// assert!(!is_version_ref("latest")); +/// assert!(!is_version_ref("8e5e7e5a3b4c1234abcdef0123456789abcdef01")); +/// ``` +#[must_use] +pub fn is_version_ref(git_ref: &str) -> bool { + let stripped = git_ref.strip_prefix('v').unwrap_or(git_ref); + let Some(first) = stripped.chars().next() else { + return false; + }; + if !first.is_ascii_digit() { + return false; + } + // SHA heuristic: pure hex, length >= 7, no dots. Real version tags + // contain dots (`1.2.3`) or are very short (`v5` → stripped = `5`). + if stripped.len() >= 7 + && !stripped.contains('.') + && stripped.chars().all(|c| c.is_ascii_hexdigit()) + { + return false; + } + true +} + +/// Count non-empty dot-separated segments in the numeric head of a version. +#[must_use] +pub fn count_numeric_segments(v: &str) -> usize { + split_numeric_head(v) + .0 + .split('.') + .filter(|s| !s.is_empty()) + .count() +} + +/// Pad a numeric version prefix to exactly three segments while preserving any +/// pre-release / build-metadata suffix. +/// +/// The input is treated as ``; the prefix is split on +/// `.`, empty segments are skipped, and the result is rebuilt with `.0` filled +/// in. Inputs with 0 or >= 3 numeric segments are returned unchanged (callers +/// pass these through `semver::Version::parse` / `node_semver::Version::parse`, +/// which decide whether they are accepted). +/// +/// ``` +/// use dependency_check_updates_core::pad_to_three_segments; +/// assert_eq!(pad_to_three_segments("5"), "5.0.0"); +/// assert_eq!(pad_to_three_segments("5.1"), "5.1.0"); +/// assert_eq!(pad_to_three_segments("5.1.0"), "5.1.0"); +/// assert_eq!(pad_to_three_segments("1.2-beta"), "1.2.0-beta"); +/// assert_eq!(pad_to_three_segments(""), ""); +/// ``` +#[must_use] +pub fn pad_to_three_segments(v: &str) -> Cow<'_, str> { + if v.is_empty() { + return Cow::Borrowed(v); } - results + let (numeric, suffix) = split_numeric_head(v); + // Walk the segment iterator directly instead of collecting into a + // throwaway `Vec<&str>`: this helper sits on per-tag / per-dependency hot + // paths (`github::registry::normalize_tag`, `cli::pipeline::compute_updates`), + // so every call previously allocated a small `Vec` just to read its + // length and the first one or two elements. + let mut parts = numeric.split('.').filter(|s| !s.is_empty()); + let Some(p0) = parts.next() else { + // 0 numeric segments — no padding possible. + return Cow::Borrowed(v); + }; + let Some(p1) = parts.next() else { + // 1 segment: pad to `.0.0`. + return Cow::Owned(format!("{p0}.0.0{suffix}")); + }; + if parts.next().is_none() { + // 2 segments: pad to `..0`. + return Cow::Owned(format!("{p0}.{p1}.0{suffix}")); + } + // 3+ segments: already padded / over-padded; leave as-is (zero-cost + // borrow). Callers' parsers decide whether to accept the result. + Cow::Borrowed(v) } #[cfg(test)] @@ -58,22 +170,89 @@ mod tests { assert_eq!(strip_range_prefix(input), expected); } - #[tokio::test] - async fn test_collect_task_results_drops_panicked() { - // Suppress panic output from the intentionally-panicking task. - let prev_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); + #[rstest] + #[case("5", "5.0.0")] + #[case("5.1", "5.1.0")] + #[case("5.1.0", "5.1.0")] + #[case("5.1.2.3", "5.1.2.3")] // 4+ segments left as-is + #[case("5.1.0-rc.1", "5.1.0-rc.1")] + #[case("1.2-beta", "1.2.0-beta")] + #[case("5-beta", "5.0.0-beta")] + #[case("", "")] + // A non-empty string with NO numeric head has nothing to pad, and must be + // handed back untouched for the caller's parser to reject. Padding it + // would fabricate a version out of a branch name. + #[case("main", "main")] + #[case("v5", "v5")] + fn pad_to_three_segments_cases(#[case] input: &str, #[case] expected: &str) { + assert_eq!(pad_to_three_segments(input), expected); + } - let handles = vec![ - tokio::spawn(async { 1_usize }), - tokio::spawn(async { panic!("simulated join error") }), - tokio::spawn(async { 3_usize }), - ]; - let results = collect_task_results(handles).await; + #[rstest] + // Pure numeric — entire string is the head. + #[case::pure_numeric_three("1.2.3", "1.2.3", "")] + #[case::pure_numeric_two("1.2", "1.2", "")] + #[case::pure_numeric_one("5", "5", "")] + // Pre-release tail starts at `-`. + #[case::pre_release("1.2.3-beta.1", "1.2.3", "-beta.1")] + #[case::pre_release_short("1.2-beta", "1.2", "-beta")] + // Build-metadata tail starts at `+`. + #[case::build_metadata("1.2.3+build.7", "1.2.3", "+build.7")] + // Leading non-digit (e.g. `v5` GitHub tag) → empty head. + #[case::leading_non_digit("v5", "", "v5")] + #[case::all_non_digit("main", "", "main")] + // Empty input → empty halves. + #[case::empty("", "", "")] + fn split_numeric_head_cases( + #[case] input: &str, + #[case] expected_numeric: &str, + #[case] expected_rest: &str, + ) { + assert_eq!(split_numeric_head(input), (expected_numeric, expected_rest)); + } - std::panic::set_hook(prev_hook); + #[rstest] + // v-prefix versions accepted as version-like. + #[case::v_major("v5", true)] + #[case::v_major_minor("v5.1", true)] + #[case::v_major_minor_patch("v5.1.0", true)] + #[case::v_prerelease("v1.0.0-beta.1", true)] + // Bare numeric versions accepted (with or without v prefix). + #[case::bare_major("5", true)] + #[case::bare_semver("1.2.3", true)] + #[case::calendar_version("2024.01.01", true)] + // Short v-versions: `v12345` strips to `12345` (5 chars, < 7) so it + // bypasses the SHA heuristic and is treated as a version. + #[case::v_short_numeric("v12345", true)] + // Container tag variants: the alphabetic suffix breaks the all-hex test, + // so `20-alpine` stays version-like despite being 9 chars with no dot. + #[case::container_variant_tag("20-alpine", true)] + #[case::container_variant_dotted("3.12-slim-bookworm", true)] + // Moving pointers are rejected (no leading digit). + #[case::branch_main("main", false)] + #[case::branch_master("master", false)] + #[case::branch_develop("develop", false)] + #[case::branch_release_with_slash("release/v5", false)] + #[case::container_latest("latest", false)] + #[case::container_codename("bookworm", false)] + // Commit SHAs / build hashes are rejected by the hex+length heuristic. + #[case::sha_40_char("8e5e7e5a3b4c1234abcdef0123456789abcdef01", false)] + #[case::sha_7_char_starting_digit("1234567", false)] + #[case::sha_8_char_mixed_hex("12345abc", false)] + // Empty / lone `v` produce no leading digit → rejected. + #[case::empty("", false)] + #[case::just_v("v", false)] + fn is_version_ref_cases(#[case] input: &str, #[case] expected: bool) { + assert_eq!(is_version_ref(input), expected); + } - // The panicking task is dropped; only the two successful values survive. - assert_eq!(results.len(), 2); + #[rstest] + #[case::empty("", 0)] + #[case::simple("5", 1)] + #[case::dotted("1.2.3", 3)] + #[case::prefix("v5", 0)] + #[case::suffix("1.2.3-beta.1", 3)] + fn count_numeric_segments_cases(#[case] input: &str, #[case] expected: usize) { + assert_eq!(count_numeric_segments(input), expected); } } diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs index 15f0511..6dc4647 100644 --- a/crates/core/src/version.rs +++ b/crates/core/src/version.rs @@ -6,7 +6,10 @@ //! once behind the [`SelectableVersion`] trait so the three registry clients //! no longer carry near-identical copies of it. +use std::str::FromStr; + use crate::types::TargetLevel; +use crate::util::strip_range_prefix; /// A version type the [`select_version`] algorithm can operate on. /// @@ -72,6 +75,21 @@ impl SelectableVersion for pep440_rs::Version { } } +/// Return the highest stable version from an ascending sorted version slice. +/// +/// Registry clients use this for their shared "latest stable" fallback after +/// filtering and sorting concrete ecosystem versions. Pre-release versions are +/// skipped; returns `None` when the list is empty or contains only +/// pre-releases. +#[must_use] +pub fn highest_stable(sorted_asc: &[V]) -> Option { + sorted_asc + .iter() + .rev() + .find(|v| !v.is_prerelease()) + .map(ToString::to_string) +} + /// Select the best candidate for `target` from a pre-sorted (ascending) /// `all_versions` list. /// @@ -92,11 +110,11 @@ pub fn select_version( current: Option<&V>, all_versions: &[V], target: TargetLevel, - latest_for_stable: Option, - unparseable_minor_patch: Option, + latest_for_stable: Option<&str>, + unparseable_minor_patch: Option<&str>, ) -> Option { if all_versions.is_empty() { - return latest_for_stable; + return latest_for_stable.map(ToOwned::to_owned); } let current_is_prerelease = current.is_some_and(SelectableVersion::is_prerelease); @@ -125,10 +143,10 @@ pub fn select_version( .rev() .find(accept) .map(ToString::to_string), - TargetLevel::Latest => latest_for_stable, + TargetLevel::Latest => latest_for_stable.map(ToOwned::to_owned), TargetLevel::Greatest | TargetLevel::Newest => all_versions.last().map(ToString::to_string), TargetLevel::Minor => match current { - None => unparseable_minor_patch, + None => unparseable_minor_patch.map(ToOwned::to_owned), Some(cur) => all_versions .iter() .rev() @@ -136,7 +154,7 @@ pub fn select_version( .map(ToString::to_string), }, TargetLevel::Patch => match current { - None => unparseable_minor_patch, + None => unparseable_minor_patch.map(ToOwned::to_owned), Some(cur) => all_versions .iter() .rev() @@ -146,6 +164,54 @@ pub fn select_version( } } +/// Strip the range prefix from `current_req_str`, parse the remainder as `V`, +/// and run [`select_version`] with the parsed current and `latest` filling +/// BOTH the `latest_for_stable` and `unparseable_minor_patch` fallback slots. +/// +/// The three per-language registries (`npm`, `crates.io`, `PyPI`) share this +/// exact strip→parse→select sequence and the same identical-fallback policy +/// (each ecosystem's `latest` field doubles as both fallbacks). Centralising +/// it here matches the existing centralisation of [`select_version`] itself +/// and removes a parallel block of code that had been reimplemented in each +/// registry crate. +/// +/// `latest` is `Option<&str>` (not `Option<&String>`) so callers can pass +/// either an owned `Option` (`x.as_deref()`) or a borrow without +/// further allocation. Returns `None` when no candidate matches. +#[must_use] +pub fn parse_and_select( + current_req_str: &str, + all_versions: &[V], + target: TargetLevel, + latest: Option<&str>, +) -> Option +where + V: SelectableVersion + FromStr, +{ + let stripped = strip_range_prefix(current_req_str); + let current = V::from_str(stripped).ok(); + select_version(current.as_ref(), all_versions, target, latest, latest) +} + +/// Return `true` when the current requirement string resolves to a pre-release +/// version after stripping any leading range operator. +/// +/// Strips the range prefix with [`strip_range_prefix`], parses the remainder +/// as `V`, and delegates to [`SelectableVersion::is_prerelease`]. Returns +/// `false` for any input that cannot be parsed (e.g. `"*"` or `""`), matching +/// the convention that an unparseable requirement is treated as stable. +/// +/// Used by registry fast-paths that need to know whether the user's current +/// pin is a pre-release before deciding whether to skip the full version-list +/// enumeration. +#[must_use] +pub fn current_req_is_prerelease(current_req: &str) -> bool +where + V: SelectableVersion + FromStr, +{ + V::from_str(strip_range_prefix(current_req)).is_ok_and(|v| v.is_prerelease()) +} + #[cfg(test)] mod tests { use super::*; @@ -274,12 +340,69 @@ mod tests { cur.as_ref(), &candidates, target, - latest_for_stable.map(ToOwned::to_owned), - unparseable_minor_patch.map(ToOwned::to_owned), + latest_for_stable, + unparseable_minor_patch, ); assert_eq!(selected, expected.map(ToOwned::to_owned)); } + #[rstest] + #[case::empty(&[], None)] + #[case::stable_prerelease_mix( + &["1.0.0", "2.0.0-alpha.1", "2.0.0", "3.0.0-rc.1"], + Some("2.0.0"), + )] + #[case::all_prerelease(&["1.0.0-alpha.1", "2.0.0-rc.1"], None)] + fn highest_stable_cases(#[case] version_strs: &[&str], #[case] expected: Option<&str>) { + let candidates = vers(version_strs); + + let selected = highest_stable(&candidates); + + assert_eq!(selected.as_deref(), expected); + } + + /// Coverage for [`current_req_is_prerelease`] against both `semver::Version` + /// (node/GitHub semantics: `!pre_release.is_empty()`) and `pep440_rs::Version` + /// (`PyPI` semantics: `any_prerelease()`). Unparseable inputs must return `false`. + #[rstest] + // semver::Version — stable inputs + #[case::semver_stable_bare("1.2.3", false)] + #[case::semver_stable_caret("^1.2.3", false)] + #[case::semver_stable_tilde("~2.0.0", false)] + #[case::semver_stable_gte(">=3.0.0", false)] + // semver::Version — prerelease inputs + #[case::semver_prerelease_bare("1.0.0-rc.1", true)] + #[case::semver_prerelease_caret("^2.0.0-beta.3", true)] + // semver::Version — unparseable (star, empty) → false + #[case::semver_unparseable_star("*", false)] + #[case::semver_unparseable_empty("", false)] + fn current_req_is_prerelease_semver_cases(#[case] input: &str, #[case] expected: bool) { + assert_eq!( + current_req_is_prerelease::(input), + expected + ); + } + + #[rstest] + // pep440_rs::Version — stable inputs + #[case::pep440_stable_bare("1.2.3", false)] + #[case::pep440_stable_gte(">=2.0.0", false)] + // pep440_rs::Version — prerelease inputs (alpha, beta, rc, dev) + #[case::pep440_alpha("2.0.0a1", true)] + #[case::pep440_beta("1.0.0b2", true)] + #[case::pep440_rc("3.0.0rc1", true)] + #[case::pep440_dev("1.0.dev0", true)] + #[case::pep440_rc_with_prefix(">=2.0.0rc1", true)] + // pep440_rs::Version — unparseable → false + #[case::pep440_unparseable_star("*", false)] + #[case::pep440_unparseable_empty("", false)] + fn current_req_is_prerelease_pep440_cases(#[case] input: &str, #[case] expected: bool) { + assert_eq!( + current_req_is_prerelease::(input), + expected + ); + } + #[test] fn test_selectable_version_trait_accessors() { let v = semver::Version::parse("3.4.5-beta.1").unwrap(); diff --git a/crates/core/src/yaml_scan.rs b/crates/core/src/yaml_scan.rs new file mode 100644 index 0000000..36b5b28 --- /dev/null +++ b/crates/core/src/yaml_scan.rs @@ -0,0 +1,138 @@ +//! Line-based YAML scalar location. +//! +//! Several manifests this tool edits are YAML that humans read far more often +//! than machines do: GitHub workflows, composite actions, Compose files. Round +//! -tripping them through a YAML emitter strips comments, blank lines, and +//! anchor formatting, so instead every YAML-backed ecosystem scans line by +//! line and patches the exact bytes of the value it wants to change. +//! +//! The "find `key:` and work out where its scalar value starts and ends" step +//! is identical for every such key — `uses:` in a workflow, `image:` in a +//! Compose service or a workflow job container — so it lives here once. + +/// Locate the scalar value of `key` on a single YAML `line`. +/// +/// `key` must include its trailing colon (`"uses:"`, `"image:"`). Returns the +/// `(start, end)` byte offsets **within `line`** of the value, with any +/// surrounding quotes and any trailing `# comment` excluded, so +/// `&line[start..end]` is exactly the scalar the user wrote. +/// +/// Returns `None` when the line does not carry `key` as an actual mapping key +/// — the text may appear inside a comment (`# uses: foo/bar@v1`), inside +/// another key (`myimage: …`), or inside a scalar value +/// (`description: This uses: pattern`) — or when the key has no value at all. +/// +/// ``` +/// use dependency_check_updates_core::scalar_value_bounds; +/// let line = " - uses: actions/checkout@v5 # pinned\n"; +/// let (start, end) = scalar_value_bounds(line, "uses:").unwrap(); +/// assert_eq!(&line[start..end], "actions/checkout@v5"); +/// ``` +#[must_use] +pub fn scalar_value_bounds(line: &str, key: &str) -> Option<(usize, usize)> { + let key_pos = line.find(key)?; + + // Verify everything before the key is YAML key context (whitespace + + // optional single `-` list-item marker). Anything else — including a + // leading `#` comment, or the tail of a longer key — disqualifies the line. + if !is_key_context(&line[..key_pos]) { + return None; + } + + let after_colon = key_pos + key.len(); + let rest = line.get(after_colon..)?; + let leading_ws = rest.find(|c: char| !c.is_whitespace())?; + let value_start = after_colon + leading_ws; + let value_str = line.get(value_start..)?; + + let first = value_str.chars().next()?; + if first == '\'' || first == '"' { + let close_rel = value_str.get(1..)?.find(first)?; + return Some((value_start + 1, value_start + 1 + close_rel)); + } + // Unquoted scalar: terminate at the first whitespace or YAML comment marker. + let end_rel = value_str + .find(|c: char| c == '#' || c.is_whitespace()) + .unwrap_or(value_str.len()); + Some((value_start, value_start + end_rel)) +} + +/// Returns true iff `s` contains only whitespace and at most one `-` token, +/// i.e. it looks like the indent of a YAML key (possibly inside a list). +fn is_key_context(s: &str) -> bool { + let mut seen_dash = false; + for ch in s.chars() { + if ch.is_whitespace() { + continue; + } + if ch == '-' && !seen_dash { + seen_dash = true; + continue; + } + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + // Plain mapping value, with and without a list-item marker. + #[case::list_item( + " - uses: actions/checkout@v5\n", + "uses:", + Some("actions/checkout@v5") + )] + #[case::plain_key( + " uses: actions/checkout@v5\n", + "uses:", + Some("actions/checkout@v5") + )] + #[case::top_level("image: nginx:1.27\n", "image:", Some("nginx:1.27"))] + // Trailing comments and CRLF endings must not bleed into the value. + #[case::trailing_comment(" image: nginx:1.27 # pinned\n", "image:", Some("nginx:1.27"))] + #[case::comment_no_space(" image: nginx:1.27# pinned\n", "image:", Some("nginx:1.27"))] + #[case::crlf(" image: nginx:1.27\r\n", "image:", Some("nginx:1.27"))] + #[case::no_trailing_newline(" image: nginx:1.27", "image:", Some("nginx:1.27"))] + // Quotes are stripped from both spellings. + #[case::single_quoted(" image: 'nginx:1.27'\n", "image:", Some("nginx:1.27"))] + #[case::double_quoted(" image: \"nginx:1.27\"\n", "image:", Some("nginx:1.27"))] + // Deeply indented Compose service key. + #[case::nested("services:\n", "image:", None)] + // The key appears, but not as a key. + #[case::inside_comment(" # uses: foo/bar@v1\n", "uses:", None)] + #[case::inside_longer_key(" myimage: nginx:1.27\n", "image:", None)] + #[case::inside_scalar_value(" description: This uses: pattern\n", "uses:", None)] + // Key present but valueless. + #[case::empty_value(" - uses:\n", "uses:", None)] + #[case::key_absent(" run: echo hi\n", "image:", None)] + fn scalar_value_bounds_cases( + #[case] line: &str, + #[case] key: &str, + #[case] expected: Option<&str>, + ) { + let bounds = scalar_value_bounds(line, key); + match expected { + Some(value) => { + let (start, end) = bounds.expect("value must be located"); + assert_eq!(&line[start..end], value); + } + None => assert!(bounds.is_none(), "expected no match, got {bounds:?}"), + } + } + + #[rstest] + #[case::empty("", true)] + #[case::spaces(" ", true)] + #[case::single_dash(" - ", true)] + #[case::tab_indent("\t\t", true)] + #[case::two_dashes(" - - ", false)] + #[case::comment_marker(" # ", false)] + #[case::trailing_word(" foo", false)] + fn is_key_context_cases(#[case] input: &str, #[case] expected: bool) { + assert_eq!(is_key_context(input), expected); + } +} diff --git a/crates/docker/Cargo.toml b/crates/docker/Cargo.toml new file mode 100644 index 0000000..5c0dc62 --- /dev/null +++ b/crates/docker/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "dependency-check-updates-docker" +version = "0.1.15" +description = "Container image support (Dockerfile, Docker Compose) for dependency-check-updates" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true +keywords = ["dependency", "update", "docker", "container", "ncu"] +categories = ["development-tools"] + +[lib] +name = "dependency_check_updates_docker" +path = "src/lib.rs" + +[dependencies] +dependency-check-updates-core.workspace = true +reqwest.workspace = true +serde.workspace = true +tokio = { workspace = true, features = ["sync"] } +node-semver.workspace = true +tracing.workspace = true +futures.workspace = true + +[dev-dependencies] +rstest.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread"] } +wiremock.workspace = true +rustls.workspace = true + +[lints] +workspace = true diff --git a/crates/docker/src/image.rs b/crates/docker/src/image.rs new file mode 100644 index 0000000..9cf5231 --- /dev/null +++ b/crates/docker/src/image.rs @@ -0,0 +1,295 @@ +//! Container image reference parsing. +//! +//! An image reference is `[registry[:port]/]name[:tag][@digest]`. Splitting it +//! correctly is subtle in exactly two places, and both are load-bearing here: +//! +//! 1. The `:` that introduces a **port** (`localhost:5000/app`) looks +//! identical to the one that introduces a **tag**. Only a `:` that appears +//! after the last `/` can be a tag. +//! 2. Whether the first path segment is a **registry host** or the first half +//! of a Docker Hub namespace (`library/node` vs `ghcr.io/org/app`) is +//! decided by the same rule the Docker CLI itself uses: a segment +//! containing `.` or `:`, or spelled exactly `localhost`, is a host. + +use crate::tag::TagShape; + +/// The default registry a bare image name resolves against. +pub(crate) const DEFAULT_REGISTRY_HOST: &str = "registry-1.docker.io"; + +/// Namespace Docker Hub gives to its own official (single-segment) images. +const DOCKER_HUB_OFFICIAL_NAMESPACE: &str = "library"; + +/// A parsed image reference, borrowed from the source text. +#[derive(Debug, PartialEq, Eq)] +pub struct ImageRef<'a> { + /// Everything before the tag / digest, exactly as written + /// (`node`, `ghcr.io/dev-five-git/api`). + pub name: &'a str, + /// The tag, if one was written. `None` for a bare `node` (implicitly + /// `latest`) or a digest-only pin. + pub tag: Option<&'a str>, + /// The `sha256:…` digest, if the reference is content-addressed. + pub digest: Option<&'a str>, +} + +impl<'a> ImageRef<'a> { + /// Parse `text` as an image reference. + /// + /// Returns `None` only for an empty name, which the callers treat as + /// "not an image" rather than as an error — a malformed `FROM` line is + /// the Docker daemon's problem to report, not this tool's. + #[must_use] + pub fn parse(text: &'a str) -> Option { + // The digest is unambiguous: `@` cannot appear anywhere else. + let (before_digest, digest) = match text.split_once('@') { + Some((head, digest)) => (head, Some(digest)), + None => (text, None), + }; + + // A `:` only introduces a tag when it sits after the final `/`; + // otherwise it is a registry port (`localhost:5000/app`). + let last_slash = before_digest.rfind('/').map_or(0, |i| i + 1); + let (name, tag) = match before_digest[last_slash..].find(':') { + Some(rel) => { + let at = last_slash + rel; + (&before_digest[..at], Some(&before_digest[at + 1..])) + } + None => (before_digest, None), + }; + + if name.is_empty() { + return None; + } + + Some(Self { name, tag, digest }) + } + + /// Split [`Self::name`] into the registry host to query and the repository + /// path within it. + /// + /// ```text + /// node → ("registry-1.docker.io", "library/node") + /// grafana/grafana → ("registry-1.docker.io", "grafana/grafana") + /// ghcr.io/org/app → ("ghcr.io", "org/app") + /// localhost:5000/app → ("localhost:5000", "app") + /// ``` + /// + /// The repository is `Cow`-free: the Docker Hub official-image case is the + /// only one that needs to synthesise a string, and it is returned owned so + /// callers get one uniform type to key their per-repository cache on. + #[must_use] + pub fn registry_target(&self) -> (&'a str, String) { + match self.name.split_once('/') { + Some((first, rest)) if is_registry_host(first) => (first, rest.to_owned()), + // Two-segment Hub reference (`grafana/grafana`) — already a full + // repository path. + Some(_) => (DEFAULT_REGISTRY_HOST, self.name.to_owned()), + // Single segment — a Docker Hub official image, which lives under + // the implicit `library/` namespace. + None => ( + DEFAULT_REGISTRY_HOST, + format!("{DOCKER_HUB_OFFICIAL_NAMESPACE}/{}", self.name), + ), + } + } +} + +/// A tracked image reference located in the source text. +/// +/// Produced by both the Dockerfile `FROM` scanner and the YAML `image:` +/// scanner, and consumed by the patcher — the byte range covers exactly the +/// tag, so quotes, trailing comments, and the image name itself are never +/// touched by a rewrite. +#[derive(Debug)] +pub(crate) struct ImageLocation { + /// Image name without tag or digest, verbatim from the source. + pub name: String, + /// The tag as written. + pub tag: String, + /// Absolute byte offset (inclusive) of the first tag byte. + pub tag_start: usize, + /// Absolute byte offset (exclusive) one past the last tag byte. + pub tag_end: usize, +} + +/// Decide whether `reference` is an image pin this tool may update, and if so +/// locate its tag. +/// +/// `ref_offset` is the absolute byte offset at which `reference` begins in the +/// document, so the returned range is document-absolute. +/// +/// This is the single place every "leave it alone" rule lives, so the +/// Dockerfile and YAML scanners cannot drift apart: +/// +/// - **`$` interpolation** (`node:${NODE_VERSION}`, `image: app:$TAG`) — the +/// effective tag is decided elsewhere (a build arg, a `.env` file), so +/// rewriting the literal text would be guesswork. +/// - **Digest pins** (`node:20@sha256:…`) — the digest, not the tag, decides +/// what gets pulled. Moving the tag alone changes nothing at pull time while +/// making the reference self-contradictory. +/// - **No tag** (`FROM node`, `FROM builder`) — an implicit `latest`, or a +/// multi-stage build stage name. Both are moving targets by construction, +/// and stage names never carry a tag, so this rule covers them too. +/// - **Non-version tags** (`:latest`, `:bookworm`, `:1a2b3c4`) — rejected by +/// [`TagShape::parse`], the same way `@main` is for GitHub Actions. +pub(crate) fn locate(reference: &str, ref_offset: usize) -> Option { + if reference.contains('$') { + return None; + } + + let parsed = ImageRef::parse(reference)?; + if parsed.digest.is_some() { + return None; + } + let tag = parsed.tag?; + TagShape::parse(tag)?; + + // With no digest, the tag is always a suffix of `reference`, so its start + // is a pure length subtraction — no pointer arithmetic needed. + let tag_start = ref_offset + (reference.len() - tag.len()); + + Some(ImageLocation { + name: parsed.name.to_owned(), + tag: tag.to_owned(), + tag_start, + tag_end: tag_start + tag.len(), + }) +} + +/// Whether the first path segment of an image name is a registry host rather +/// than a Docker Hub namespace. +/// +/// This mirrors the Docker CLI's own heuristic (`reference.splitDockerDomain`): +/// a host must contain a `.` (`ghcr.io`, `mcr.microsoft.com`) or a `:` port +/// (`localhost:5000`), or be exactly `localhost`. Everything else — `library`, +/// `grafana`, `bitnami` — is a Hub namespace. +fn is_registry_host(segment: &str) -> bool { + segment == "localhost" || segment.contains('.') || segment.contains(':') +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + // Bare official image, no tag. + #[case::bare("node", "node", None, None)] + // The common cases: official and namespaced images with a tag. + #[case::official_tagged("node:20-alpine", "node", Some("20-alpine"), None)] + #[case::namespaced_tagged("grafana/grafana:11.3.0", "grafana/grafana", Some("11.3.0"), None)] + // Explicit registry hosts. + #[case::ghcr("ghcr.io/org/app:v1.2.3", "ghcr.io/org/app", Some("v1.2.3"), None)] + #[case::mcr( + "mcr.microsoft.com/dotnet/sdk:8.0", + "mcr.microsoft.com/dotnet/sdk", + Some("8.0"), + None + )] + // A registry PORT must not be mistaken for a tag — the `:` before the + // last `/` belongs to the host. + #[case::host_port_no_tag("localhost:5000/app", "localhost:5000/app", None, None)] + #[case::host_port_with_tag("localhost:5000/app:1.2", "localhost:5000/app", Some("1.2"), None)] + // Digest pins, with and without an accompanying tag. + #[case::digest_only("node@sha256:abc123", "node", None, Some("sha256:abc123"))] + #[case::tag_and_digest( + "node:20-alpine@sha256:abc123", + "node", + Some("20-alpine"), + Some("sha256:abc123") + )] + fn parse_cases( + #[case] input: &str, + #[case] name: &str, + #[case] tag: Option<&str>, + #[case] digest: Option<&str>, + ) { + let parsed = ImageRef::parse(input).expect("reference must parse"); + assert_eq!(parsed, ImageRef { name, tag, digest }); + } + + #[rstest] + // An empty name is the one input treated as "not an image". + #[case::empty("")] + #[case::tag_without_name(":1.0")] + #[case::digest_without_name("@sha256:abc")] + fn parse_rejects_empty_name(#[case] input: &str) { + assert_eq!(ImageRef::parse(input), None); + } + + #[rstest] + // Single segment → Docker Hub official namespace. + #[case::official("node:20", DEFAULT_REGISTRY_HOST, "library/node")] + // Two segments whose head is not host-like → Hub user namespace. + #[case::hub_namespace("grafana/grafana:11", DEFAULT_REGISTRY_HOST, "grafana/grafana")] + // Dotted head → registry host. + #[case::ghcr("ghcr.io/org/app:v1", "ghcr.io", "org/app")] + #[case::deep_path( + "mcr.microsoft.com/dotnet/aspnet/runtime:8.0", + "mcr.microsoft.com", + "dotnet/aspnet/runtime" + )] + // `localhost` (no dot, no port) is host-like by special case. + #[case::localhost("localhost/app:1", "localhost", "app")] + // Ported host. + #[case::localhost_port("localhost:5000/team/app:1", "localhost:5000", "team/app")] + fn registry_target_cases( + #[case] input: &str, + #[case] expected_host: &str, + #[case] expected_repo: &str, + ) { + let parsed = ImageRef::parse(input).expect("reference must parse"); + let (host, repo) = parsed.registry_target(); + assert_eq!(host, expected_host); + assert_eq!(repo, expected_repo); + } + + #[rstest] + // Happy path: the tag range slices back to exactly the tag, with the + // offset shifted by the caller-supplied document position. + #[case::official("node:20-alpine", 7, Some(("node", "20-alpine")))] + #[case::registry_host("ghcr.io/org/app:v1.2.3", 0, Some(("ghcr.io/org/app", "v1.2.3")))] + #[case::ported_host("localhost:5000/app:1.2", 3, Some(("localhost:5000/app", "1.2")))] + // Every skip rule, one case each. + #[case::interpolated_tag("node:${NODE_VERSION}", 0, None)] + #[case::interpolated_name("$REGISTRY/app:1.0", 0, None)] + #[case::digest_pin("node:20@sha256:abc123", 0, None)] + #[case::digest_only("node@sha256:abc123", 0, None)] + #[case::no_tag("node", 0, None)] + #[case::stage_alias("builder", 0, None)] + #[case::latest("node:latest", 0, None)] + #[case::codename("debian:bookworm", 0, None)] + #[case::build_hash("app:1a2b3c4", 0, None)] + fn locate_cases( + #[case] reference: &str, + #[case] offset: usize, + #[case] expected: Option<(&str, &str)>, + ) { + let located = locate(reference, offset); + match expected { + Some((name, tag)) => { + let located = located.expect("reference must be tracked"); + assert_eq!(located.name, name); + assert_eq!(located.tag, tag); + // The recorded range must slice the ORIGINAL reference back to + // the tag once the caller's offset is removed. + assert_eq!( + &reference[located.tag_start - offset..located.tag_end - offset], + tag + ); + } + None => assert!(located.is_none(), "{reference} must not be tracked"), + } + } + + #[rstest] + #[case::dotted("ghcr.io", true)] + #[case::dotted_deep("mcr.microsoft.com", true)] + #[case::localhost("localhost", true)] + #[case::ported("registry:5000", true)] + #[case::hub_namespace("grafana", false)] + #[case::hub_library("library", false)] + fn is_registry_host_cases(#[case] segment: &str, #[case] expected: bool) { + assert_eq!(is_registry_host(segment), expected); + } +} diff --git a/crates/docker/src/lib.rs b/crates/docker/src/lib.rs new file mode 100644 index 0000000..94070b3 --- /dev/null +++ b/crates/docker/src/lib.rs @@ -0,0 +1,252 @@ +//! Container image support for dependency-check-updates. +//! +//! Tracks the images a project builds on — Dockerfile `FROM` instructions and +//! the `image:` key of Compose services — and resolves newer tags from any +//! OCI Distribution registry (Docker Hub, `ghcr.io`, `quay.io`, +//! `mcr.microsoft.com`, a self-hosted registry, …). +//! +//! Two properties shape everything here: +//! +//! - **A tag is a version plus a variant.** `node:20-alpine` must become +//! `node:22-alpine`, never `node:22`. See [`tag`] for how variant groups +//! keep those lanes separate. +//! - **Moving and immutable pins are left alone.** `:latest`, codenames like +//! `:bookworm`, `@sha256:` digests, `${VAR}` interpolations, and untagged +//! references (including multi-stage `FROM builder`) are all skipped on +//! purpose — each one means the user opted out of tag tracking. +//! +//! A GitHub Actions workflow spells its job containers and service containers +//! with the very same `image:` key a Compose file uses, so +//! [`yaml_image_dependencies`] and [`apply_yaml_image_updates`] expose that +//! scanner to the GitHub crate instead of it growing a second copy. + +#![warn(missing_docs)] + +mod image; +mod parser; +mod patcher; +mod registry; +mod tag; +mod yaml; + +use std::path::Path; + +use dependency_check_updates_core::manifest::{ManifestHandler, ParsedManifest}; +use dependency_check_updates_core::{ + DcuError, DependencySection, DependencySpec, ManifestKind, ManifestRef, PlannedUpdate, +}; + +use image::ImageLocation; +pub use registry::DockerRegistry; + +/// Turn scanned image locations into dependency specs. +fn to_dependencies(locations: Vec) -> Vec { + locations + .into_iter() + .map(|location| DependencySpec { + name: location.name, + current_req: location.tag, + section: DependencySection::DockerImage, + path_version: None, + }) + .collect() +} + +/// Collect the tracked image dependencies of a YAML document. +/// +/// Exposed for the GitHub Actions crate, whose workflow manifests can carry +/// `container:` / `services:` image references alongside their `uses:` +/// directives. +#[must_use] +pub fn yaml_image_dependencies(text: &str) -> Vec { + to_dependencies(yaml::scan(text)) +} + +/// Apply image-tag updates to a YAML document. +/// +/// Exposed alongside [`yaml_image_dependencies`] so the GitHub Actions patcher +/// can rewrite `image:` values without duplicating the byte-range machinery. +/// +/// # Errors +/// +/// Never returns an error through this path; the signature matches +/// [`ManifestHandler::apply_updates`] so the two can share a call site. +/// +/// # Panics +/// +/// Panics only if two patches would overlap, which the line-based scanner +/// cannot produce: every tag occupies a distinct, byte-disjoint span and each +/// located tag is consumed at most once. A panic here means a scanner bug. +pub fn apply_yaml_image_updates(text: &str, updates: &[PlannedUpdate]) -> Result { + Ok(patcher::apply(text, &yaml::scan(text), updates).expect("image tag patches never overlap")) +} + +/// Handler for Dockerfile build definitions. +pub struct DockerfileHandler; + +impl ManifestHandler for DockerfileHandler { + fn parse(&self, text: &str, path: &Path) -> Result { + Ok(ParsedManifest { + manifest_ref: ManifestRef { + path: path.to_path_buf(), + kind: ManifestKind::Dockerfile, + }, + dependencies: to_dependencies(parser::scan(text)), + }) + } + + fn apply_updates(&self, text: &str, updates: &[PlannedUpdate]) -> Result { + // `apply` only fails on overlapping patches, which the line-by-line + // scanner can never produce: each tag occupies a distinct, byte-disjoint + // span and every location is consumed at most once. + Ok(patcher::apply(text, &parser::scan(text), updates) + .expect("image tag patches never overlap")) + } +} + +/// Handler for Docker Compose project files. +pub struct ComposeHandler; + +impl ManifestHandler for ComposeHandler { + fn parse(&self, text: &str, path: &Path) -> Result { + Ok(ParsedManifest { + manifest_ref: ManifestRef { + path: path.to_path_buf(), + kind: ManifestKind::DockerCompose, + }, + dependencies: to_dependencies(yaml::scan(text)), + }) + } + + fn apply_updates(&self, text: &str, updates: &[PlannedUpdate]) -> Result { + apply_yaml_image_updates(text, updates) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + const DOCKERFILE: &str = concat!( + "# syntax=docker/dockerfile:1\n", + "FROM node:20-alpine AS deps\n", + "WORKDIR /app\n", + "\n", + "FROM node:latest AS scratchpad\n", + "FROM gcr.io/distroless/nodejs20-debian12@sha256:abc123\n", + "FROM deps\n", + ); + + const COMPOSE: &str = concat!( + "services:\n", + " db:\n", + " image: postgres:16-alpine # pinned\n", + " cache:\n", + " image: 'redis:7.2'\n", + " app:\n", + " image: app:${TAG}\n", + ); + + fn update(name: &str, from: &str, to: &str) -> PlannedUpdate { + PlannedUpdate { + name: name.to_owned(), + section: DependencySection::DockerImage, + from: from.to_owned(), + to: to.to_owned(), + } + } + + #[test] + fn dockerfile_handler_collects_only_trackable_images() { + let parsed = DockerfileHandler + .parse(DOCKERFILE, Path::new("Dockerfile")) + .expect("Dockerfiles always parse"); + + assert_eq!(parsed.manifest_ref.kind, ManifestKind::Dockerfile); + // `:latest`, the digest pin, and the `FROM deps` stage reference are + // all deliberately skipped. + assert_eq!(parsed.dependencies.len(), 1); + assert_eq!(parsed.dependencies[0].name, "node"); + assert_eq!(parsed.dependencies[0].current_req, "20-alpine"); + assert_eq!( + parsed.dependencies[0].section, + DependencySection::DockerImage + ); + } + + #[test] + fn dockerfile_handler_rewrites_only_the_tag() { + let result = DockerfileHandler + .apply_updates(DOCKERFILE, &[update("node", "20-alpine", "22-alpine")]) + .expect("patch must apply"); + + assert!(result.contains("FROM node:22-alpine AS deps")); + // Everything the scanner skipped must survive byte-for-byte. + assert!(result.contains("# syntax=docker/dockerfile:1")); + assert!(result.contains("FROM node:latest AS scratchpad")); + assert!(result.contains("@sha256:abc123")); + assert!(result.contains("FROM deps")); + } + + #[test] + fn compose_handler_collects_only_trackable_images() { + let parsed = ComposeHandler + .parse(COMPOSE, Path::new("compose.yaml")) + .expect("Compose files always parse"); + + assert_eq!(parsed.manifest_ref.kind, ManifestKind::DockerCompose); + // The `${TAG}` interpolation is skipped; the other two are tracked. + let names: Vec<&str> = parsed + .dependencies + .iter() + .map(|d| d.name.as_str()) + .collect(); + assert_eq!(names, vec!["postgres", "redis"]); + } + + #[test] + fn compose_handler_preserves_comments_and_quotes() { + let result = ComposeHandler + .apply_updates( + COMPOSE, + &[ + update("postgres", "16-alpine", "17-alpine"), + update("redis", "7.2", "7.4"), + ], + ) + .expect("patch must apply"); + + assert!(result.contains("image: postgres:17-alpine # pinned")); + assert!(result.contains("image: 'redis:7.4'")); + assert!(result.contains("image: app:${TAG}")); + } + + #[test] + fn yaml_helpers_serve_the_github_crate() { + // The workflow shape: a job container plus a service container. + let workflow = concat!( + "jobs:\n", + " test:\n", + " container:\n", + " image: node:20-alpine\n", + " services:\n", + " redis:\n", + " image: redis:7.2\n", + " steps:\n", + " - uses: actions/checkout@v5\n", + ); + + let deps = yaml_image_dependencies(workflow); + assert_eq!(deps.len(), 2); + assert_eq!(deps[0].name, "node"); + assert_eq!(deps[1].name, "redis"); + + let patched = apply_yaml_image_updates(workflow, &[update("redis", "7.2", "7.4")]) + .expect("patch must apply"); + assert!(patched.contains("image: redis:7.4")); + // The `uses:` directive belongs to the GitHub scanner and must be + // untouched by this one. + assert!(patched.contains("uses: actions/checkout@v5")); + } +} diff --git a/crates/docker/src/parser.rs b/crates/docker/src/parser.rs new file mode 100644 index 0000000..b9f7267 --- /dev/null +++ b/crates/docker/src/parser.rs @@ -0,0 +1,199 @@ +//! Line-based scanner for Dockerfile `FROM` instructions. +//! +//! Dockerfiles have no round-trippable object model — `docker` itself parses +//! them line by line — so the scanner walks the text the same way, records the +//! absolute byte offsets of each tracked image tag, and lets the patcher +//! replace only those bytes. +//! +//! Only `FROM` is scanned. `COPY --from=` can also name an image, but +//! it far more often names a build stage, and the two are textually +//! indistinguishable at the line level; leaving it alone keeps the scanner +//! from ever rewriting a stage reference. + +use crate::image::{ImageLocation, locate}; + +/// The instruction keyword introducing a base image. +const FROM: &str = "FROM"; + +/// Scan Dockerfile text and return every tracked `FROM` image tag. +/// +/// Infallible: a malformed line simply yields no match rather than aborting +/// the scan. +pub(crate) fn scan(text: &str) -> Vec { + let mut locations = Vec::new(); + let mut offset = 0usize; + for line in text.split_inclusive('\n') { + if let Some(location) = scan_line(line, offset) { + locations.push(location); + } + offset += line.len(); + } + locations +} + +/// Scan a single line, returning its image location iff the line is a `FROM` +/// instruction naming an image this tool may update. +fn scan_line(line: &str, line_offset: usize) -> Option { + let indent = line.len() - line.trim_start().len(); + let rest = line.get(indent..)?; + + // `FROM` is case-insensitive in Dockerfiles, and must be followed by + // whitespace — `FROMAGE` is not an instruction, and a `# FROM …` comment + // fails the check because the `#` is the first non-whitespace byte. + if !rest + .get(..FROM.len()) + .is_some_and(|kw| kw.eq_ignore_ascii_case(FROM)) + { + return None; + } + if !rest + .as_bytes() + .get(FROM.len()) + .is_some_and(u8::is_ascii_whitespace) + { + return None; + } + + // Walk past any build flags (`--platform=linux/amd64`) that sit between + // the keyword and the image reference. + let mut cursor = indent + FROM.len(); + loop { + let (token, start) = next_token(line, cursor)?; + cursor = start + token.len(); + if token.starts_with("--") { + continue; + } + return locate(token, line_offset + start); + } +} + +/// Return the next whitespace-delimited token in `line` at or after byte +/// offset `from`, together with its start offset. +/// +/// Splitting on ASCII whitespace only means every index produced here lands on +/// a UTF-8 boundary, so the returned slice is always valid. A trailing `\n` or +/// `\r\n` terminates the final token like any other whitespace. +fn next_token(line: &str, from: usize) -> Option<(&str, usize)> { + let bytes = line.as_bytes(); + let mut i = from; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() { + return None; + } + let start = i; + while i < bytes.len() && !bytes[i].is_ascii_whitespace() { + i += 1; + } + Some((&line[start..i], start)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + // Canonical single-stage build. + #[case::simple("FROM node:20-alpine\n", "node", "20-alpine")] + // Multi-stage: the `AS ` tail must not bleed into the reference. + #[case::with_stage_alias("FROM node:20-alpine AS builder\n", "node", "20-alpine")] + // Build flags sit between the keyword and the image. + #[case::platform_flag("FROM --platform=linux/amd64 node:20-alpine\n", "node", "20-alpine")] + #[case::multiple_flags( + "FROM --platform=$BUILDPLATFORM --foo=bar python:3.12-slim\n", + "python", + "3.12-slim" + )] + // The keyword is case-insensitive and may be indented. + #[case::lowercase_keyword("from node:20\n", "node", "20")] + #[case::mixed_case_keyword("FrOm node:20\n", "node", "20")] + #[case::indented(" FROM node:20\n", "node", "20")] + // Line-ending and EOF variations. + #[case::crlf("FROM node:20\r\n", "node", "20")] + #[case::no_trailing_newline("FROM node:20", "node", "20")] + // Registry hosts, including one with a port that must not be read as a tag. + #[case::registry_host("FROM ghcr.io/org/app:v1.2.3\n", "ghcr.io/org/app", "v1.2.3")] + #[case::ported_host("FROM localhost:5000/app:1.2\n", "localhost:5000/app", "1.2")] + fn scan_yields_single_match( + #[case] dockerfile: &str, + #[case] expected_name: &str, + #[case] expected_tag: &str, + ) { + let locations = scan(dockerfile); + assert_eq!(locations.len(), 1, "got: {locations:?}"); + assert_eq!(locations[0].name, expected_name); + assert_eq!(locations[0].tag, expected_tag); + // The recorded offsets must slice the original text back to the tag. + assert_eq!( + &dockerfile[locations[0].tag_start..locations[0].tag_end], + expected_tag + ); + } + + #[rstest] + // Not a FROM instruction. + #[case::run_line("RUN echo FROM node:20\n")] + #[case::comment("# FROM node:20\n")] + #[case::keyword_prefix_only("FROMAGE node:20\n")] + #[case::bare_keyword("FROM\n")] + // A FROM whose image this tool deliberately leaves alone. + #[case::stage_reference("FROM builder\n")] + #[case::scratch("FROM scratch\n")] + #[case::implicit_latest("FROM node\n")] + #[case::explicit_latest("FROM node:latest\n")] + #[case::codename_tag("FROM debian:bookworm\n")] + #[case::digest_pin("FROM node:20@sha256:abc123\n")] + #[case::build_arg_tag("FROM node:${NODE_VERSION}\n")] + #[case::build_arg_name("FROM $REGISTRY/node:20\n")] + fn scan_yields_no_matches(#[case] dockerfile: &str) { + let locations = scan(dockerfile); + assert!( + locations.is_empty(), + "expected no matches, got {locations:?}" + ); + } + + #[test] + fn scan_multi_stage_build_records_every_tracked_stage() { + let dockerfile = concat!( + "FROM node:20-alpine AS deps\n", + "WORKDIR /app\n", + "\n", + "FROM node:20-alpine AS builder\n", + "COPY --from=deps /app/node_modules ./node_modules\n", + "\n", + "# The runtime stage intentionally pins a digest.\n", + "FROM gcr.io/distroless/nodejs20-debian12@sha256:abc123\n", + "FROM builder\n", + ); + + let locations = scan(dockerfile); + assert_eq!(locations.len(), 2, "got: {locations:?}"); + for location in &locations { + assert_eq!(location.name, "node"); + assert_eq!(location.tag, "20-alpine"); + assert_eq!( + &dockerfile[location.tag_start..location.tag_end], + "20-alpine" + ); + } + // The two occurrences must be recorded at distinct, ascending offsets + // so the patcher can rewrite each independently. + assert!(locations[0].tag_start < locations[1].tag_start); + } + + #[rstest] + #[case::from_start(" a bc ", 0, Some(("a", 2)))] + #[case::mid_line(" a bc ", 3, Some(("bc", 4)))] + #[case::trailing_whitespace_only(" a bc ", 6, None)] + #[case::past_end(" a", 99, None)] + fn next_token_cases( + #[case] line: &str, + #[case] from: usize, + #[case] expected: Option<(&str, usize)>, + ) { + assert_eq!(next_token(line, from), expected); + } +} diff --git a/crates/docker/src/patcher.rs b/crates/docker/src/patcher.rs new file mode 100644 index 0000000..3dfd36e --- /dev/null +++ b/crates/docker/src/patcher.rs @@ -0,0 +1,195 @@ +//! Byte-range patcher for container image tags. +//! +//! Only the bytes of a tag are replaced. The image name, surrounding quotes, +//! trailing comments, `AS ` tails, build flags, and every untracked +//! reference (`:latest`, digest pins, `${VAR}` interpolations) survive +//! byte-for-byte. +//! +//! The descending-`replace_range` engine itself lives in +//! [`dependency_check_updates_core::patch`]; this module only turns a list of +//! [`PlannedUpdate`]s into the byte ranges it consumes. + +use dependency_check_updates_core::PlannedUpdate; +use dependency_check_updates_core::patch::{Patch, PatchError, apply_byte_patches}; + +use crate::image::ImageLocation; + +/// Build byte patches by matching `updates` against `locations`. +/// +/// Updates are joined on `(name, from-tag)` because one document can reference +/// the same image at two different tags — a Compose file pinning +/// `postgres:16` for the primary and `postgres:15` for a migration sidecar is +/// ordinary — so each occurrence must be rewritten using its own original tag +/// as the key. Locations are consumed as they match, which lets repeated +/// `(name, tag)` pairs each receive their own patch. +/// +/// Updates that match no location are silently skipped: the reference may have +/// been edited since the scan that produced the plan. +pub(crate) fn build_patches(locations: &[ImageLocation], updates: &[PlannedUpdate]) -> Vec { + let mut consumed = vec![false; locations.len()]; + let mut patches = Vec::with_capacity(updates.len()); + + for update in updates { + let Some((idx, location)) = locations + .iter() + .enumerate() + .find(|(i, l)| !consumed[*i] && l.name == update.name && l.tag == update.from) + else { + continue; + }; + consumed[idx] = true; + patches.push(Patch { + start: location.tag_start, + end: location.tag_end, + new_value: update.to.clone(), + }); + } + + patches +} + +/// Apply `updates` to `text`, replacing only the tag bytes of each matched +/// image reference. +/// +/// # Errors +/// +/// Returns [`PatchError::OverlappingPatches`] if two patches would touch the +/// same byte range. Each scanned tag occupies a distinct, byte-disjoint span +/// and every location is consumed at most once, so this indicates a scanner +/// bug rather than user error. +pub(crate) fn apply( + text: &str, + locations: &[ImageLocation], + updates: &[PlannedUpdate], +) -> Result { + if updates.is_empty() { + return Ok(text.to_owned()); + } + apply_byte_patches(text, &build_patches(locations, updates)) +} + +#[cfg(test)] +mod tests { + use super::*; + use dependency_check_updates_core::DependencySection; + use rstest::rstest; + + fn updates(rows: &[(&str, &str, &str)]) -> Vec { + rows.iter() + .map(|(name, from, to)| PlannedUpdate { + name: (*name).to_owned(), + section: DependencySection::DockerImage, + from: (*from).to_owned(), + to: (*to).to_owned(), + }) + .collect() + } + + #[rstest] + // No updates → byte-identical output. + #[case::empty_is_identity("FROM node:20-alpine\n", &[], "FROM node:20-alpine\n")] + // The headline case: only the tag changes, the variant travels with it. + #[case::single_update( + "FROM node:20-alpine\n", + &[("node", "20-alpine", "22-alpine")], + "FROM node:22-alpine\n" + )] + // Everything around the tag survives: stage alias, build flag, comment. + #[case::preserves_stage_alias( + "FROM node:20-alpine AS builder\n", + &[("node", "20-alpine", "22-alpine")], + "FROM node:22-alpine AS builder\n" + )] + #[case::preserves_platform_flag( + "FROM --platform=linux/amd64 node:20 AS build\n", + &[("node", "20", "22")], + "FROM --platform=linux/amd64 node:22 AS build\n" + )] + #[case::preserves_yaml_comment( + " image: redis:7.2 # pinned\n", + &[("redis", "7.2", "7.4")], + " image: redis:7.4 # pinned\n" + )] + #[case::preserves_yaml_quotes( + " image: 'redis:7.2'\n", + &[("redis", "7.2", "7.4")], + " image: 'redis:7.4'\n" + )] + // A tag that grows or shrinks in length must not disturb its neighbours. + #[case::handles_length_change( + "FROM node:20 AS build\n", + &[("node", "20", "22.3.0-alpine")], + "FROM node:22.3.0-alpine AS build\n" + )] + // Untracked references next to a tracked one are left alone. + #[case::leaves_untracked_alone( + concat!( + "FROM node:20-alpine AS build\n", + "FROM node:latest AS scratchpad\n", + "FROM gcr.io/distroless/base@sha256:abc123\n", + ), + &[("node", "20-alpine", "22-alpine")], + concat!( + "FROM node:22-alpine AS build\n", + "FROM node:latest AS scratchpad\n", + "FROM gcr.io/distroless/base@sha256:abc123\n", + ) + )] + // The same image pinned at two different tags: each occurrence is keyed on + // its own `from`, so neither steals the other's patch. + #[case::same_image_distinct_tags( + concat!( + " image: postgres:15\n", + " image: postgres:16\n", + ), + &[("postgres", "15", "15.8"), ("postgres", "16", "16.4")], + concat!( + " image: postgres:15.8\n", + " image: postgres:16.4\n", + ) + )] + // The same image at the same tag twice: both get patched, not just the first. + #[case::duplicate_occurrences_both_patched( + concat!( + "FROM node:20-alpine AS deps\n", + "FROM node:20-alpine AS build\n", + ), + &[("node", "20-alpine", "22-alpine"), ("node", "20-alpine", "22-alpine")], + concat!( + "FROM node:22-alpine AS deps\n", + "FROM node:22-alpine AS build\n", + ) + )] + // A `from` that no longer matches the text is skipped, not misapplied. + #[case::unmatched_update_skipped( + "FROM node:20-alpine\n", + &[("node", "19-alpine", "22-alpine")], + "FROM node:20-alpine\n" + )] + fn apply_dockerfile_cases( + #[case] text: &str, + #[case] rows: &[(&str, &str, &str)], + #[case] expected: &str, + ) { + // Both scanners feed the same patcher, so the case table drives + // whichever one recognises the fixture. + let mut locations = crate::parser::scan(text); + locations.extend(crate::yaml::scan(text)); + let result = apply(text, &locations, &updates(rows)).expect("patches never overlap"); + assert_eq!(result, expected); + } + + #[test] + fn build_patches_consumes_each_location_once() { + // Two updates carrying an identical `(name, from)` must map onto the + // two distinct occurrences rather than both landing on the first. + let text = "FROM node:20 AS a\nFROM node:20 AS b\n"; + let locations = crate::parser::scan(text); + let patches = build_patches( + &locations, + &updates(&[("node", "20", "22"), ("node", "20", "22")]), + ); + assert_eq!(patches.len(), 2); + assert_ne!(patches[0].start, patches[1].start); + } +} diff --git a/crates/docker/src/registry.rs b/crates/docker/src/registry.rs new file mode 100644 index 0000000..798d85f --- /dev/null +++ b/crates/docker/src/registry.rs @@ -0,0 +1,903 @@ +//! OCI Distribution (Registry HTTP API v2) client for resolving image tags. +//! +//! Fetches `GET https:///v2//tags/list` and groups the +//! returned names by build variant (see [`crate::tag`]). One code path serves +//! every registry — Docker Hub, `ghcr.io`, `quay.io`, `mcr.microsoft.com`, +//! `public.ecr.aws`, a self-hosted `localhost:5000` — because they all +//! implement the same specification. +//! +//! ## Anonymous authentication +//! +//! Public images still require a token. The registry answers an unauthenticated +//! request with `401` plus a `WWW-Authenticate: Bearer realm=…,service=…` +//! challenge; fetching that realm with a `repository::pull` scope yields +//! a short-lived token that grants read access without any credentials. This +//! client performs that exchange transparently and retries once. +//! +//! ## Batching +//! +//! Every unique repository is fetched exactly once per batch, so a Compose file +//! referencing `postgres:16` and `postgres:16-alpine` costs one round-trip +//! (plus its token exchange), not two. +//! +//! ## Pagination +//! +//! `tags/list` is requested without an `n` parameter, which every mainstream +//! registry answers with the complete tag list in one response. Deliberately +//! not following `Link` headers keeps request counts predictable during deep +//! scans, at the cost of missing tags on registries that impose a default page +//! size — the same trade-off the GitHub Tags API client makes. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use tokio::sync::Semaphore; +use tracing::{debug, trace}; + +use dependency_check_updates_core::{ + DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, ResolvedVersion, TargetLevel, + build_client, +}; + +use crate::image::ImageRef; +use crate::tag::PreparedTags; + +/// Response body of `GET /v2//tags/list`. +#[derive(Debug, Deserialize)] +struct TagsResponse { + /// Registries return `null` rather than `[]` for a repository that exists + /// but has no tags. + tags: Option>, +} + +/// Response body of a token-realm exchange. +/// +/// The specification names the field `token`; Docker Hub and several others +/// also emit the `OAuth2` spelling `access_token`. Accepting both keeps the +/// client working across every registry seen in practice. +#[derive(Debug, Deserialize)] +struct TokenResponse { + token: Option, + access_token: Option, +} + +impl TokenResponse { + fn into_token(self) -> Option { + self.token.or(self.access_token) + } +} + +/// A parsed `WWW-Authenticate: Bearer …` challenge. +#[derive(Debug, PartialEq, Eq)] +struct BearerChallenge { + realm: String, + service: Option, + scope: Option, +} + +/// OCI Distribution registry client. +#[derive(Clone)] +pub struct DockerRegistry { + client: Client, + semaphore: Arc, + /// When set, every request targets this base instead of the `https://` + /// derived from the image reference. Tests point it at a mock server. + base_url_override: Option>, +} + +impl DockerRegistry { + /// Construct a client that talks to whichever registry each image names. + #[must_use] + pub fn new() -> Self { + Self::build(None) + } + + /// Construct a client that routes every request to `base_url`, ignoring the + /// host encoded in the image reference. Used by tests via `wiremock`. + #[must_use] + pub fn with_base_url(base_url: &str) -> Self { + Self::build(Some(Arc::from(base_url.trim_end_matches('/')))) + } + + fn build(base_url_override: Option>) -> Self { + Self { + client: build_client(), + semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_REQUESTS)), + base_url_override, + } + } + + /// The scheme + authority to send registry requests to. + fn endpoint(&self, host: &str) -> String { + endpoint_for(self.base_url_override.as_deref(), host) + } + + /// Fetch every published tag of `repository` on `host`. + async fn fetch_tags(&self, host: &str, repository: &str) -> Result, String> { + let _permit = self + .semaphore + .acquire() + .await + .map_err(|e| format!("semaphore error: {e}"))?; + + let url = format!("{}/v2/{repository}/tags/list", self.endpoint(host)); + debug!(host, repository, %url, "fetching tags"); + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; + + // Public images still need a token; the registry says so with a 401 + // plus the challenge describing where to get one. + let response = if response.status() == StatusCode::UNAUTHORIZED { + let challenge = response + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .and_then(parse_bearer_challenge) + .ok_or_else(|| { + "HTTP 401 without a usable Bearer challenge — the registry requires \ + credentials this tool cannot supply anonymously." + .to_owned() + })?; + + let token = self.fetch_token(&challenge, repository).await?; + self.client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(|e| e.to_string())? + } else { + response + }; + + let status = response.status(); + if !status.is_success() { + return Err(describe_failure(status)); + } + + let body: TagsResponse = response.json().await.map_err(|e| format!("parse: {e}"))?; + Ok(body.tags.unwrap_or_default()) + } + + /// Exchange a Bearer challenge for a pull-scoped token. + async fn fetch_token( + &self, + challenge: &BearerChallenge, + repository: &str, + ) -> Result { + // Registries echo the exact scope they want in the challenge; fall back + // to the read-only scope the specification defines when they do not. + let scope = challenge + .scope + .clone() + .unwrap_or_else(|| format!("repository:{repository}:pull")); + + let url = token_url(&challenge.realm, &scope, challenge.service.as_deref()); + trace!(%url, "exchanging bearer challenge for a token"); + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; + let status = response.status(); + if !status.is_success() { + return Err(format!("token request failed: HTTP {status}")); + } + + response + .json::() + .await + .map_err(|e| format!("token parse: {e}"))? + .into_token() + .ok_or_else(|| "token response carried neither `token` nor `access_token`".to_owned()) + } + + /// Resolve every dep in `deps`, fetching each unique repository once. + /// + /// # Panics + /// + /// Panics only if the internal tag cache is missing a key that + /// [`registry_target_of`] produced — an invariant violation, not a + /// user-input issue. + pub async fn resolve_batch( + &self, + deps: &[DependencySpec], + target: TargetLevel, + ) -> Vec<(usize, Result)> { + // Step 1: collect the unique (host, repository) pairs. Two deps that + // differ only by variant (`postgres:16` / `postgres:16-alpine`) share + // one repository and therefore one fetch. + let mut seen: HashSet = HashSet::with_capacity(deps.len()); + let mut unique: Vec<(String, String)> = Vec::with_capacity(deps.len()); + for dep in deps { + let Some((host, repository)) = registry_target_of(&dep.name) else { + continue; + }; + let key = cache_key(&host, &repository); + if seen.insert(key) { + unique.push((host, repository)); + } + } + + // Step 2: fan out the fetches. + let fetched = + futures::future::join_all(unique.into_iter().map(|(host, repository)| async move { + let result = self.fetch_tags(&host, &repository).await; + (cache_key(&host, &repository), result) + })) + .await; + + let mut prepared: HashMap> = + HashMap::with_capacity(fetched.len()); + for (key, result) in fetched { + prepared.insert(key, result.map(|tags| PreparedTags::new(&tags))); + } + + // Step 3: resolve each dep against its repository's prepared tag groups. + let mut results = Vec::with_capacity(deps.len()); + for (idx, dep) in deps.iter().enumerate() { + let resolved = match registry_target_of(&dep.name) { + None => Err(DcuError::RegistryLookup { + package: dep.name.clone(), + detail: "not a valid image reference".to_owned(), + }), + // The `expect` is safe by construction: every parseable name + // produced a key in step 1, and step 2 inserted one entry per + // key. Asserting the invariant beats a defensive arm that no + // input can reach. + Some((host, repository)) => match prepared + .get(&cache_key(&host, &repository)) + .expect("tag cache must contain every unique repository key") + { + Err(detail) => Err(DcuError::RegistryLookup { + package: dep.name.clone(), + detail: detail.clone(), + }), + Ok(tags) => { + let resolved = tags.select(&dep.current_req, target); + trace!( + image = %dep.name, + current = %dep.current_req, + selected = ?resolved.selected, + "resolved tag" + ); + Ok(resolved) + } + }, + }; + results.push((idx, resolved)); + } + + results + } +} + +impl Default for DockerRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Resolve an image name to the `(host, repository)` pair to query. +fn registry_target_of(name: &str) -> Option<(String, String)> { + let parsed = ImageRef::parse(name)?; + let (host, repository) = parsed.registry_target(); + Some((host.to_owned(), repository)) +} + +/// Cache key for one repository on one registry. +fn cache_key(host: &str, repository: &str) -> String { + format!("{host}/{repository}") +} + +/// Resolve the scheme + authority for `host`, honouring a test override. +/// +/// A bare `localhost` / `127.0.0.1` registry is assumed to be plain HTTP, +/// matching the Docker daemon's own default insecure-registry rule; everything +/// else is HTTPS. +/// +/// Kept as a free function so its behaviour can be tested without constructing +/// a `DockerRegistry` — building one instantiates a `reqwest::Client`, which +/// panics unless a rustls crypto provider has already been installed in the +/// process. Depending on some *other* test to have installed it first makes the +/// outcome hinge on test execution order. +fn endpoint_for(base_url_override: Option<&str>, host: &str) -> String { + if let Some(base) = base_url_override { + return base.to_owned(); + } + // Compare the bare authority, so a public host that merely *starts with* + // a local name (`localhost.example.com`, `127.0.0.1.example.com`) is not + // downgraded to plain HTTP. + let authority = host.split_once(':').map_or(host, |(name, _port)| name); + let scheme = if matches!(authority, "localhost" | "127.0.0.1" | "::1") { + "http" + } else { + "https" + }; + format!("{scheme}://{host}") +} + +/// Build the token-realm URL for a pull-scoped exchange. +/// +/// The parameter values are appended verbatim. Every byte that appears in a +/// registry scope (`repository:library/node:pull,push`) or a service name is +/// already legal in a query component per RFC 3986 — `:`, `/`, and `,` are all +/// `pchar` / sub-delims — which is why the Docker CLI sends them unescaped +/// too. A realm that already carries its own query string is respected. +fn token_url(realm: &str, scope: &str, service: Option<&str>) -> String { + let separator = if realm.contains('?') { '&' } else { '?' }; + let mut url = format!("{realm}{separator}scope={scope}"); + if let Some(service) = service { + url.push_str("&service="); + url.push_str(service); + } + url +} + +/// Turn a non-success status into a message that names the fix where one +/// exists. +/// +/// Docker Hub throttles anonymous clients aggressively enough that a deep scan +/// of a large monorepo can hit the ceiling, and the bare status code gives the +/// user nothing to act on. +fn describe_failure(status: StatusCode) -> String { + match status { + StatusCode::TOO_MANY_REQUESTS => "registry rate limit exceeded — retry later, or \ + authenticate with the registry to raise the anonymous quota." + .to_owned(), + StatusCode::NOT_FOUND => { + "repository not found — private registries and locally built images \ + cannot be resolved anonymously." + .to_owned() + } + other => format!("HTTP {other}"), + } +} + +/// Parse a `WWW-Authenticate` header into its Bearer challenge parameters. +/// +/// Returns `None` for a non-Bearer scheme or a challenge with no `realm`, +/// since neither can be turned into a token request. +fn parse_bearer_challenge(header: &str) -> Option { + let params = header.strip_prefix("Bearer ").or_else(|| { + // The scheme token is case-insensitive per RFC 7235. + header + .split_once(char::is_whitespace) + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Bearer")) + .map(|(_, rest)| rest) + })?; + + let mut realm = None; + let mut service = None; + let mut scope = None; + for (key, value) in parse_auth_params(params) { + match key { + "realm" => realm = Some(value.to_owned()), + "service" => service = Some(value.to_owned()), + "scope" => scope = Some(value.to_owned()), + _ => {} + } + } + + Some(BearerChallenge { + realm: realm?, + service, + scope, + }) +} + +/// Split `key="value"` / `key=value` auth parameters, honouring quotes. +/// +/// A naive `split(',')` is wrong here: a scope legitimately contains commas +/// (`scope="repository:app:pull,push"`), and they sit inside the quoted value. +fn parse_auth_params(params: &str) -> Vec<(&str, &str)> { + let bytes = params.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + + while i < bytes.len() { + while i < bytes.len() && (bytes[i] == b',' || bytes[i].is_ascii_whitespace()) { + i += 1; + } + let key_start = i; + while i < bytes.len() && bytes[i] != b'=' && bytes[i] != b',' { + i += 1; + } + // A trailing token with no `=` is not a parameter. + if i >= bytes.len() || bytes[i] != b'=' { + break; + } + let key = params[key_start..i].trim(); + i += 1; + + let value = if bytes.get(i) == Some(&b'"') { + i += 1; + let value_start = i; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + let value = ¶ms[value_start..i]; + // Step past the closing quote when one is present. + i = (i + 1).min(bytes.len()); + value + } else { + let value_start = i; + while i < bytes.len() && bytes[i] != b',' { + i += 1; + } + params[value_start..i].trim() + }; + + out.push((key, value)); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use dependency_check_updates_core::DependencySection; + use rstest::rstest; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path as match_path, query_param}, + }; + + /// Idempotent rustls provider install. Returning `Err` (already set) is the + /// expected steady state once any test has run — `let _ =` swallows it. + fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + fn dep(name: &str, tag: &str) -> DependencySpec { + DependencySpec { + name: name.to_owned(), + current_req: tag.to_owned(), + section: DependencySection::DockerImage, + path_version: None, + } + } + + fn tags_body(names: &[&str]) -> serde_json::Value { + serde_json::json!({ "name": "test", "tags": names }) + } + + #[rstest] + // The canonical Docker Hub challenge. + #[case::docker_hub( + r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io""#, + Some(("https://auth.docker.io/token", Some("registry.docker.io"), None)) + )] + // ghcr.io echoes the scope it wants. + #[case::with_scope( + r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:org/app:pull""#, + Some(( + "https://ghcr.io/token", + Some("ghcr.io"), + Some("repository:org/app:pull") + )) + )] + // A scope containing a comma must survive intact — the reason the param + // splitter is quote-aware rather than a `split(',')`. + #[case::comma_inside_quoted_scope( + r#"Bearer realm="https://auth.example.com/token",scope="repository:app:pull,push""#, + Some(( + "https://auth.example.com/token", + None, + Some("repository:app:pull,push") + )) + )] + // RFC 7235 makes the scheme token case-insensitive. + #[case::lowercase_scheme( + r#"bearer realm="https://auth.example.com/token""#, + Some(("https://auth.example.com/token", None, None)) + )] + // Parameters this client does not consume are ignored rather than + // aborting the parse — registries append their own (`error=…`). + #[case::ignores_unknown_params( + r#"Bearer realm="https://ghcr.io/token",error="insufficient_scope",foo=bar"#, + Some(("https://ghcr.io/token", None, None)) + )] + // Unusable challenges. + #[case::basic_scheme(r#"Basic realm="registry""#, None)] + #[case::missing_realm(r#"Bearer service="registry.docker.io""#, None)] + #[case::empty("", None)] + fn parse_bearer_challenge_cases( + #[case] header: &str, + #[case] expected: Option<(&str, Option<&str>, Option<&str>)>, + ) { + let parsed = parse_bearer_challenge(header); + assert_eq!( + parsed, + expected.map(|(realm, service, scope)| BearerChallenge { + realm: realm.to_owned(), + service: service.map(ToOwned::to_owned), + scope: scope.map(ToOwned::to_owned), + }) + ); + } + + #[rstest] + #[case::unquoted("a=1,b=2", &[("a", "1"), ("b", "2")])] + #[case::spaced("a = 1, b = 2", &[("a", "1"), ("b", "2")])] + #[case::unterminated_quote(r#"a="1"#, &[("a", "1")])] + #[case::trailing_token_without_equals("a=1,junk", &[("a", "1")])] + #[case::empty("", &[])] + fn parse_auth_params_cases(#[case] input: &str, #[case] expected: &[(&str, &str)]) { + assert_eq!(parse_auth_params(input), expected); + } + + #[rstest] + // Scope characters (`:`, `/`, `,`) are legal query bytes and travel raw, + // exactly as the Docker CLI sends them. + #[case::basic( + "https://auth.docker.io/token", + "repository:library/node:pull", + Some("registry.docker.io"), + "https://auth.docker.io/token?scope=repository:library/node:pull&service=registry.docker.io" + )] + #[case::no_service( + "https://auth.example.com/token", + "repository:app:pull,push", + None, + "https://auth.example.com/token?scope=repository:app:pull,push" + )] + // A realm that already carries a query string keeps it. + #[case::realm_with_existing_query( + "https://auth.example.com/token?account=ci", + "repository:app:pull", + None, + "https://auth.example.com/token?account=ci&scope=repository:app:pull" + )] + fn token_url_cases( + #[case] realm: &str, + #[case] scope: &str, + #[case] service: Option<&str>, + #[case] expected: &str, + ) { + assert_eq!(token_url(realm, scope, service), expected); + } + + #[rstest] + #[case::hub("node", "registry-1.docker.io/library/node")] + #[case::namespaced("grafana/grafana", "registry-1.docker.io/grafana/grafana")] + #[case::ghcr("ghcr.io/org/app", "ghcr.io/org/app")] + fn registry_target_of_cases(#[case] name: &str, #[case] expected_key: &str) { + let (host, repository) = registry_target_of(name).expect("name must resolve"); + assert_eq!(cache_key(&host, &repository), expected_key); + } + + #[rstest] + // Local registries default to plain HTTP, as the Docker daemon does. + #[case::localhost_ported(None, "localhost:5000", "http://localhost:5000")] + #[case::localhost_bare(None, "localhost", "http://localhost")] + #[case::loopback_ip(None, "127.0.0.1:5000", "http://127.0.0.1:5000")] + // Everything else is HTTPS — including hosts that merely *contain* + // "localhost", which must not be mistaken for the local one. + #[case::public_registry(None, "ghcr.io", "https://ghcr.io")] + #[case::docker_hub(None, "registry-1.docker.io", "https://registry-1.docker.io")] + #[case::lookalike_host(None, "localhost.example.com", "https://localhost.example.com")] + #[case::lookalike_ip(None, "127.0.0.1.example.com", "https://127.0.0.1.example.com")] + // A test override replaces the derived endpoint entirely. + #[case::override_wins(Some("http://127.0.0.1:9999"), "ghcr.io", "http://127.0.0.1:9999")] + fn endpoint_for_cases( + #[case] base_url_override: Option<&str>, + #[case] host: &str, + #[case] expected: &str, + ) { + assert_eq!(endpoint_for(base_url_override, host), expected); + } + + #[test] + fn new_and_default_construct() { + install_crypto_provider(); + let _ = DockerRegistry::new(); + let _ = DockerRegistry::default(); + } + + #[tokio::test] + async fn resolve_batch_fetches_each_repository_once() { + install_crypto_provider(); + let mock = MockServer::start().await; + + // Two deps share `library/node`, differing only by variant — exactly + // one HTTP call must be issued for them. + Mock::given(method("GET")) + .and(match_path("/v2/library/node/tags/list")) + .respond_with(ResponseTemplate::new(200).set_body_json(tags_body(&[ + "20", + "20-alpine", + "22", + "22-alpine", + ]))) + .expect(1) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let deps = vec![dep("node", "20"), dep("node", "20-alpine")]; + let results = registry.resolve_batch(&deps, TargetLevel::Latest).await; + + assert_eq!(results.len(), 2); + // Each dep resolves within its OWN variant group. + assert_eq!( + results[0].1.as_ref().unwrap().selected.as_deref(), + Some("22") + ); + assert_eq!( + results[1].1.as_ref().unwrap().selected.as_deref(), + Some("22-alpine") + ); + } + + #[tokio::test] + async fn resolve_batch_performs_the_anonymous_token_exchange() { + install_crypto_provider(); + let mock = MockServer::start().await; + let realm = format!("{}/token", mock.uri()); + + // Unauthenticated request → 401 with a Bearer challenge pointing at the + // mock's own token realm. + Mock::given(method("GET")) + .and(match_path("/v2/library/redis/tags/list")) + .and(header("authorization", "Bearer issued-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(tags_body(&["7.2", "7.4"]))) + .expect(1) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(match_path("/token")) + .and(query_param("scope", "repository:library/redis:pull")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "token": "issued-token" })), + ) + .expect(1) + .mount(&mock) + .await; + + // Lowest priority: the unauthenticated first attempt. + Mock::given(method("GET")) + .and(match_path("/v2/library/redis/tags/list")) + .respond_with(ResponseTemplate::new(401).insert_header( + "WWW-Authenticate", + format!(r#"Bearer realm="{realm}",service="mock""#).as_str(), + )) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("redis", "7.2")], TargetLevel::Latest) + .await; + + let resolved = results[0] + .1 + .as_ref() + .expect("token exchange should succeed"); + assert_eq!(resolved.selected.as_deref(), Some("7.4")); + } + + #[tokio::test] + async fn resolve_batch_accepts_the_oauth2_access_token_spelling() { + install_crypto_provider(); + let mock = MockServer::start().await; + let realm = format!("{}/token", mock.uri()); + + Mock::given(method("GET")) + .and(match_path("/v2/library/redis/tags/list")) + .and(header("authorization", "Bearer oauth-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(tags_body(&["7.2", "7.4"]))) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(match_path("/token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "access_token": "oauth-token" })), + ) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(match_path("/v2/library/redis/tags/list")) + .respond_with(ResponseTemplate::new(401).insert_header( + "WWW-Authenticate", + format!(r#"Bearer realm="{realm}""#).as_str(), + )) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("redis", "7.2")], TargetLevel::Latest) + .await; + assert_eq!( + results[0].1.as_ref().unwrap().selected.as_deref(), + Some("7.4") + ); + } + + #[tokio::test] + async fn resolve_batch_tolerates_a_null_tag_list() { + install_crypto_provider(); + let mock = MockServer::start().await; + + // A repository that exists but has no tags returns `"tags": null`. + Mock::given(method("GET")) + .and(match_path("/v2/library/empty/tags/list")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "name": "empty", "tags": null })), + ) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("empty", "1.0")], TargetLevel::Latest) + .await; + let resolved = results[0] + .1 + .as_ref() + .expect("empty tag list is not an error"); + assert_eq!(resolved.selected, None); + } + + #[rstest] + // A 429 must name the fix rather than surfacing a bare status code. + #[case::rate_limited(429, &["rate limit"])] + // A 404 must explain why a private / locally built image cannot resolve. + #[case::not_found(404, &["not found", "locally built"])] + // Anything else keeps the status code. + #[case::server_error(500, &["HTTP 500"])] + #[tokio::test] + async fn resolve_batch_reports_actionable_failures( + #[case] status: u16, + #[case] must_contain: &[&str], + ) { + install_crypto_provider(); + let mock = MockServer::start().await; + + Mock::given(method("GET")) + .and(match_path("/v2/library/node/tags/list")) + .respond_with(ResponseTemplate::new(status)) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("node", "20")], TargetLevel::Latest) + .await; + + let error = results[0].1.as_ref().expect_err("non-2xx must be an error"); + let detail = format!("{error:?}"); + for needle in must_contain { + assert!( + detail.contains(needle), + "expected `{needle}` in error: {detail}" + ); + } + } + + /// The challenge parses and the realm is reachable, but the token endpoint + /// itself refuses. The status must reach the user rather than being + /// swallowed into a generic "could not fetch tags". + #[tokio::test] + async fn resolve_batch_surfaces_a_failing_token_exchange() { + install_crypto_provider(); + let mock = MockServer::start().await; + let realm = format!("{}/token", mock.uri()); + + Mock::given(method("GET")) + .and(match_path("/token")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(match_path("/v2/library/node/tags/list")) + .respond_with(ResponseTemplate::new(401).insert_header( + "WWW-Authenticate", + format!(r#"Bearer realm="{realm}""#).as_str(), + )) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("node", "20")], TargetLevel::Latest) + .await; + + let error = results[0] + .1 + .as_ref() + .expect_err("a failing token exchange must surface"); + let detail = format!("{error:?}"); + assert!(detail.contains("token request failed"), "got: {detail}"); + assert!(detail.contains("500"), "got: {detail}"); + } + + /// A token realm that answers 200 with a body carrying neither spelling of + /// the token field. + #[tokio::test] + async fn resolve_batch_errors_on_a_tokenless_token_response() { + install_crypto_provider(); + let mock = MockServer::start().await; + let realm = format!("{}/token", mock.uri()); + + Mock::given(method("GET")) + .and(match_path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "expires_in": 300 })), + ) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(match_path("/v2/library/node/tags/list")) + .respond_with(ResponseTemplate::new(401).insert_header( + "WWW-Authenticate", + format!(r#"Bearer realm="{realm}""#).as_str(), + )) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("node", "20")], TargetLevel::Latest) + .await; + + let error = results[0] + .1 + .as_ref() + .expect_err("a tokenless body is an error"); + assert!(format!("{error:?}").contains("access_token")); + } + + #[tokio::test] + async fn resolve_batch_errors_when_a_401_carries_no_usable_challenge() { + install_crypto_provider(); + let mock = MockServer::start().await; + + // A 401 with a Basic challenge cannot be satisfied anonymously. + Mock::given(method("GET")) + .and(match_path("/v2/library/node/tags/list")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("WWW-Authenticate", r#"Basic realm="registry""#), + ) + .mount(&mock) + .await; + + let registry = DockerRegistry::with_base_url(&mock.uri()); + let results = registry + .resolve_batch(&[dep("node", "20")], TargetLevel::Latest) + .await; + + let error = results[0] + .1 + .as_ref() + .expect_err("unusable challenge is an error"); + assert!(format!("{error:?}").contains("credentials")); + } + + #[tokio::test] + async fn resolve_batch_errors_on_an_unparseable_image_name() { + install_crypto_provider(); + // An empty name never reaches the network. + let registry = DockerRegistry::with_base_url("http://127.0.0.1:1"); + let results = registry + .resolve_batch(&[dep("", "20")], TargetLevel::Latest) + .await; + assert!(results[0].1.is_err()); + } +} diff --git a/crates/docker/src/tag.rs b/crates/docker/src/tag.rs new file mode 100644 index 0000000..ddf5c94 --- /dev/null +++ b/crates/docker/src/tag.rs @@ -0,0 +1,430 @@ +//! Variant-aware container tag selection. +//! +//! A container tag is not a version — it is a version **plus a build variant**: +//! `node:20-alpine`, `python:3.12-slim`, `eclipse-temurin:21-jre-jammy`. The +//! variant is a hard constraint: bumping `20-alpine` to `22` silently swaps +//! Alpine for Debian and usually breaks the build, while bumping it to +//! `22.3.0-alpine3.20` swaps the Alpine base version. Neither is an update the +//! user asked for. +//! +//! So this module groups every published tag by its **variant key** — the +//! verbatim, opaque remainder after the leading numeric run — and only ever +//! compares tags within one group: +//! +//! ```text +//! "" 20, 20.11, 20.11.1, 22, 22.3, 22.3.0 +//! "-alpine" 20-alpine, 22-alpine +//! "-slim" 20-slim, 22-slim +//! ``` +//! +//! Treating the variant as opaque means `1.2.3-rc.1` lands in its own `-rc.1` +//! group rather than being interpreted as a semver pre-release. That is +//! deliberate: `-rc.1` and `-alpine` are indistinguishable at the tag level, +//! and guessing wrong emits a tag that does not exist. Staying inside the +//! group can only ever under-report an update, never break an image. + +use std::collections::{HashMap, HashSet}; + +use dependency_check_updates_core::{ + ResolvedVersion, TargetLevel, count_numeric_segments, is_version_ref, pad_to_three_segments, + select_version, split_numeric_head, +}; + +/// A tag split into the parts the updater reasons about. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct TagShape<'a> { + /// The leading numeric run, with any `v` prefix removed (`20`, `3.12`). + pub numeric: &'a str, + /// Everything after the numeric run, verbatim — the variant key + /// (`""`, `"-alpine"`, `"-slim-bookworm"`). + pub variant: &'a str, +} + +impl<'a> TagShape<'a> { + /// Decompose `tag`, or return `None` when it is not a version-like tag. + /// + /// `latest`, codenames (`bookworm`), and build hashes are rejected by + /// [`is_version_ref`] — the same predicate the GitHub Actions scanner uses + /// for `@main` and commit SHAs. + pub(crate) fn parse(tag: &'a str) -> Option { + if !is_version_ref(tag) { + return None; + } + // `is_version_ref` guarantees a leading digit after the optional `v`, + // so the numeric head below is always non-empty. + let rest = tag.strip_prefix('v').unwrap_or(tag); + let (numeric, variant) = split_numeric_head(rest); + Some(Self { numeric, variant }) + } +} + +/// Every published tag of one repository, grouped by variant key. +/// +/// Built once per unique repository so that a Compose file referencing +/// `postgres:16` and `postgres:16-alpine` pays the grouping cost once. +pub(crate) struct PreparedTags { + by_variant: HashMap, +} + +/// The tags of a single variant group. +#[derive(Default)] +struct VariantTags { + /// Numeric heads padded to three segments, sorted ascending and + /// de-duplicated (`20` and `20.0.0` collapse to one entry). + sorted: Vec, + /// Numeric heads exactly as published (`20`, `20.11`, `20.11.1`), used to + /// check whether a given pin precision is actually backed by a tag. + numerics: HashSet, +} + +impl PreparedTags { + /// Group `tags` by variant key, discarding everything non-version-like. + pub(crate) fn new(tags: &[String]) -> Self { + let mut by_variant: HashMap = HashMap::new(); + + for tag in tags { + let Some(shape) = TagShape::parse(tag) else { + continue; + }; + let Ok(version) = node_semver::Version::parse(pad_to_three_segments(shape.numeric)) + else { + // 4+-segment numerics (`1.2.3.4`) have no semver meaning. + continue; + }; + let entry = by_variant.entry(shape.variant.to_owned()).or_default(); + entry.sorted.push(version); + entry.numerics.insert(shape.numeric.to_owned()); + } + + for group in by_variant.values_mut() { + // Unstable sort matches the convention of the other registries; + // the following dedup needs the list sorted anyway, and `20` / + // `20.0.0` both pad to `20.0.0` so duplicates are real. + group.sorted.sort_unstable(); + group.sorted.dedup(); + } + + Self { by_variant } + } + + /// Resolve `current_tag` against the published tags for `target`. + /// + /// Both returned values are bare tag bodies **without** any `v` prefix: + /// the CLI pipeline strips the leading non-digit run off the current tag + /// and re-glues it onto whatever the registry returns, so emitting the `v` + /// here would double it (`v20` → `vv22`). + /// + /// `TargetLevel::Newest` resolves identically to `Greatest`. The OCI + /// `tags/list` endpoint returns bare names with no timestamps, and + /// recovering real publish dates would cost one manifest fetch per tag — + /// the same trade-off the GitHub Tags API forces. + pub(crate) fn select(&self, current_tag: &str, target: TargetLevel) -> ResolvedVersion { + let none = ResolvedVersion { + latest: None, + selected: None, + }; + + let Some(shape) = TagShape::parse(current_tag) else { + return none; + }; + // No group means the registry publishes no tag with this variant — + // e.g. the image dropped its `-alpine` line. Reporting nothing is the + // only safe answer. + let Some(group) = self.by_variant.get(shape.variant) else { + return none; + }; + + // Every numeric head in a group is a plain `x.y.z`, so the whole group + // is "stable" as far as `select_version` is concerned and its highest + // entry doubles as the `latest` fallback. + let highest = group.sorted.last().map(ToString::to_string); + let current = node_semver::Version::parse(pad_to_three_segments(shape.numeric)).ok(); + + let selected = select_version( + current.as_ref(), + &group.sorted, + target, + highest.as_deref(), + None, + ) + .map(|padded| { + let numeric = pick_existing_numeric(&padded, shape.numeric, &group.numerics); + format!("{numeric}{}", shape.variant) + }); + + ResolvedVersion { + latest: highest.map(|padded| { + let numeric = pick_existing_numeric(&padded, shape.numeric, &group.numerics); + format!("{numeric}{}", shape.variant) + }), + selected, + } + } +} + +/// Collapse a padded three-segment version back to the shortest numeric form +/// an actual published tag uses, preferring the user's current pin precision. +/// +/// Images publish moving tags at several precisions (`node:22`, `node:22.3`, +/// `node:22.3.0`) but not uniformly — `postgres` publishes `16` and `16.4`, +/// `traefik` only `v3.1.6`. Emitting a precision nobody published produces a +/// tag that fails to pull, so this walks the user's precision upward and, only +/// if nothing at or above it exists, downward, returning the first form backed +/// by a real tag. +/// +/// The precision the user already pins is treated as known-to-exist — their +/// build runs on it right now — so a same-prefix match short-circuits without +/// consulting the tag list. Without this, an image whose moving `20` tag is +/// absent from the response would be "upgraded" from `20` to `20.11.1`, which +/// is the same image under a noisier name. +fn pick_existing_numeric( + padded: &str, + current_numeric: &str, + numerics: &HashSet, +) -> String { + let segments: Vec<&str> = padded.split('.').filter(|s| !s.is_empty()).collect(); + let len = segments.len(); + // `len.max(1)` keeps `clamp` well-formed — it panics when min > max — for a + // segment-less input. Every slice below then goes through `get`, so that + // degenerate case falls through to the closing fallback rather than needing + // an early return no caller can reach. + let start = count_numeric_segments(current_numeric).clamp(1, len.max(1)); + + if let Some(prefix) = segments.get(..start) { + let current_prefix = prefix.join("."); + if current_numeric == current_prefix { + return current_prefix; + } + } + + // Shortest form at or above the pin precision first, then the longest + // shorter form. The padded input itself is always one of the candidates at + // `p == len`, so the fallback only fires for inputs that never came from + // this repository's tag list. + (start..=len) + .chain((1..start).rev()) + .find_map(|p| { + let candidate = segments.get(..p)?.join("."); + numerics.contains(candidate.as_str()).then_some(candidate) + }) + .unwrap_or_else(|| padded.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + fn tags(names: &[&str]) -> PreparedTags { + let owned: Vec = names.iter().map(|s| (*s).to_owned()).collect(); + PreparedTags::new(&owned) + } + + #[rstest] + // Plain numeric tags at every precision. + #[case::major("20", Some(("20", "")))] + #[case::major_minor("3.12", Some(("3.12", "")))] + #[case::full("1.2.3", Some(("1.2.3", "")))] + // `v` prefix is stripped from the numeric but leaves the variant alone. + #[case::v_prefixed("v3.1.6", Some(("3.1.6", "")))] + // Variant suffixes are captured verbatim, however many segments they have. + #[case::alpine("20-alpine", Some(("20", "-alpine")))] + #[case::slim("3.12-slim", Some(("3.12", "-slim")))] + #[case::multi_variant("21-jre-jammy", Some(("21", "-jre-jammy")))] + #[case::variant_with_digits("20.11-alpine3.19", Some(("20.11", "-alpine3.19")))] + // A semver pre-release is deliberately treated as just another variant. + #[case::prerelease_is_a_variant("7.4.0-rc1", Some(("7.4.0", "-rc1")))] + // Moving pointers and hashes have no version to track. + #[case::latest("latest", None)] + #[case::codename("bookworm", None)] + #[case::build_hash("1a2b3c4", None)] + #[case::empty("", None)] + fn tag_shape_parse_cases(#[case] input: &str, #[case] expected: Option<(&str, &str)>) { + let actual = TagShape::parse(input); + assert_eq!( + actual, + expected.map(|(numeric, variant)| TagShape { numeric, variant }) + ); + } + + #[rstest] + // ---- The headline guarantee: variants never cross-contaminate. ---- + // `20-alpine` must reach `22-alpine`, never the bare `22` that also exists. + #[case::alpine_stays_alpine( + &["20", "20-alpine", "22", "22-alpine"], + "20-alpine", + TargetLevel::Latest, + Some("22-alpine") + )] + // …and the bare pin must not wander into a variant. + #[case::bare_stays_bare( + &["20", "20-alpine", "22", "22-alpine"], + "20", + TargetLevel::Latest, + Some("22") + )] + // A variant the registry no longer publishes yields nothing rather than + // falling back to some other variant. + #[case::unknown_variant_yields_nothing( + &["20", "22"], + "20-alpine", + TargetLevel::Latest, + None + )] + // ---- Pin precision is preserved when a tag backs it. ---- + #[case::major_pin_keeps_major( + &["20", "20.11.1", "22", "22.3.0"], + "20", + TargetLevel::Latest, + Some("22") + )] + #[case::full_pin_keeps_full( + &["20.11.1", "22.3.0"], + "20.11.1", + TargetLevel::Latest, + Some("22.3.0") + )] + #[case::minor_pin_keeps_minor( + &["20.11", "20.11.1", "22.3", "22.3.0"], + "20.11", + TargetLevel::Latest, + Some("22.3") + )] + // Major pin, but the registry publishes no moving major tag → escalate to + // the shortest form that actually exists rather than emitting a 404 tag. + #[case::escalates_when_major_tag_absent( + &["20", "22.3.0"], + "20", + TargetLevel::Latest, + Some("22.3.0") + )] + // Full pin, but only a moving major tag exists on the new train → + // de-escalate to it. + #[case::de_escalates_to_major( + &["20.11.1", "22"], + "20.11.1", + TargetLevel::Latest, + Some("22") + )] + // ---- Target levels behave as they do everywhere else. ---- + #[case::minor_stays_on_major( + &["20.1.0", "20.5.0", "22.0.0"], + "20.1.0", + TargetLevel::Minor, + Some("20.5.0") + )] + #[case::patch_stays_on_minor( + &["20.1.0", "20.1.4", "20.5.0"], + "20.1.0", + TargetLevel::Patch, + Some("20.1.4") + )] + #[case::greatest_takes_the_top( + &["20", "22", "23"], + "20", + TargetLevel::Greatest, + Some("23") + )] + // `newest` has no publish dates to work with and mirrors `greatest`. + #[case::newest_mirrors_greatest( + &["20", "22", "23"], + "20", + TargetLevel::Newest, + Some("23") + )] + // ---- Inputs that must produce no suggestion at all. ---- + #[case::latest_pin_is_untracked(&["20", "22"], "latest", TargetLevel::Latest, None)] + #[case::empty_registry(&[], "20", TargetLevel::Latest, None)] + // Non-version tags in the response are filtered out, not tripped over. + #[case::ignores_non_version_tags( + &["latest", "bookworm", "20", "22"], + "20", + TargetLevel::Latest, + Some("22") + )] + // Already on the top tag → the selection equals the current pin, and the + // pipeline's equality check turns that into "no update". + #[case::already_current(&["20", "22"], "22", TargetLevel::Latest, Some("22"))] + fn select_cases( + #[case] published: &[&str], + #[case] current: &str, + #[case] target: TargetLevel, + #[case] expected: Option<&str>, + ) { + let selected = tags(published).select(current, target).selected; + assert_eq!(selected.as_deref(), expected); + } + + #[test] + fn select_never_emits_a_v_prefix() { + // The pipeline re-glues the leading non-digit run from the current + // spec, so a `v`-prefixed pin must come back bare or it doubles up. + let selected = tags(&["v3.1.6", "v3.2.0"]) + .select("v3.1.6", TargetLevel::Latest) + .selected; + assert_eq!(selected.as_deref(), Some("3.2.0")); + } + + #[test] + fn select_reports_latest_within_the_variant_group() { + // `latest` is informational, but it must still respect the variant + // boundary — reporting the bare `23` for an `-alpine` pin would be + // actively misleading. + let resolved = + tags(&["20-alpine", "23-alpine", "24"]).select("20-alpine", TargetLevel::Latest); + assert_eq!(resolved.latest.as_deref(), Some("23-alpine")); + } + + #[test] + fn select_keeps_a_major_float_absent_from_the_tag_list() { + // Mirrors a high-velocity image whose moving `20` tag is not part of + // the response: the user is provably running on `20`, so keep it + // instead of "upgrading" them to the equivalent `20.11.1`. + let selected = tags(&["20.11.1", "20.11.0"]) + .select("20", TargetLevel::Latest) + .selected; + assert_eq!(selected.as_deref(), Some("20")); + } + + #[test] + fn prepared_tags_dedupes_equivalent_precisions() { + // `22` and `22.0.0` both pad to `22.0.0`; the sorted list must not + // carry the duplicate, but BOTH precisions stay available to the + // existence walk. + let prepared = tags(&["22", "22.0.0"]); + let group = prepared.by_variant.get("").expect("bare variant group"); + assert_eq!(group.sorted.len(), 1); + assert!(group.numerics.contains("22")); + assert!(group.numerics.contains("22.0.0")); + } + + #[test] + fn prepared_tags_skips_four_segment_numerics() { + // `1.2.3.4` has no semver reading; it must be dropped rather than + // mis-parsed into the group. + let prepared = tags(&["1.2.3.4", "1.2.3"]); + let group = prepared.by_variant.get("").expect("bare variant group"); + assert_eq!(group.sorted.len(), 1); + assert!(!group.numerics.contains("1.2.3.4")); + } + + #[rstest] + // The padded input always wins when the tag list backs that exact form. + #[case::exact_match("22.3.0", "20.11.1", &["22.3.0"], "22.3.0")] + // Shorter published forms are preferred at the pin's precision. + #[case::major_pin_finds_major("22.0.0", "20", &["22", "22.0.0"], "22")] + // Nothing at or above the pin precision → walk downward. + #[case::walks_down("22.0.0", "20.11.1", &["22"], "22")] + // A version that never came from this tag list falls back to itself + // instead of panicking. + #[case::unbacked_input_falls_back("99.0.0", "20", &["22"], "99.0.0")] + fn pick_existing_numeric_cases( + #[case] padded: &str, + #[case] current: &str, + #[case] published: &[&str], + #[case] expected: &str, + ) { + let numerics: HashSet = published.iter().map(|s| (*s).to_owned()).collect(); + assert_eq!(pick_existing_numeric(padded, current, &numerics), expected); + } +} diff --git a/crates/docker/src/yaml.rs b/crates/docker/src/yaml.rs new file mode 100644 index 0000000..465e808 --- /dev/null +++ b/crates/docker/src/yaml.rs @@ -0,0 +1,141 @@ +//! Line-based scanner for YAML `image:` keys. +//! +//! Compose services and GitHub Actions job containers spell a container image +//! exactly the same way: +//! +//! ```yaml +//! services: +//! db: +//! image: postgres:16-alpine # Compose +//! jobs: +//! test: +//! container: +//! image: node:20-alpine # workflow job container +//! services: +//! redis: +//! image: redis:7-alpine # workflow service container +//! ``` +//! +//! One scanner therefore serves both, and the GitHub Actions crate calls into +//! it rather than growing a second copy. +//! +//! Compose services that only `build:` locally still carry an `image:` naming +//! the built artefact. Those names are scanned like any other, and the +//! registry lookup for a purely local name simply fails — the failure is +//! reported per-dependency and nothing is written, so the manifest is never +//! corrupted by a name the registry has never heard of. + +use dependency_check_updates_core::scalar_value_bounds; + +use crate::image::{ImageLocation, locate}; + +/// Scan YAML text and return every tracked `image:` value. +/// +/// Infallible: lines that are not an `image:` mapping key, and images this +/// tool deliberately leaves alone, are skipped without aborting the scan. +pub(crate) fn scan(text: &str) -> Vec { + let mut locations = Vec::new(); + let mut offset = 0usize; + for line in text.split_inclusive('\n') { + if let Some(location) = scan_line(line, offset) { + locations.push(location); + } + offset += line.len(); + } + locations +} + +/// Scan a single line for an `image:` mapping key naming a tracked image. +fn scan_line(line: &str, line_offset: usize) -> Option { + let (start, end) = scalar_value_bounds(line, "image:")?; + locate(line.get(start..end)?, line_offset + start) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + // Compose service, at the indentation Compose files actually use. + #[case::compose_service( + "services:\n db:\n image: postgres:16-alpine\n", + "postgres", + "16-alpine" + )] + // Workflow job container / service container. + #[case::workflow_container( + "jobs:\n test:\n container:\n image: node:20-alpine\n", + "node", + "20-alpine" + )] + // Quoting and trailing comments must not bleed into the tag. + #[case::single_quoted(" image: 'redis:7.4'\n", "redis", "7.4")] + #[case::double_quoted(" image: \"redis:7.4\"\n", "redis", "7.4")] + #[case::trailing_comment(" image: redis:7.4 # pinned\n", "redis", "7.4")] + // List-item form and CRLF endings. + #[case::list_item(" - image: redis:7.4\n", "redis", "7.4")] + #[case::crlf(" image: redis:7.4\r\n", "redis", "7.4")] + // Registry hosts, including a port that must not be read as a tag. + #[case::registry_host(" image: ghcr.io/org/app:v1.2.3\n", "ghcr.io/org/app", "v1.2.3")] + #[case::ported_host(" image: localhost:5000/app:1.2\n", "localhost:5000/app", "1.2")] + fn scan_yields_single_match( + #[case] yaml: &str, + #[case] expected_name: &str, + #[case] expected_tag: &str, + ) { + let locations = scan(yaml); + assert_eq!(locations.len(), 1, "got: {locations:?}"); + assert_eq!(locations[0].name, expected_name); + assert_eq!(locations[0].tag, expected_tag); + assert_eq!( + &yaml[locations[0].tag_start..locations[0].tag_end], + expected_tag + ); + } + + #[rstest] + // `image:` present, but not as a mapping key. + #[case::inside_comment(" # image: redis:7.4\n")] + #[case::inside_longer_key(" myimage: redis:7.4\n")] + #[case::valueless(" image:\n")] + // Images this tool deliberately leaves alone. + #[case::latest(" image: redis:latest\n")] + #[case::no_tag(" image: redis\n")] + #[case::digest_pin(" image: redis:7.4@sha256:abc123\n")] + #[case::interpolated(" image: redis:${REDIS_TAG}\n")] + #[case::compose_env_interpolation(" image: app:${TAG:-latest}\n")] + fn scan_yields_no_matches(#[case] yaml: &str) { + let locations = scan(yaml); + assert!( + locations.is_empty(), + "expected no matches, got {locations:?}" + ); + } + + #[test] + fn scan_records_every_service_at_distinct_offsets() { + let yaml = concat!( + "services:\n", + " db:\n", + " image: postgres:16-alpine\n", + " cache:\n", + " image: redis:7-alpine\n", + " app:\n", + " build: .\n", + " image: postgres:16-alpine\n", + ); + + let locations = scan(yaml); + assert_eq!(locations.len(), 3, "got: {locations:?}"); + assert_eq!(locations[0].name, "postgres"); + assert_eq!(locations[1].name, "redis"); + // The repeated image is recorded again at its own offset so the + // patcher updates both occurrences. + assert_eq!(locations[2].name, "postgres"); + assert!(locations[0].tag_start < locations[2].tag_start); + for location in &locations { + assert_eq!(&yaml[location.tag_start..location.tag_end], location.tag); + } + } +} diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 359ffb6..b0bcdd1 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -18,17 +18,19 @@ path = "src/lib.rs" [dependencies] dependency-check-updates-core.workspace = true +# Workflows spell their job / service containers with the same `image:` key a +# Compose file uses, so the container scanner is reused rather than duplicated. +dependency-check-updates-docker.workspace = true reqwest.workspace = true serde.workspace = true -serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["sync"] } node-semver.workspace = true -thiserror.workspace = true tracing.workspace = true futures.workspace = true [dev-dependencies] rstest.workspace = true +serde_json.workspace = true tokio = { workspace = true, features = ["rt-multi-thread"] } wiremock.workspace = true rustls.workspace = true diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 058c571..4d55490 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -9,6 +9,16 @@ //! Refs that do not look like version numbers (`@main`, `@master`, branch //! names, commit SHAs) are intentionally skipped: tracking the moving target //! they point at is the caller's responsibility. +//! +//! ## Two ecosystems, one file +//! +//! A workflow can also pin **container images** — a job `container:` or a +//! `services:` block — and those are ordinary `image:` keys resolved against a +//! container registry, not the GitHub Tags API. Both kinds of dependency are +//! collected here and distinguished by [`DependencySection`]: `GitHubActions` +//! for `uses:`, `DockerImage` for `image:`. The CLI routes each section to the +//! registry that can answer it, and `apply_updates` routes each planned update +//! back to the scanner that located it. #![warn(missing_docs)] @@ -19,7 +29,9 @@ mod registry; use std::path::Path; use dependency_check_updates_core::manifest::{ManifestHandler, ParsedManifest}; -use dependency_check_updates_core::{DcuError, ManifestKind, ManifestRef, PlannedUpdate}; +use dependency_check_updates_core::{ + DcuError, DependencySection, ManifestKind, ManifestRef, PlannedUpdate, +}; use parser::WorkflowManifest; use patcher::WorkflowPatcher; @@ -30,24 +42,38 @@ pub struct GitHubHandler; impl ManifestHandler for GitHubHandler { fn parse(&self, text: &str, path: &Path) -> Result { - let manifest = WorkflowManifest::parse(text); + let mut dependencies = WorkflowManifest::parse(text).dependencies; + dependencies.extend(dependency_check_updates_docker::yaml_image_dependencies( + text, + )); Ok(ParsedManifest { manifest_ref: ManifestRef { path: path.to_path_buf(), kind: ManifestKind::GitHubWorkflow, }, - original_text: manifest.original_text, - dependencies: manifest.dependencies, + dependencies, }) } fn apply_updates(&self, text: &str, updates: &[PlannedUpdate]) -> Result { + // Each scanner knows only its own key, so an update is handed to the + // one that located it. Applying them in two passes is safe because the + // two byte ranges never overlap — a line carries either `uses:` or + // `image:`, never both — and each pass re-scans the text it receives. + let (image_updates, action_updates): (Vec<_>, Vec<_>) = updates + .iter() + .cloned() + .partition(|update| update.section == DependencySection::DockerImage); + // `WorkflowPatcher::apply` only fails on overlapping patches, which the // line-by-line scanner can never produce (each `uses:` ref occupies a // distinct, byte-disjoint span and every update consumes a location at // most once). The error arm is therefore unreachable through this path. - Ok(WorkflowPatcher::apply(text, updates).expect("workflow patches never overlap")) + let patched = + WorkflowPatcher::apply(text, &action_updates).expect("workflow patches never overlap"); + + dependency_check_updates_docker::apply_yaml_image_updates(&patched, &image_updates) } } @@ -97,6 +123,83 @@ mod tests { ); } + /// A workflow that pins both an action and a job container must surface + /// both, each tagged with the section that decides which registry resolves + /// it. + #[test] + fn handler_parse_collects_actions_and_container_images() { + let yaml = concat!( + "jobs:\n", + " test:\n", + " container:\n", + " image: node:20-alpine\n", + " services:\n", + " redis:\n", + " image: redis:7.2\n", + " steps:\n", + " - uses: actions/checkout@v4\n", + ); + + let parsed = GitHubHandler + .parse(yaml, Path::new(".github/workflows/CI.yml")) + .unwrap(); + + let sections: Vec<_> = parsed.dependencies.iter().map(|d| d.section).collect(); + assert_eq!( + sections, + vec![ + DependencySection::GitHubActions, + DependencySection::DockerImage, + DependencySection::DockerImage, + ] + ); + let names: Vec<&str> = parsed + .dependencies + .iter() + .map(|d| d.name.as_str()) + .collect(); + assert_eq!(names, vec!["actions/checkout", "node", "redis"]); + } + + /// Both patchers must be able to run over one document without either + /// disturbing the other's ranges. + #[test] + fn handler_apply_updates_patches_actions_and_images_together() { + let yaml = concat!( + "jobs:\n", + " test:\n", + " container:\n", + " image: node:20-alpine # pinned\n", + " steps:\n", + " - uses: actions/checkout@v4\n", + " - uses: changepacks/action@main\n", + ); + + let result = GitHubHandler + .apply_updates( + yaml, + &[ + PlannedUpdate { + name: "actions/checkout".to_owned(), + section: DependencySection::GitHubActions, + from: "v4".to_owned(), + to: "v5".to_owned(), + }, + PlannedUpdate { + name: "node".to_owned(), + section: DependencySection::DockerImage, + from: "20-alpine".to_owned(), + to: "22-alpine".to_owned(), + }, + ], + ) + .unwrap(); + + assert!(result.contains("uses: actions/checkout@v5")); + assert!(result.contains("image: node:22-alpine # pinned")); + assert!(result.contains("changepacks/action@main")); + } + #[test] fn handler_apply_updates_preserves_unrelated_text() { let yaml = concat!( diff --git a/crates/github/src/parser.rs b/crates/github/src/parser.rs index c003e72..6a95e9a 100644 --- a/crates/github/src/parser.rs +++ b/crates/github/src/parser.rs @@ -9,10 +9,12 @@ //! its scalar value on `@`, and records absolute byte offsets of the version //! ref so the patcher can perform surgical substring replacement. -use dependency_check_updates_core::{DependencySection, DependencySpec}; +use dependency_check_updates_core::{ + DependencySection, DependencySpec, is_version_ref, scalar_value_bounds, +}; /// A located `uses:` directive in the workflow text. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct UsesLocation { /// `owner/repo` or `owner/repo/sub/path` — preserved verbatim from the /// source so output matches what the user wrote. @@ -30,8 +32,6 @@ pub struct UsesLocation { /// Parsed workflow manifest. #[derive(Debug)] pub struct WorkflowManifest { - /// The original raw text (preserved for surgical patching). - pub original_text: String, /// Version-like `uses:` refs collected as dependency specs. pub dependencies: Vec, } @@ -49,13 +49,11 @@ impl WorkflowManifest { name: loc.name, current_req: loc.current_ref, section: DependencySection::GitHubActions, + path_version: None, }) .collect(); - Self { - original_text: text.to_owned(), - dependencies, - } + Self { dependencies } } } @@ -77,25 +75,10 @@ pub fn scan(text: &str) -> Vec { /// Scan a single line; returns the directive iff it parses cleanly and its /// ref is version-like. fn scan_line(line: &str, line_offset: usize) -> Option { - let uses_pos = line.find("uses:")?; - - // Verify everything before `uses:` is YAML key context (whitespace + - // optional single `-` list-item marker). Anything else — including - // a leading `#` comment — disqualifies the line. - if !is_key_context(&line[..uses_pos]) { - return None; - } - - let after_colon = uses_pos + "uses:".len(); - let rest = line.get(after_colon..)?; - let leading_ws = rest.find(|c: char| !c.is_whitespace())?; - let value_start_in_line = after_colon + leading_ws; - let value_str = line.get(value_start_in_line..)?; - - // Strip optional surrounding quotes. `inner_start` is the byte offset - // (within `line`) of the first content char; `inner_end` is one past - // the last content char (so [inner_start, inner_end) is the value). - let (inner_start, inner_end) = parse_scalar_bounds(value_str, value_start_in_line)?; + // `inner_start` is the byte offset (within `line`) of the first content + // char; `inner_end` is one past the last (so `[inner_start, inner_end)` is + // the value, quotes and trailing comment excluded). + let (inner_start, inner_end) = scalar_value_bounds(line, "uses:")?; let inner = line.get(inner_start..inner_end)?; let at_pos = inner.find('@')?; @@ -124,112 +107,11 @@ fn scan_line(line: &str, line_offset: usize) -> Option { }) } -/// Returns true iff `s` contains only whitespace and at most one `-` token, -/// i.e. it looks like the indent of a YAML key (possibly inside a list). -fn is_key_context(s: &str) -> bool { - let mut seen_dash = false; - for ch in s.chars() { - if ch.is_whitespace() { - continue; - } - if ch == '-' && !seen_dash { - seen_dash = true; - continue; - } - return false; - } - true -} - -/// Compute the (start, end) byte offsets within `line` of the scalar value, -/// stripping optional surrounding quotes and any trailing `# comment` / -/// whitespace. -/// -/// `value_start_in_line` is the byte offset within `line` where `value_str` -/// begins; this is needed because the returned offsets are absolute within -/// `line`. -fn parse_scalar_bounds(value_str: &str, value_start_in_line: usize) -> Option<(usize, usize)> { - let first = value_str.chars().next()?; - if first == '\'' || first == '"' { - let close_rel = value_str.get(1..)?.find(first)?; - let inner_start = value_start_in_line + 1; - let inner_end = value_start_in_line + 1 + close_rel; - return Some((inner_start, inner_end)); - } - // Unquoted scalar: terminate at first whitespace or YAML comment marker. - let end_rel = value_str - .find(|c: char| c == '#' || c.is_whitespace()) - .unwrap_or(value_str.len()); - Some((value_start_in_line, value_start_in_line + end_rel)) -} - -/// Return true if `git_ref` looks like a version tag we want to track. -/// -/// Rules (all must hold): -/// 1. After stripping an optional leading `v`, the first char is a digit. -/// 2. The ref is NOT a commit SHA — heuristically defined as "all -/// hex digits, length ≥ 7, no dots", which matches both short and full -/// SHAs while letting `v5`, `v5.1`, `v5.1.0`, `2024.01.01`, `1.0-beta` -/// through. -/// -/// Refs that fail either rule (`@main`, `@master`, `@my-branch`, -/// `@8e5e7e5a3b4c1234abcdef0123456789abcdef01`) are intentionally skipped: -/// the user is opting out of automatic version pinning by referencing a -/// moving target or a content-addressed SHA. -#[must_use] -pub fn is_version_ref(git_ref: &str) -> bool { - let stripped = git_ref.strip_prefix('v').unwrap_or(git_ref); - let Some(first) = stripped.chars().next() else { - return false; - }; - if !first.is_ascii_digit() { - return false; - } - // SHA heuristic: pure hex, length ≥ 7, no dots. Real version tags - // contain dots (`1.2.3`) or are very short (`v5` → stripped = `5`). - if stripped.len() >= 7 - && !stripped.contains('.') - && stripped.chars().all(|c| c.is_ascii_hexdigit()) - { - return false; - } - true -} - #[cfg(test)] mod tests { use super::*; use rstest::rstest; - #[rstest] - // v-prefix versions accepted as version-like. - #[case::v_major("v5", true)] - #[case::v_major_minor("v5.1", true)] - #[case::v_major_minor_patch("v5.1.0", true)] - #[case::v_prerelease("v1.0.0-beta.1", true)] - // Bare numeric versions accepted (with or without v prefix). - #[case::bare_major("5", true)] - #[case::bare_semver("1.2.3", true)] - #[case::calendar_version("2024.01.01", true)] - // Short v-versions: `v12345` strips to `12345` (5 chars, < 7) so it - // bypasses the SHA heuristic and is treated as a version. - #[case::v_short_numeric("v12345", true)] - // Branch-like refs are rejected (not version-like). - #[case::branch_main("main", false)] - #[case::branch_master("master", false)] - #[case::branch_develop("develop", false)] - #[case::branch_release_with_slash("release/v5", false)] - // Commit SHAs are rejected by the hex+length heuristic. - #[case::sha_40_char("8e5e7e5a3b4c1234abcdef0123456789abcdef01", false)] - #[case::sha_7_char_starting_digit("1234567", false)] - #[case::sha_8_char_mixed_hex("12345abc", false)] - // Empty / lone `v` produce no leading digit → rejected. - #[case::empty("", false)] - #[case::just_v("v", false)] - fn is_version_ref_cases(#[case] input: &str, #[case] expected: bool) { - assert_eq!(is_version_ref(input), expected); - } - #[rstest] // Yaml that produces EXACTLY one `uses:` match — name, ref, and byte // offsets must slice back to the recorded ref. diff --git a/crates/github/src/patcher.rs b/crates/github/src/patcher.rs index df1454b..62ee92f 100644 --- a/crates/github/src/patcher.rs +++ b/crates/github/src/patcher.rs @@ -4,29 +4,17 @@ //! correspond to a version ref. Comments, indentation, anchors, blank lines, //! and any unrelated `uses:` directives (e.g. ones pinned to `@main` or a //! commit SHA) survive untouched. +//! +//! The actual descending-`replace_range` engine lives in +//! [`dependency_check_updates_core::patch`]; this module only handles the +//! workflow-specific scan-and-match step that turns a list of +//! [`PlannedUpdate`]s into byte-range [`Patch`]es. use dependency_check_updates_core::PlannedUpdate; +use dependency_check_updates_core::patch::{Patch, PatchError, apply_byte_patches}; use crate::parser::scan; -/// Errors returned by the patcher. -#[derive(Debug, thiserror::Error)] -pub enum PatchError { - /// Two updates resolved to overlapping byte ranges. Should not happen in - /// practice — each `uses:` ref occupies a distinct byte range — but the - /// check is cheap and prevents silent corruption. - #[error("overlapping patches detected")] - OverlappingPatches, -} - -/// A patch: replace bytes `[start..end)` with `new_value`. -#[derive(Debug, Clone)] -pub struct Patch { - pub start: usize, - pub end: usize, - pub new_value: String, -} - /// Format-preserving workflow patcher. pub struct WorkflowPatcher; @@ -72,35 +60,8 @@ impl WorkflowPatcher { }); } - apply_patches(text, &patches) - } -} - -/// Apply raw byte-range patches to `original`. -/// -/// Patches are applied from highest to lowest byte offset so each replacement -/// leaves the offsets of later (i.e. earlier-in-the-list) patches intact. -fn apply_patches(original: &str, patches: &[Patch]) -> Result { - if patches.is_empty() { - return Ok(original.to_owned()); - } - - let mut sorted: Vec<&Patch> = patches.iter().collect(); - sorted.sort_by_key(|p| std::cmp::Reverse(p.start)); - - for window in sorted.windows(2) { - // sorted descending: window[0].start >= window[1].start, so window[1] - // (the lower-start patch) must end at-or-before window[0] starts. - if window[1].end > window[0].start { - return Err(PatchError::OverlappingPatches); - } - } - - let mut result = original.to_owned(); - for patch in &sorted { - result.replace_range(patch.start..patch.end, &patch.new_value); + apply_byte_patches(text, &patches) } - Ok(result) } #[cfg(test)] @@ -216,24 +177,4 @@ mod tests { let result = WorkflowPatcher::apply(text, &make_updates(updates)).unwrap(); assert_eq!(result, expected); } - - #[test] - fn overlapping_patches_error() { - // Sentinel for parser bugs: two patches whose byte ranges overlap - // must surface as an error instead of silently corrupting the output. - let patches = vec![ - Patch { - start: 0, - end: 5, - new_value: "a".to_owned(), - }, - Patch { - start: 3, - end: 10, - new_value: "b".to_owned(), - }, - ]; - let result = apply_patches("abcdefghijk", &patches); - assert!(result.is_err()); - } } diff --git a/crates/github/src/registry.rs b/crates/github/src/registry.rs index c254015..96732a2 100644 --- a/crates/github/src/registry.rs +++ b/crates/github/src/registry.rs @@ -18,11 +18,10 @@ use tokio::sync::Semaphore; use tracing::{debug, trace}; use dependency_check_updates_core::{ - DcuError, DependencySpec, ResolvedVersion, TargetLevel, build_client, + DcuError, DependencySpec, ResolvedVersion, TargetLevel, build_client, count_numeric_segments, + is_version_ref, pad_to_three_segments, split_numeric_head, }; -use crate::parser::is_version_ref; - /// Cap on parallel GitHub API calls. The unauthenticated rate limit is /// 60 req/hr; keeping concurrency modest avoids burst-rejection during deep /// scans of multi-workflow repos. @@ -33,11 +32,73 @@ const MAX_CONCURRENT_REQUESTS: usize = 5; const TAGS_PER_PAGE: u32 = 100; /// One tag entry from the GitHub API. -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize)] struct Tag { name: String, } +/// Per-repo tag list with its parse + sort + highest-stable lookup AND the +/// `pick_existing_ref` numeric-prefix set already done once. Built once per +/// unique `owner/repo` in `resolve_batch` so each duplicate-repo dep reuses +/// the same prepared data instead of re-parsing and re-sorting the same +/// 100-entry tag list — and instead of rebuilding the same `tag_numerics` +/// `HashSet` inside `pick_existing_ref` for every dep that shares the repo. +struct PreparedTags { + sorted_versions: Vec, + highest_stable: Option, + /// Numeric tag prefixes (`v8.1.0` → `8.1.0`, `v8` → `8`) used by + /// `pick_existing_ref` to check existence at a given precision. Owned + /// `String`s let the set outlive the source `Vec` so the tag list + /// can be consumed once and dropped. + tag_numerics: HashSet, +} + +impl PreparedTags { + fn new(tags: Vec) -> Self { + // Single pass over the tag list: build `sorted_versions` and + // `tag_numerics` together AND inline the per-tag upfront work so + // `is_version_ref` and `strip_prefix('v')` each run exactly once + // per tag. The previous form delegated to `normalize_tag` AND + // `tag_numeric_str`, which each repeated `is_version_ref` + the + // `v`-strip — paying that cost twice per tag. The two helpers + // are kept intact for their other callers (`select_from_tags` + // and `pick_existing_ref` respectively). + // Capture the length before consuming `tags` in the loop below so we + // can pre-size both output collections and avoid grow-path reallocs. + let capacity = tags.len(); + let mut sorted_versions: Vec = Vec::with_capacity(capacity); + let mut tag_numerics: HashSet = HashSet::with_capacity(capacity); + for tag in tags { + // Mirror `normalize_tag` + `tag_numeric_str` semantics exactly, + // but share the up-front work between them. Skip both pushes when + // either `is_version_ref` is false OR `Version::parse` refuses + // (e.g. 4+-segment numerics) — same as `normalize_tag = None`. + if !is_version_ref(&tag.name) { + continue; + } + let stripped = tag.name.strip_prefix('v').unwrap_or(&tag.name); + let numeric_str = split_numeric_head(stripped).0.trim_end_matches('.'); + let padded = pad_to_three_segments(stripped); + let Ok(version) = node_semver::Version::parse(&padded) else { + continue; + }; + sorted_versions.push(version); + tag_numerics.insert(numeric_str.to_owned()); + } + // Unstable sort matches the cargo/npm registry convention for these + // final, already-unique version lists; `pdqsort` skips `Timsort`'s + // auxiliary buffer for the same observable ordering. See + // 0007-analyze.md F2. + sorted_versions.sort_unstable(); + let highest_stable = dependency_check_updates_core::highest_stable(&sorted_versions); + Self { + sorted_versions, + highest_stable, + tag_numerics, + } + } +} + /// GitHub Tags API client. #[derive(Clone)] pub struct GitHubActionsRegistry { @@ -93,14 +154,34 @@ impl GitHubActionsRegistry { /// `actions/checkout/sub/path` → `Some("actions/checkout")` (sub-action; /// tags still live on the parent repo) /// `not-a-valid-name` → `None` - fn repo_key(name: &str) -> Option { - let mut parts = name.splitn(3, '/'); - let owner = parts.next()?; - let repo = parts.next()?; - if owner.is_empty() || repo.is_empty() { + /// + /// Returns a borrowed prefix of `name` (the formatted output was always + /// byte-equivalent to such a prefix). The dedup site in `resolve_batch` + /// upgrades to owned via `.to_owned()` only when the borrowed key is newly + /// seen, so a workflow with N deps over R unique repos allocates exactly + /// R `String`s — once per unique repo — instead of N times. + fn repo_key(name: &str) -> Option<&str> { + // First '/' separates owner from repo. An owner of zero length + // (`/foo`, `/`) is rejected so we never emit `/repos//repo/tags`. + let first = name.find('/')?; + if first == 0 { + return None; + } + let after = first + 1; + // Trailing slash (`foo/`) — no repo segment at all. + if after >= name.len() { + return None; + } + // End of the repo segment is either the next '/' or end-of-string. + // `'/'` is ASCII single-byte so every returned index sits on a UTF-8 + // char boundary, keeping the final `&name[..end]` slice valid. + let end = name[after..].find('/').map_or(name.len(), |i| after + i); + // Empty repo segment (`foo//bar`) — same guard as the old + // `repo.is_empty()` check. + if end == after { return None; } - Some(format!("{owner}/{repo}")) + Some(&name[..end]) } /// Fetch tags for a single repo. @@ -185,33 +266,38 @@ impl GitHubActionsRegistry { target: TargetLevel, ) -> Vec<(usize, Result)> { // Step 1: collect unique repos. Sub-actions (`owner/repo/sub`) collapse - // to the same key as `owner/repo`. - let mut unique_repos: HashSet = HashSet::new(); + // to the same key as `owner/repo`. Dedup on a borrowed `&str` so the + // owned `String` is allocated EXACTLY ONCE per unique repo — the prior + // `HashSet` form took ownership before the dedup check and + // therefore allocated on every iteration only to drop the duplicates. + let mut seen: HashSet<&str> = HashSet::with_capacity(deps.len()); + let mut unique_repos: Vec = Vec::with_capacity(deps.len()); for dep in deps { if let Some(key) = Self::repo_key(&dep.name) { - unique_repos.insert(key); + if seen.insert(key) { + unique_repos.push(key.to_owned()); + } } } // Step 2: fan out fetches in parallel. - let mut fetch_futures = Vec::with_capacity(unique_repos.len()); - for repo in &unique_repos { - let repo = repo.clone(); - let me = self.clone(); - fetch_futures.push(async move { - let result = me.fetch_tags(&repo).await; - (repo, result) - }); - } - - let fetched = futures::future::join_all(fetch_futures).await; - let mut tags_by_repo: HashMap, String>> = HashMap::new(); + let fetched = futures::future::join_all(unique_repos.into_iter().map(|repo| async move { + let result = self.fetch_tags(&repo).await; + (repo, result) + })) + .await; + // Build prepared tag data ONCE per unique repo (parse + sort + + // highest-stable). A workflow that uses the same repo across N jobs + // now pays this cost once instead of N times. Pre-size to avoid + // reallocation as we insert each repo's prepared data. + let mut prepared_by_repo: HashMap> = + HashMap::with_capacity(fetched.len()); for (repo, result) in fetched { - tags_by_repo.insert(repo, result); + prepared_by_repo.insert(repo, result.map(PreparedTags::new)); } - // Step 3: resolve each dep against the cached tag list. Errors are - // duplicated per-dep so each failing dep gets its own diagnostic. + // Step 3: resolve each dep against the cached prepared data. Errors + // are duplicated per-dep so each failing dep gets its own diagnostic. let mut results = Vec::with_capacity(deps.len()); for (idx, dep) in deps.iter().enumerate() { let Some(key) = Self::repo_key(&dep.name) else { @@ -227,22 +313,22 @@ impl GitHubActionsRegistry { // Safe because `key` came from `repo_key(&dep.name)`, and every // such value was inserted into `unique_repos` (and therefore into - // `tags_by_repo`) above. Using `.expect()` documents the invariant - // and keeps the code path linear for coverage. - match tags_by_repo - .get(&key) + // `prepared_by_repo`) above. Using `.expect()` documents the + // invariant and keeps the code path linear for coverage. + match prepared_by_repo + .get(key) .expect("tags cache must contain every unique repo key") { - Ok(tags) => { - let mut resolved = select_from_tags(tags, &dep.current_req, target); + Ok(prepared) => { + let mut resolved = select_from_tags(prepared, &dep.current_req, target); // Collapse the resolved full version to the shortest ref // form that an actual tag backs (e.g. `v8` → `v8.1.0` when // only the full tag was published), so the emitted ref // never dangles. `compute_updates` skips its generic // precision truncation for GitHub on the strength of this. - resolved.selected = resolved - .selected - .map(|sel| pick_existing_ref(&sel, &dep.current_req, tags)); + resolved.selected = resolved.selected.map(|sel| { + pick_existing_ref(&sel, &dep.current_req, &prepared.tag_numerics) + }); trace!( action = %dep.name, current = %dep.current_req, @@ -325,29 +411,20 @@ fn normalize_tag(tag: &str) -> Option { return None; } let stripped = tag.strip_prefix('v').unwrap_or(tag); - // Separate the numeric `1.2.3` head from a `-pre+build` tail. - let (numeric, suffix) = stripped - .find(|c: char| !c.is_ascii_digit() && c != '.') - .map_or((stripped, ""), |i| stripped.split_at(i)); - - let parts: Vec<&str> = numeric.split('.').filter(|s| !s.is_empty()).collect(); - // `is_version_ref` above guarantees `numeric` starts with at least one - // digit, so `parts.len() >= 1` always — the previous explicit `0 =>` - // arm was unreachable and is folded into the wildcard `_` arm. - let padded = match parts.len() { - 1 => format!("{}.0.0{}", parts[0], suffix), - 2 => format!("{}.{}.0{}", parts[0], parts[1], suffix), - _ => format!("{numeric}{suffix}"), - }; + // Pad the numeric `1.2.3` head to three segments, preserving any + // `-pre+build` tail. Shared with the cli `compute_updates` safety net + // so both crates pad identically. + // + // Byte-equivalence with the old inline logic: `is_version_ref` guarantees + // `stripped` starts with a digit, so `find(non-digit-dot)` produces a + // (numeric, suffix) split whose concatenation IS `stripped`. The shared + // helper's `_ => v.to_owned()` therefore yields the same bytes as the old + // `_ => format!("{numeric}{suffix}")` for >=3-segment inputs. + let padded = pad_to_three_segments(stripped); node_semver::Version::parse(&padded).ok() } -/// Parse the user's current ref so we can compare against tag versions. -fn parse_current_ref(req: &str) -> Option { - normalize_tag(req) -} - /// Select a tag for the dep based on the target level. /// /// Parses + sorts the tag list, then delegates the target-match algorithm to @@ -361,30 +438,26 @@ fn parse_current_ref(req: &str) -> Option { /// lookup per tag), so true publish-date ordering is intentionally not /// attempted — unlike the npm/crates.io/PyPI registries, whose responses /// already carry timestamps. -fn select_from_tags(tags: &[Tag], current_req: &str, target: TargetLevel) -> ResolvedVersion { - // Parse + sort ascending by semver. - let mut versions: Vec = - tags.iter().filter_map(|t| normalize_tag(&t.name)).collect(); - versions.sort(); - - let highest_stable = versions - .iter() - .rev() - .find(|v| v.pre_release.is_empty()) - .map(node_semver::Version::to_string); - - let current = parse_current_ref(current_req); +fn select_from_tags( + prepared: &PreparedTags, + current_req: &str, + target: TargetLevel, +) -> ResolvedVersion { + // Parsing + sorting + highest-stable extraction live on `PreparedTags`, + // built once per unique repo by `resolve_batch`. This function is now + // only the per-dep `current_req` parse + `select_version` dispatch. + let current = normalize_tag(current_req); let selected = dependency_check_updates_core::select_version( current.as_ref(), - &versions, + &prepared.sorted_versions, target, - highest_stable.clone(), + prepared.highest_stable.as_deref(), None, ); ResolvedVersion { - latest: highest_stable, + latest: prepared.highest_stable.clone(), selected, } } @@ -398,27 +471,13 @@ fn tag_numeric_str(tag: &str) -> Option<&str> { return None; } let stripped = tag.strip_prefix('v').unwrap_or(tag); - Some( - stripped - .split(|c: char| !c.is_ascii_digit() && c != '.') - .next() - .unwrap_or("") - .trim_end_matches('.'), - ) + Some(split_numeric_head(stripped).0.trim_end_matches('.')) } /// Count the segment precision of the user's current ref (`v7` → 1, /// `v7.6` → 2, `v7.6.0` → 3). Always at least 1. fn ref_precision(req: &str) -> usize { - let stripped = req.strip_prefix('v').unwrap_or(req); - stripped - .split(|c: char| !c.is_ascii_digit() && c != '.') - .next() - .unwrap_or("") - .split('.') - .filter(|s| !s.is_empty()) - .count() - .max(1) + count_numeric_segments(req.strip_prefix('v').unwrap_or(req)).max(1) } /// Collapse a resolved full version to the shortest tag form that an actual @@ -439,10 +498,8 @@ fn ref_precision(req: &str) -> usize { /// `taiki-e/install-action`, which publishes hundreds of `v2.x.y` patch tags) /// would be wrongly escalated to `v2.81.6`, surfacing a spurious update even /// though `@v2` already floats to that version. -fn pick_existing_ref(selected: &str, current_req: &str, tags: &[Tag]) -> String { - let (numeric, suffix) = selected - .find(|c: char| !c.is_ascii_digit() && c != '.') - .map_or((selected, ""), |i| selected.split_at(i)); +fn pick_existing_ref(selected: &str, current_req: &str, tag_numerics: &HashSet) -> String { + let (numeric, suffix) = split_numeric_head(selected); if !suffix.is_empty() { return selected.to_owned(); } @@ -461,21 +518,23 @@ fn pick_existing_ref(selected: &str, current_req: &str, tags: &[Tag]) -> String return current_prefix; } - let exists = |p: usize| { - let candidate = segments[..p].join("."); - tags.iter() - .any(|t| tag_numeric_str(&t.name) == Some(candidate.as_str())) - }; - + // `tag_numerics` (the set of numeric tag prefixes such as `5.0.0`, `5`) + // is precomputed once per unique repo in `PreparedTags::new`, so every + // dep that shares the repo reuses the same HashSet instead of rebuilding + // a per-call one — turning the per-dep O(tags) HashSet build into O(1). // Prefer the shortest form at or above the pin precision; otherwise the // longest shorter form. The resolved version always came from a real tag, // so some precision in this order always matches — the `expect` documents // that invariant and keeps the success line on the covered path. - let chosen = (start..=len) + (start..=len) .chain((1..start).rev()) - .find(|&p| exists(p)) - .expect("resolved version is always backed by at least one tag"); - segments[..chosen].join(".") + .find_map(|p| { + let candidate = segments[..p].join("."); + tag_numerics + .contains(candidate.as_str()) + .then_some(candidate) + }) + .expect("resolved version is always backed by at least one tag") } #[cfg(test)] @@ -524,6 +583,7 @@ mod tests { name: name.to_owned(), current_req: current_req.to_owned(), section: DependencySection::GitHubActions, + path_version: None, } } @@ -601,6 +661,16 @@ mod tests { Some("4.1.0"), Some("4.1.0") )] + // A four-segment tag looks version-like to `is_version_ref` but has no + // semver reading, so `Version::parse` refuses it. It must be dropped + // rather than derailing the whole tag list. + #[case::ignores_four_segment_tags( + &["v4.0.0", "v1.2.3.4", "v4.1.0"], + "v4", + TargetLevel::Latest, + Some("4.1.0"), + Some("4.1.0") + )] // Minor happy-path: stable v4.1.0 wins over the in-between prerelease. #[case::minor_rejects_pre_when_current_is_stable_happy_path( &["v4.0.0", "v4.1.0-beta.1", "v4.1.0"], @@ -656,8 +726,8 @@ mod tests { #[case] expected_selected: Option<&str>, #[case] expected_latest: Option<&str>, ) { - let tags = make_tags(tag_names); - let r = select_from_tags(&tags, current_req, target); + let prepared = PreparedTags::new(make_tags(tag_names)); + let r = select_from_tags(&prepared, current_req, target); assert_eq!(r.selected.as_deref(), expected_selected); assert_eq!(r.latest.as_deref(), expected_latest); } @@ -723,8 +793,11 @@ mod tests { #[case] tag_names: &[&str], #[case] expected: &str, ) { - let tags = make_tags(tag_names); - assert_eq!(pick_existing_ref(selected, current, &tags), expected); + let prepared = PreparedTags::new(make_tags(tag_names)); + assert_eq!( + pick_existing_ref(selected, current, &prepared.tag_numerics), + expected, + ); } #[rstest] @@ -732,13 +805,20 @@ mod tests { #[case::with_subdir("actions/checkout/sub/dir", Some("actions/checkout"))] #[case::single_segment("checkout", None)] #[case::empty_string("", None)] - // `splitn(3, '/')` yields empty strings for `foo/` and `/foo`; the - // empty-half guard must catch them to avoid `/repos/foo//tags` URLs. + // `find('/')` guards: leading slash `first == 0`, trailing slash + // `after >= name.len()`, empty repo segment `end == after`. These catch + // the edge cases to avoid `/repos/foo//tags` URLs. #[case::trailing_slash("foo/", None)] #[case::leading_slash("/foo", None)] #[case::just_slash("/", None)] + // Empty repo segment: the owner is present but the repo name is not, so + // the guard must fire before we emit `/repos/foo//tags`. + #[case::empty_repo_segment("foo//bar", None)] fn repo_key_cases(#[case] input: &str, #[case] expected: Option<&str>) { - assert_eq!(GitHubActionsRegistry::repo_key(input).as_deref(), expected); + // `repo_key` now returns `Option<&str>` directly — `.as_deref()` would + // be a no-op (`Option<&str>::as_deref()` returns the same `Option<&str>`) + // and trips `clippy::needless_option_as_deref`. + assert_eq!(GitHubActionsRegistry::repo_key(input), expected); } #[rstest] diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 0dbb41d..dc97ff1 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -28,6 +28,11 @@ wiremock.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } rustls.workspace = true tracing-subscriber.workspace = true +criterion.workspace = true + +[[bench]] +name = "hot" +harness = false [lints] workspace = true diff --git a/crates/node/benches/hot.rs b/crates/node/benches/hot.rs new file mode 100644 index 0000000..b700e83 --- /dev/null +++ b/crates/node/benches/hot.rs @@ -0,0 +1,86 @@ +//! Criterion micro-benchmarks for the Node.js manifest hot paths: parsing a +//! representative `package.json` and applying format-preserving version patches +//! through the public [`ManifestHandler`] surface. +//! +//! Run with `cargo bench`. Criterion stores a baseline under +//! `target/criterion/`, so a second `cargo bench` after a change prints the +//! per-function delta. + +use std::hint::black_box; +use std::path::Path; +use std::time::Duration; + +use criterion::{Criterion, criterion_group, criterion_main}; +use dependency_check_updates_core::{DependencySection, ManifestHandler, PlannedUpdate}; +use dependency_check_updates_node::NodeHandler; + +/// A representative `package.json` with a realistic spread of dependencies and +/// devDependencies, mirroring the shape the parser and patcher see in practice. +const PACKAGE_JSON: &str = r#"{ + "name": "bench-fixture", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "^17.0.0", + "react-dom": "^17.0.0", + "lodash": "^4.17.20", + "axios": "^0.21.1", + "express": "^4.17.1", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "zod": "^3.11.6" + }, + "devDependencies": { + "typescript": "^4.3.5", + "eslint": "^7.32.0", + "jest": "^27.0.6", + "vite": "^2.4.4", + "prettier": "^2.3.2" + } +} +"#; + +fn bench_parse(c: &mut Criterion) { + let handler = NodeHandler; + let path = Path::new("package.json"); + c.bench_function("node_parse_package_json", |b| { + b.iter(|| black_box(handler.parse(black_box(PACKAGE_JSON), path))); + }); +} + +fn bench_apply_updates(c: &mut Criterion) { + let handler = NodeHandler; + let updates = vec![ + PlannedUpdate { + name: "react".to_owned(), + section: DependencySection::Dependencies, + from: "^17.0.0".to_owned(), + to: "^18.2.0".to_owned(), + }, + PlannedUpdate { + name: "lodash".to_owned(), + section: DependencySection::Dependencies, + from: "^4.17.20".to_owned(), + to: "^4.17.21".to_owned(), + }, + PlannedUpdate { + name: "typescript".to_owned(), + section: DependencySection::DevDependencies, + from: "^4.3.5".to_owned(), + to: "^5.4.5".to_owned(), + }, + ]; + c.bench_function("node_apply_updates", |b| { + b.iter(|| black_box(handler.apply_updates(black_box(PACKAGE_JSON), black_box(&updates)))); + }); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(60) + .warm_up_time(Duration::from_millis(500)) + .measurement_time(Duration::from_secs(2)); + targets = bench_parse, bench_apply_updates +} +criterion_main!(benches); diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index f292329..03e366b 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -12,10 +12,11 @@ mod registry; use std::path::Path; use dependency_check_updates_core::manifest::{ManifestHandler, ParsedManifest}; +use dependency_check_updates_core::patch::Patch; use dependency_check_updates_core::{DcuError, ManifestKind, ManifestRef, PlannedUpdate}; use parser::PackageJsonManifest; -use patcher::{JsonPatcher, Patch}; +use patcher::JsonPatcher; pub use registry::NpmRegistry; /// Node.js manifest handler for `package.json` files. @@ -33,7 +34,6 @@ impl ManifestHandler for NodeHandler { path: path.to_path_buf(), kind: ManifestKind::PackageJson, }, - original_text: manifest.original_text, dependencies: manifest.dependencies, }) } @@ -67,21 +67,10 @@ impl ManifestHandler for NodeHandler { mod tests { use super::*; use dependency_check_updates_core::manifest::ManifestHandler; - use dependency_check_updates_core::{DependencySection, DependencySpec, PlannedUpdate}; + use dependency_check_updates_core::{DependencySection, PlannedUpdate}; use rstest::rstest; use std::path::Path; - /// Test-only helper: true when the section belongs to the Node ecosystem. - fn is_node_ecosystem(dep: &DependencySpec) -> bool { - matches!( - dep.section, - DependencySection::Dependencies - | DependencySection::DevDependencies - | DependencySection::PeerDependencies - | DependencySection::OptionalDependencies - ) - } - #[rstest] // raw JSON, expected dependency count (None ⇒ parse must error). #[case::with_deps( @@ -180,16 +169,4 @@ mod tests { "expected PatchFailed with package.json path, got: {err_str}" ); } - - #[rstest] - #[case::node_dependencies(DependencySection::Dependencies, true)] - #[case::non_node_build_dependencies(DependencySection::BuildDependencies, false)] - fn is_node_ecosystem_cases(#[case] section: DependencySection, #[case] expected: bool) { - let dep = DependencySpec { - name: "pkg".to_owned(), - current_req: "^1.0.0".to_owned(), - section, - }; - assert_eq!(is_node_ecosystem(&dep), expected); - } } diff --git a/crates/node/src/parser.rs b/crates/node/src/parser.rs index ac8ec31..d6211b6 100644 --- a/crates/node/src/parser.rs +++ b/crates/node/src/parser.rs @@ -17,8 +17,6 @@ pub const DEPENDENCY_SECTIONS: &[(DependencySection, &str)] = &[ /// A parsed package.json file. #[derive(Debug)] pub struct PackageJsonManifest { - /// The original raw text (preserved for surgical patching). - pub original_text: String, /// All collected dependency specs. pub dependencies: Vec, } @@ -35,14 +33,16 @@ impl PackageJsonManifest { let dependencies = Self::collect_dependencies(&parsed); - Ok(Self { - original_text: text.to_owned(), - dependencies, - }) + Ok(Self { dependencies }) } fn collect_dependencies(root: &Value) -> Vec { - let mut deps = Vec::new(); + let capacity = DEPENDENCY_SECTIONS + .iter() + .filter_map(|(_, key)| root.get(key).and_then(Value::as_object)) + .map(serde_json::Map::len) + .sum(); + let mut deps = Vec::with_capacity(capacity); for &(section, key) in DEPENDENCY_SECTIONS { if let Some(Value::Object(map)) = root.get(key) { @@ -53,6 +53,7 @@ impl PackageJsonManifest { name: name.clone(), current_req: version_str.to_owned(), section, + path_version: None, }); } } @@ -65,6 +66,24 @@ impl PackageJsonManifest { } } +const SKIP_PREFIXES: &[&str] = &[ + "workspace:", + "npm:", + "git+", + "git:", + "github:", + "bitbucket:", + "gitlab:", + "gist:", + "http:", + "https:", + "file:", + "link:", + "catalog:", + "portal:", + "patch:", +]; + /// Check if a dependency value is a resolvable version spec. /// /// Filters out non-semver specifiers like workspace protocols, npm aliases, @@ -76,15 +95,9 @@ fn is_version_spec(value: &str) -> bool { if matches!(trimmed, "latest" | "*" | "x" | "X" | "") { return false; } - !value.starts_with("workspace:") - && !value.starts_with("npm:") - && !value.starts_with("git+") - && !value.starts_with("git:") - && !value.starts_with("github:") - && !value.starts_with("http:") - && !value.starts_with("https:") - && !value.starts_with("file:") - && !value.starts_with("link:") + !SKIP_PREFIXES + .iter() + .any(|prefix| trimmed.starts_with(prefix)) } /// Errors from package.json parsing. @@ -276,6 +289,43 @@ mod tests { "http-pkg": "http://example.com/pkg.tgz", "react": "^18.0.0" } +}"#, + "react" + )] + #[case::whitespace_led_protocols( + r#"{ + "dependencies": { + "ws-workspace": " workspace:*", + "ws-npm": " npm:react@^18.0.0", + "ws-git": " git+https://github.com/user/repo.git", + "ws-github": " github:user/repo", + "ws-http": " https://example.com/pkg.tgz", + "ws-file": " file:../local", + "ws-link": " link:../linked", + "react": "^18.0.0" + } +}"#, + "react" + )] + #[case::pnpm_catalog_yarn_portal_patch( + r#"{ + "dependencies": { + "pnpm-dep": "catalog:react", + "yarn-portal": "portal:../local-pkg", + "yarn-patch": "patch:left-pad@1.0.0#./p.patch", + "react": "^18.0.0" + } +}"#, + "react" + )] + #[case::npm_git_host_shortcuts( + r#"{ + "dependencies": { + "bitbucket-fork": "bitbucket:user/repo", + "gitlab-fork": "gitlab:user/repo", + "gist-fork": "gist:abc123def456", + "react": "^18.0.0" + } }"#, "react" )] @@ -285,13 +335,6 @@ mod tests { assert_eq!(manifest.dependencies[0].name, survivor); } - #[test] - fn test_original_text_preserved() { - let json = "{\n \"name\": \"test\",\n \"version\": \"1.0.0\"\n}\n"; - let manifest = PackageJsonManifest::parse(json).unwrap(); - assert_eq!(manifest.original_text, json); - } - #[test] fn test_range_prefixes_collected() { let json = r#"{ diff --git a/crates/node/src/patcher.rs b/crates/node/src/patcher.rs index db32f4a..84d68ca 100644 --- a/crates/node/src/patcher.rs +++ b/crates/node/src/patcher.rs @@ -4,12 +4,13 @@ //! finds the exact byte positions of dependency version strings in the original //! text and replaces only those bytes. +use dependency_check_updates_core::patch::{Patch, apply_byte_patches}; use dependency_check_updates_core::{DependencySection, PlannedUpdate}; use crate::parser::DEPENDENCY_SECTIONS; /// A located version string within the JSON text. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct VersionLocation { /// The dependency section this belongs to. pub section: DependencySection, @@ -21,14 +22,6 @@ pub struct VersionLocation { pub value_end: usize, } -/// A patch to apply: replace bytes `[start..end)` with `new_value`. -#[derive(Debug, Clone)] -pub struct Patch { - pub start: usize, - pub end: usize, - pub new_value: String, -} - /// Errors from the patch engine. #[derive(Debug, thiserror::Error)] pub enum PatchError { @@ -44,6 +37,16 @@ pub enum PatchError { ValidationFailed(String), } +impl From for PatchError { + fn from(value: dependency_check_updates_core::patch::PatchError) -> Self { + match value { + dependency_check_updates_core::patch::PatchError::OverlappingPatches => { + Self::OverlappingPatches + } + } + } +} + /// Format-preserving JSON patcher. pub struct JsonPatcher; @@ -144,36 +147,27 @@ impl JsonPatcher { /// Apply patches to the original text, replacing version strings. /// - /// Patches are applied back-to-front (highest offset first) so that earlier - /// byte offsets are not invalidated. + /// Delegates the byte-range mechanics (sort-descending, overlap check, + /// in-place `replace_range`) to the shared + /// [`apply_byte_patches`] primitive, then re-validates the resulting text + /// as JSON — the JSON-specific check that the YAML patcher does not need. /// /// # Errors /// - /// Returns an error if patches overlap or the result is not valid JSON. + /// Returns [`PatchError::OverlappingPatches`] (via the `From` impl on the + /// core [`PatchError`](dependency_check_updates_core::patch::PatchError)) + /// if any two patches touch the same byte range, or + /// [`PatchError::ValidationFailed`] if the patched output is not valid + /// JSON. pub fn apply_patches(original: &str, patches: &[Patch]) -> Result { - if patches.is_empty() { - return Ok(original.to_owned()); - } - - // Sort descending by start position - let mut sorted: Vec<&Patch> = patches.iter().collect(); - sorted.sort_by_key(|p| std::cmp::Reverse(p.start)); - - // Check for overlapping patches - for window in sorted.windows(2) { - // sorted is descending, so window[0].start >= window[1].start - if window[1].end > window[0].start { - return Err(PatchError::OverlappingPatches); - } - } - - let mut result = original.to_owned(); - for patch in &sorted { - result.replace_range(patch.start..patch.end, &patch.new_value); - } - - // Verify the result is still valid JSON - serde_json::from_str::(&result) + let result = apply_byte_patches(original, patches)?; + + // Verify the result is still valid JSON. `IgnoredAny` drives the same + // `serde_json` parser as `Value` (so it catches the same structural + // breakage a misformed patch could introduce) but discards every + // token instead of materialising a transient `Value`/`Map`/`Vec` + // tree — saving hundreds of allocations on a multi-KB package.json. + serde_json::from_str::(&result) .map_err(|e| PatchError::ValidationFailed(e.to_string()))?; Ok(result) @@ -198,19 +192,15 @@ fn find_section_bounds(text: &str, section_key: &str) -> Option<(usize, usize)> /// Searches for `"key"` as a JSON key (followed by `:`), starting from `from`. fn find_json_key_position(text: &str, key: &str, from: usize) -> Option { let needle = format!("\"{key}\""); - let bytes = text.as_bytes(); - let needle_bytes = needle.as_bytes(); let mut pos = from; - while pos + needle_bytes.len() <= bytes.len() { + while pos + needle.len() <= text.len() { if let Some(found) = text[pos..].find(&needle) { let abs_pos = pos + found; // Verify this is a key (followed by optional whitespace then `:`) - let after = abs_pos + needle_bytes.len(); - if let Some(colon_pos) = find_char_skipping_whitespace(text, ':', after) { - if colon_pos < text.len() { - return Some(abs_pos); - } + let after = abs_pos + needle.len(); + if find_char_skipping_whitespace(text, ':', after).is_some() { + return Some(abs_pos); } pos = abs_pos + 1; } else { @@ -235,19 +225,6 @@ fn find_char_skipping_whitespace(text: &str, ch: char, from: usize) -> Option Option { - for (i, c) in text[from..].char_indices() { - if c == '"' { - return Some(from + i); - } - if !c.is_whitespace() { - return None; // Non-whitespace, non-quote character found - } - } - None -} - /// Find the next occurrence of `ch` outside of JSON strings, starting from `from`. fn find_char_skipping_strings(text: &str, ch: char, from: usize) -> Option { let bytes = text.as_bytes(); @@ -339,7 +316,7 @@ fn find_dep_value_position( // Find the opening quote of the value string after the colon. // Skip whitespace then expect `"`. - let value_quote_start = find_next_quote(text, colon_pos + 1)?; + let value_quote_start = find_char_skipping_whitespace(text, '"', colon_pos + 1)?; // The value content starts after the opening quote let value_start = value_quote_start + 1; @@ -849,18 +826,6 @@ mod tests { assert_eq!(find_char_skipping_whitespace(text, ch, from), expected); } - #[rstest] - #[case::non_quote_char_first("abc\"", 0, None)] - #[case::leading_whitespace(" \"hello\"", 0, Some(2))] - #[case::empty_slice_from_end("abc", 3, None)] - fn find_next_quote_cases( - #[case] text: &str, - #[case] from: usize, - #[case] expected: Option, - ) { - assert_eq!(find_next_quote(text, from), expected); - } - #[rstest] // Single-section JSON: scan returns one location whose `section` matches. #[case::peer_dependencies( diff --git a/crates/node/src/registry.rs b/crates/node/src/registry.rs index 7dca235..ffadc5d 100644 --- a/crates/node/src/registry.rs +++ b/crates/node/src/registry.rs @@ -1,15 +1,18 @@ //! npm registry client for looking up package versions. +use std::borrow::Cow; +use std::fmt; use std::sync::Arc; use reqwest::Client; use serde::Deserialize; +use serde::de::{IgnoredAny, MapAccess, Visitor}; use tokio::sync::Semaphore; use tracing::{debug, trace}; use dependency_check_updates_core::{ DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, ResolvedVersion, TargetLevel, - build_client, collect_task_results, strip_range_prefix, + build_client, current_req_is_prerelease, send_checked, }; /// npm registry client for looking up package versions. @@ -25,13 +28,64 @@ pub struct NpmRegistry { struct NpmPackageInfo { #[serde(rename = "dist-tags")] dist_tags: Option, - versions: Option>, + versions: Option, /// Map of version → ISO-8601 publish time. Only present in the *full* /// packument (the abbreviated `install-v1` format omits it), so it is /// fetched on demand for `--target newest`. time: Option>, } +/// List of version-string keys extracted from a packument `versions` JSON +/// object. Each value body (the nested per-version metadata: `dependencies`, +/// `peerDependencies`, `dist`, ...) is walked past with `IgnoredAny` instead +/// of being materialised into a `serde_json::Value` tree, since downstream +/// code only ever needs the keys. Saves the per-version `Value`-tree +/// allocation on every npm packument parse — popular packages publish +/// hundreds of versions, each with multi-KB nested bodies. The container is a +/// `Vec` (not a `HashSet`) because JSON object keys are unique by +/// spec — the downstream consumer (`extract_sorted_versions`) only iterates +/// the keys and sorts them, never probing membership, so the per-key hashing +/// cost of a `HashSet` was pure overhead. +#[derive(Debug)] +struct VersionKeys(Vec); + +/// Collects the keys of the packument's `versions` object. +/// +/// Lives at module scope rather than nested inside [`VersionKeys::deserialize`] +/// so it is one plain item with one set of instantiations, which keeps its +/// coverage attributable. +struct VersionKeysVisitor; + +impl<'de> Visitor<'de> for VersionKeysVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a JSON object whose keys are version strings") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some(key) = map.next_key::()? { + // Skip the value body without materialising it. + let _: IgnoredAny = map.next_value()?; + keys.push(key); + } + Ok(keys) + } +} + +impl<'de> Deserialize<'de> for VersionKeys { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(VersionKeysVisitor).map(Self) + } +} + #[derive(Debug, Deserialize)] struct DistTags { latest: Option, @@ -62,11 +116,11 @@ impl NpmRegistry { /// /// Scoped packages like `@scope/name` need the `/` encoded as `%2F`. #[must_use] - pub fn encode_package_name(name: &str) -> String { + pub fn encode_package_name(name: &str) -> Cow<'_, str> { if name.starts_with('@') { - name.replacen('/', "%2F", 1) + Cow::Owned(name.replacen('/', "%2F", 1)) } else { - name.to_owned() + Cow::Borrowed(name) } } @@ -97,24 +151,8 @@ impl NpmRegistry { "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*" }; - let response = self - .client - .get(&url) - .header("Accept", accept) - .send() - .await - .map_err(|e| DcuError::RegistryLookup { - package: name.to_owned(), - detail: e.to_string(), - })?; - - if !response.status().is_success() { - let status = response.status(); - return Err(DcuError::RegistryLookup { - package: name.to_owned(), - detail: format!("HTTP {status}"), - }); - } + let request = self.client.get(&url).header("Accept", accept); + let response = send_checked(request, name).await?; response.json().await.map_err(|e| DcuError::RegistryLookup { package: name.to_owned(), @@ -134,11 +172,11 @@ impl NpmRegistry { ) -> Result { // `newest` needs publish timestamps, which only the full packument // carries; every other target uses the cheaper abbreviated format. - let info = self + let mut info = self .fetch_package_info(&dep.name, target == TargetLevel::Newest) .await?; - let latest = info.dist_tags.as_ref().and_then(|dt| dt.latest.clone()); + let latest = info.dist_tags.take().and_then(|dt| dt.latest); // Detect if the user's current requirement is a prerelease. When it is, // we cannot use the dist-tags.latest fast path because the user may be @@ -146,7 +184,7 @@ impl NpmRegistry { // dist-tags.latest points at `1.1.20`), and we must consider the full // sorted version list to preserve the "prerelease tail" policy. let current_is_prerelease = - parse_base_version(&dep.current_req).is_some_and(|v| !v.pre_release.is_empty()); + current_req_is_prerelease::(&dep.current_req); // Fast path: Latest + current is stable → return dist-tags.latest directly. let selected = if target == TargetLevel::Latest && !current_is_prerelease { @@ -171,7 +209,15 @@ impl NpmRegistry { newest_by_date(&info, &all_versions) .or_else(|| all_versions.last().map(ToString::to_string)) } else { - select_version(&dep.current_req, latest.as_ref(), &all_versions, target) + // Shared strip→parse→select sequence centralised in `core`; + // npm's `latest` (dist-tags) doubles as the fallback for the + // stable-`Latest` and unparseable-`Minor`/`Patch` cases. + dependency_check_updates_core::parse_and_select( + &dep.current_req, + &all_versions, + target, + latest.as_deref(), + ) } }; @@ -194,28 +240,20 @@ impl NpmRegistry { /// Resolve versions for a batch of dependencies concurrently. /// /// Returns `(index, result)` pairs preserving the original ordering. + /// Delegates the `join_all` pipeline to + /// [`dependency_check_updates_core::resolve_batch_concurrent`] so the + /// concurrency model — no `tokio::spawn`, no per-dep `JoinHandle` / + /// `DependencySpec` clones, source-order preserved — lives in exactly one + /// place across every per-dep registry. pub async fn resolve_batch( &self, deps: &[DependencySpec], target: TargetLevel, ) -> Vec<(usize, Result)> { - let mut handles = Vec::with_capacity(deps.len()); - - for (idx, dep) in deps.iter().enumerate() { - let dep = dep.clone(); - let registry = self.clone(); - - let handle = tokio::spawn(async move { - let result = registry.resolve_version(&dep, target).await; - (idx, result) - }); - - handles.push(handle); - } - - let mut results = collect_task_results(handles).await; - results.sort_unstable_by_key(|(idx, _)| *idx); - results + dependency_check_updates_core::resolve_batch_concurrent(deps, |dep| { + self.resolve_version(dep, target) + }) + .await } } @@ -238,12 +276,10 @@ fn newest_by_date(info: &NpmPackageInfo, all_versions: &[node_semver::Version]) .iter() .filter_map(|v| { let s = v.to_string(); - times - .get(&s) - .and_then(serde_json::Value::as_str) - .map(|t| (t.to_owned(), s)) + let t = times.get(&s).and_then(serde_json::Value::as_str)?; + Some((t, s)) }) - .max_by(|a, b| a.0.cmp(&b.0)) + .max_by(|a, b| a.0.cmp(b.0)) .map(|(_, s)| s) } @@ -254,44 +290,18 @@ fn extract_sorted_versions(info: &NpmPackageInfo) -> Vec { return Vec::new(); }; - let mut parsed: Vec = versions - .keys() - .filter_map(|v| node_semver::Version::parse(v).ok()) - .collect(); + let mut parsed: Vec = Vec::with_capacity(versions.0.len()); + parsed.extend( + versions + .0 + .iter() + .filter_map(|v| node_semver::Version::parse(v).ok()), + ); parsed.sort_unstable(); parsed } -/// Select the appropriate version based on target level. -/// -/// Thin wrapper over [`dependency_check_updates_core::select_version`]: parses -/// the current requirement and supplies npm's fallbacks (the dist-tags latest -/// for both the stable-`Latest` and unparseable-`Minor`/`Patch` cases). -fn select_version( - current_req_str: &str, - latest: Option<&String>, - all_versions: &[node_semver::Version], - target: TargetLevel, -) -> Option { - let current = parse_base_version(current_req_str); - dependency_check_updates_core::select_version( - current.as_ref(), - all_versions, - target, - latest.cloned(), - latest.cloned(), - ) -} - -/// Parse a base version from a requirement string. -/// -/// Strips leading range operators: `^1.2.3` -> `1.2.3`, `~2.0.0` -> `2.0.0`, -/// `>=1.0.0` -> `1.0.0`. -fn parse_base_version(req_str: &str) -> Option { - node_semver::Version::parse(strip_range_prefix(req_str)).ok() -} - #[cfg(test)] // rstest's `#[from(crypto_provider)] _crypto: ()` parameter resolves the // `crypto_provider` fixture in the macro-expanded body. The underscore is @@ -347,6 +357,7 @@ mod tests { name: name.to_owned(), current_req: current_req.to_owned(), section: DependencySection::Dependencies, + path_version: None, } } @@ -364,28 +375,7 @@ mod tests { #[case::scoped_types("@types/react", "@types%2Freact")] #[case::scoped_babel("@babel/core", "@babel%2Fcore")] fn encode_package_name_cases(#[case] input: &str, #[case] expected: &str) { - assert_eq!(NpmRegistry::encode_package_name(input), expected); - } - - #[rstest] - // Range prefix variants strip to the same `1.2.3` (or `1.0.0` for `>=`). - // `None` ⇒ the requirement has no parseable numeric prefix. - #[case::caret("^1.2.3", Some((1, 2, 3)))] - #[case::tilde("~1.2.3", Some((1, 2, 3)))] - #[case::gte(">=1.0.0", Some((1, 0, 0)))] - #[case::bare("1.2.3", Some((1, 2, 3)))] - #[case::star("*", None)] - fn parse_base_version_cases(#[case] input: &str, #[case] expected: Option<(u64, u64, u64)>) { - let result = parse_base_version(input); - match expected { - Some((major, minor, patch)) => { - let v = result.unwrap(); - assert_eq!(v.major, major); - assert_eq!(v.minor, minor); - assert_eq!(v.patch, patch); - } - None => assert!(result.is_none()), - } + assert_eq!(NpmRegistry::encode_package_name(input).as_ref(), expected); } #[rstest] @@ -425,9 +415,15 @@ mod tests { #[case] target: TargetLevel, #[case] expected: Option<&str>, ) { - let latest = latest_str.to_owned(); let versions = make_versions(versions); - let got = select_version(current_req, Some(&latest), &versions, target); + // Drives the same algorithm the registry now calls directly: the + // ecosystem-agnostic helper in `core` that fuses strip→parse→select. + let got = dependency_check_updates_core::parse_and_select::( + current_req, + &versions, + target, + Some(latest_str), + ); assert_eq!(got, expected.map(ToOwned::to_owned)); } @@ -436,13 +432,12 @@ mod tests { // Current: 4.0.0-beta.1. Unrelated 5.0.0-alpha.1 must NOT be selected. // Kept separate because its assertion is `assert_ne!`, not `assert_eq!`, // and rstest parametrization would obscure that distinction. - let latest = "3.5.0".to_owned(); let versions = make_versions(&["3.5.0", "4.0.0-beta.1", "5.0.0-alpha.1"]); - let result = select_version( + let result = dependency_check_updates_core::parse_and_select::( "4.0.0-beta.1", - Some(&latest), &versions, TargetLevel::Latest, + Some("3.5.0"), ); assert_ne!(result, Some("5.0.0-alpha.1".to_owned())); } @@ -451,22 +446,11 @@ mod tests { fn test_extract_sorted_versions() { let info = NpmPackageInfo { dist_tags: None, - versions: Some({ - let mut map = serde_json::Map::new(); - map.insert( - "2.0.0".to_owned(), - serde_json::Value::Object(serde_json::Map::new()), - ); - map.insert( - "1.0.0".to_owned(), - serde_json::Value::Object(serde_json::Map::new()), - ); - map.insert( - "1.5.0".to_owned(), - serde_json::Value::Object(serde_json::Map::new()), - ); - map - }), + versions: Some(VersionKeys(vec![ + "2.0.0".to_owned(), + "1.0.0".to_owned(), + "1.5.0".to_owned(), + ])), time: None, }; let versions = extract_sorted_versions(&info); @@ -487,6 +471,66 @@ mod tests { assert!(versions.is_empty()); } + /// `versions` is deserialized by a hand-written `Visitor` so the packument's + /// multi-KB per-version bodies are walked past instead of materialised. + /// A registry that answers with the wrong JSON shape must therefore produce + /// a readable serde error naming what was expected, not a bare "invalid + /// type" — which is exactly what `Visitor::expecting` supplies. + #[test] + fn version_keys_rejects_a_non_object_with_an_explanatory_error() { + let error = serde_json::from_str::(r#"{"versions": ["1.0.0"]}"#) + .expect_err("an array is not a valid `versions` map"); + + assert!( + error + .to_string() + .contains("a JSON object whose keys are version strings"), + "the visitor's `expecting` text must reach the message: {error}" + ); + } + + /// `serde_json` rejects a mis-shaped `versions` before it ever reaches the + /// visitor, so that route only exercises `expecting`. Feeding the visitor a + /// sequence directly drives its inherited `visit_seq`, proving the visitor + /// itself — not just the JSON parser in front of it — refuses anything that + /// is not a map, and does so with the same explanatory wording. + #[test] + fn version_keys_rejects_a_sequence_handed_straight_to_the_visitor() { + use serde::de::IntoDeserializer; + use serde::de::value::{Error as ValueError, SeqDeserializer}; + + let deserializer: SeqDeserializer<_, ValueError> = SeqDeserializer::new( + ["1.0.0"] + .into_iter() + .map(IntoDeserializer::into_deserializer), + ); + + let error = + VersionKeys::deserialize(deserializer).expect_err("a sequence is not a versions map"); + + assert!( + error + .to_string() + .contains("a JSON object whose keys are version strings"), + "the visitor must explain what it wanted: {error}" + ); + } + + #[test] + fn version_keys_collects_object_keys_and_skips_their_bodies() { + // The happy path of the same Visitor: keys are kept, the nested + // per-version metadata is walked past without being materialised. + let info: NpmPackageInfo = serde_json::from_str( + r#"{"versions": {"1.0.0": {"dist": {"tarball": "x"}}, "2.0.0": {}}}"#, + ) + .expect("a versions object must deserialize"); + + let versions = extract_sorted_versions(&info); + assert_eq!(versions.len(), 2); + assert_eq!(versions[0].to_string(), "1.0.0"); + assert_eq!(versions[1].to_string(), "2.0.0"); + } + #[rstest] // Both `NpmRegistry::new()` and `NpmRegistry::default()` must construct // a usable client without panicking. `_crypto` ensures the rustls provider diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index 50a9a62..f92d360 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -14,10 +14,9 @@ categories.workspace = true [dependencies] dependency-check-updates-core.workspace = true -pep440_rs = "0.7.3" +pep440_rs.workspace = true toml_edit.workspace = true serde.workspace = true -serde_json.workspace = true reqwest.workspace = true tokio = { workspace = true, features = ["sync"] } tracing.workspace = true @@ -25,6 +24,7 @@ thiserror.workspace = true [dev-dependencies] rstest.workspace = true +serde_json.workspace = true wiremock.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } rustls.workspace = true diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 46386ec..287b2fd 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -31,7 +31,6 @@ impl ManifestHandler for PythonHandler { path: path.to_path_buf(), kind: ManifestKind::PyProjectToml, }, - original_text: manifest.original_text, dependencies: manifest.dependencies, }) } diff --git a/crates/python/src/parser.rs b/crates/python/src/parser.rs index 33e8820..508c443 100644 --- a/crates/python/src/parser.rs +++ b/crates/python/src/parser.rs @@ -11,8 +11,6 @@ use toml_edit::{DocumentMut, Item}; /// A parsed pyproject.toml file. #[derive(Debug)] pub struct PyProjectManifest { - /// The original raw text. - pub original_text: String, /// The `toml_edit` document (format-preserving). pub doc: DocumentMut, /// Collected dependencies. @@ -32,11 +30,7 @@ impl PyProjectManifest { let dependencies = Self::collect_dependencies(&doc); - Ok(Self { - original_text: text.to_owned(), - doc, - dependencies, - }) + Ok(Self { doc, dependencies }) } fn collect_dependencies(doc: &DocumentMut) -> Vec { @@ -45,15 +39,7 @@ impl PyProjectManifest { // PEP 621: [project] dependencies = ["requests>=2.0", ...] if let Some(project) = doc.get("project").and_then(Item::as_table) { if let Some(dep_array) = project.get("dependencies").and_then(Item::as_array) { - for item in dep_array { - if let Some(spec_str) = item.as_str() { - if let Some(dep) = - parse_pep508_spec(spec_str, DependencySection::ProjectDependencies) - { - deps.push(dep); - } - } - } + collect_pep508_array(dep_array, DependencySection::ProjectDependencies, &mut deps); } // [project.optional-dependencies] @@ -61,74 +47,34 @@ impl PyProjectManifest { .get("optional-dependencies") .and_then(Item::as_table) { - for (_group, items) in opt_deps { - if let Some(arr) = items.as_array() { - for item in arr { - if let Some(spec_str) = item.as_str() { - if let Some(dep) = parse_pep508_spec( - spec_str, - DependencySection::OptionalDependencies, - ) { - deps.push(dep); - } - } - } - } - } + collect_pep508_array_table( + opt_deps, + DependencySection::OptionalDependencies, + &mut deps, + ); } } - // Poetry: [tool.poetry.dependencies] + // Poetry: [tool.poetry.dependencies] and [tool.poetry.dev-dependencies] + // funnel through one shared `collect_poetry_table` helper — the two + // loops were previously byte-for-byte identical except for the + // `DependencySection` literal, and the dev-loop's `python` skip + // comment already mirrored the main-loop guard, signalling the + // duplication. See 0007-analyze.md F1. if let Some(tool) = doc.get("tool").and_then(Item::as_table) { if let Some(poetry) = tool.get("poetry").and_then(Item::as_table) { - if let Some(poetry_deps) = poetry.get("dependencies").and_then(Item::as_table) { - for (name, item) in poetry_deps { - if name == "python" { - continue; // Skip python version constraint - } - if let Some(version) = extract_poetry_version(item) { - if !is_wildcard_req(&version) { - deps.push(DependencySpec { - name: name.to_owned(), - current_req: version, - section: DependencySection::Dependencies, - }); - } - } - } + if let Some(t) = poetry.get("dependencies").and_then(Item::as_table) { + collect_poetry_table(t, DependencySection::Dependencies, &mut deps); } - // Poetry dev-dependencies - if let Some(dev_deps) = poetry.get("dev-dependencies").and_then(Item::as_table) { - for (name, item) in dev_deps { - if let Some(version) = extract_poetry_version(item) { - if !is_wildcard_req(&version) { - deps.push(DependencySpec { - name: name.to_owned(), - current_req: version, - section: DependencySection::DevDependencies, - }); - } - } - } + if let Some(t) = poetry.get("dev-dependencies").and_then(Item::as_table) { + collect_poetry_table(t, DependencySection::DevDependencies, &mut deps); } } } // PEP 735: [dependency-groups] if let Some(groups) = doc.get("dependency-groups").and_then(Item::as_table) { - for (_group_name, items) in groups { - if let Some(arr) = items.as_array() { - for item in arr { - if let Some(spec_str) = item.as_str() { - if let Some(dep) = - parse_pep508_spec(spec_str, DependencySection::DevDependencies) - { - deps.push(dep); - } - } - } - } - } + collect_pep508_array_table(groups, DependencySection::DevDependencies, &mut deps); } deps @@ -147,70 +93,209 @@ impl PyProjectManifest { } fn apply_single_update(&mut self, update: &PlannedUpdate) { - // Try PEP 621 project.dependencies + // Try PEP 621 project.dependencies (and optional-dependencies) if let Some(project) = self.doc.get_mut("project").and_then(Item::as_table_mut) { if let Some(dep_array) = project.get_mut("dependencies").and_then(Item::as_array_mut) { - for item in dep_array.iter_mut() { - let Some(spec_str) = item.as_str() else { - continue; - }; - if !spec_str_matches_name(spec_str, &update.name) { - continue; + if apply_to_pep508_array(dep_array, update) { + return; + } + } + // PEP 621: [project.optional-dependencies] — one named array per + // extra group; the matrix in 0027-analyze.md flagged this as a + // silent drop. Each value is a PEP 508 array, identical shape to + // the main `dependencies` array above. + if let Some(opt) = project + .get_mut("optional-dependencies") + .and_then(Item::as_table_mut) + { + for (_group, items) in opt.iter_mut() { + if let Some(arr) = items.as_array_mut() { + if apply_to_pep508_array(arr, update) { + return; + } } - let new_spec = replace_version_in_pep508(spec_str, &update.to); - // Preserve the element's surrounding decor (leading newline + - // indentation, trailing whitespace/comment) instead of - // replacing the value wholesale — a fresh `Formatted::new` - // carries empty decor, which collapses a multi-line - // `dependencies` array onto a single line. Mirrors the - // decor-preserving Poetry path below. - if let toml_edit::Value::String(s) = item { - let mut new_s = toml_edit::Formatted::new(new_spec); - *new_s.decor_mut() = s.decor().clone(); - *s = new_s; + } + } + } + + // PEP 735: [dependency-groups] — modern standard for dev dep groups. + if let Some(groups) = self + .doc + .get_mut("dependency-groups") + .and_then(Item::as_table_mut) + { + for (_group, items) in groups.iter_mut() { + if let Some(arr) = items.as_array_mut() { + if apply_to_pep508_array(arr, update) { + return; } - return; } } } - // Try Poetry tool.poetry.dependencies + // Try Poetry tool.poetry.dependencies (and dev-dependencies). All + // three Poetry value shapes that `extract_poetry_version` recognises + // (string, inline-table, full-table) are handled by the shared + // `apply_to_poetry_table` helper — the previous string-only path + // silently dropped inline/full-table updates that `compute_updates` + // had already planned, so `dcu -u` printed the row but left the file + // unchanged. See 0036-analyze.md F1. if let Some(tool) = self.doc.get_mut("tool").and_then(Item::as_table_mut) { if let Some(poetry) = tool.get_mut("poetry").and_then(Item::as_table_mut) { if let Some(deps) = poetry.get_mut("dependencies").and_then(Item::as_table_mut) { - if let Some(Item::Value(toml_edit::Value::String(s))) = - deps.get_mut(&update.name) - { - let decor = s.decor().clone(); - let mut new_s = toml_edit::Formatted::new(update.to.clone()); - *new_s.decor_mut() = decor; - *s = new_s; + if apply_to_poetry_table(deps, &update.name, &update.to) { + return; } } + if let Some(deps) = poetry + .get_mut("dev-dependencies") + .and_then(Item::as_table_mut) + { + apply_to_poetry_table(deps, &update.name, &update.to); + } } } - // Silently skip if not found (may be in optional-deps or groups) + // Silently skip if the dep is truly absent from every supported + // section. This now only fires on real no-ops, not on the three + // sections this method previously dropped. } } -/// Parse a PEP 508 dependency spec like `"requests>=2.28.0"` or `"flask~=2.0"`. +/// Walk a PEP 508 array, parsing each string element via +/// [`parse_pep508_spec`] and pushing every successfully-parsed +/// [`DependencySpec`] into `deps` under the given `section`. /// -/// Returns `None` for specs without version constraints (e.g., bare `"requests"`). -fn parse_pep508_spec(spec: &str, section: DependencySection) -> Option { - let spec = spec.trim(); +/// Mirrors the patch-side [`apply_to_pep508_array`] so the parse and patch +/// sides share the same shape: both walk the array, both skip non-string +/// elements, both delegate the per-element work to a single helper. The +/// three PEP 508 array sites in [`PyProjectManifest::collect_dependencies`] +/// (PEP 621 `[project].dependencies`, PEP 621 `[project.optional-dependencies]` +/// groups, PEP 735 `[dependency-groups]` groups) all funnel through here. +fn collect_pep508_array( + arr: &toml_edit::Array, + section: DependencySection, + deps: &mut Vec, +) { + for item in arr { + if let Some(spec_str) = item.as_str() { + if let Some(dep) = parse_pep508_spec(spec_str, section) { + deps.push(dep); + } + } + } +} - // Find where the version constraint starts (first non-alphanumeric, non-hyphen, non-dot, non-underscore) +/// Iterate a table of PEP 508 arrays (e.g., `[project.optional-dependencies]` +/// or `[dependency-groups]`), calling `collect_pep508_array` for each array value. +fn collect_pep508_array_table( + table: &toml_edit::Table, + section: DependencySection, + deps: &mut Vec, +) { + for (_group, items) in table { + if let Some(arr) = items.as_array() { + collect_pep508_array(arr, section, deps); + } + } +} + +/// Walk a Poetry dependency table (`[tool.poetry.dependencies]` or +/// `[tool.poetry.dev-dependencies]`), pushing every collected +/// [`DependencySpec`] into `deps` under the given `section`. +/// +/// Funnels the two previously-duplicated inner loops in +/// [`PyProjectManifest::collect_dependencies`] through one shared body so +/// the `python = "^…"` interpreter guard, the +/// [`extract_poetry_version`] extraction, the [`is_wildcard_req`] skip, and +/// the [`DependencySpec`] shape (including `path_version: None`) all live in +/// exactly one place. The `section` parameter is the only piece that +/// differed between the main- and dev-dep loops, mirroring the analyze +/// report's `collect_pep508_array` parallel. +fn collect_poetry_table( + table: &toml_edit::Table, + section: DependencySection, + deps: &mut Vec, +) { + for (name, item) in table { + // `python` here is the interpreter version constraint Poetry tracks, + // not a PyPI package; both the main- and dev-dep loops have always + // skipped it (see 0004-analyze.md for the dev-loop addition). + if name == "python" { + continue; + } + let Some(version) = extract_poetry_version(item) else { + continue; + }; + if is_wildcard_req(&version) { + continue; + } + deps.push(DependencySpec { + name: name.to_owned(), + current_req: version, + section, + path_version: None, + }); + } +} + +/// Walk a PEP 508 array; on the first element whose name matches +/// `update.name`, rewrite its version constraint via +/// [`replace_version_in_pep508`], preserving the element's decor exactly. +/// +/// Returns `true` if a match was found (caller should stop searching). +/// +/// Faithful extraction of the existing PEP 621 main-array inner loop — same +/// matching predicate, same decor preservation, no semantic drift. +fn apply_to_pep508_array(arr: &mut toml_edit::Array, update: &PlannedUpdate) -> bool { + let update_name_norm = normalize_pep503(&update.name); + for item in arr.iter_mut() { + let Some(spec_str) = item.as_str() else { + continue; + }; + if !spec_name_matches_normalized(spec_str, &update_name_norm) { + continue; + } + let new_spec = replace_version_in_pep508(spec_str, &update.to); + // Preserve the element's surrounding decor (leading newline + + // indentation, trailing whitespace/comment) — a fresh `Formatted::new` + // carries empty decor, which would collapse a multi-line array onto a + // single line. + if let toml_edit::Value::String(s) = item { + dependency_check_updates_core::replace_string_preserving_decor(s, new_spec); + } + return true; + } + false +} + +/// Split a PEP 508 dependency spec into `(name, rest)` at the PEP 503 name +/// boundary. The first character outside `[A-Za-z0-9._-]` ends the name; +/// `rest` is everything from that offset onwards (extras, version, marker). +/// +/// Borrow-only; no allocation. Single source of truth for "where does the +/// package-name head stop and the rest of the PEP 508 spec begin" — every +/// other helper in this module that needs that split calls this function +/// instead of open-coding the boundary scan again, so adding any future +/// PEP 508 / PEP 685 edge case (tightening quoting in environment markers, +/// accepting unicode-normalised names) only has to land here. +fn split_pep508_name(spec: &str) -> (&str, &str) { + let spec = spec.trim(); let name_end = spec .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.') .unwrap_or(spec.len()); + spec.split_at(name_end) +} - let name = spec[..name_end].trim(); +/// Parse a PEP 508 dependency spec like `"requests>=2.28.0"` or `"flask~=2.0"`. +/// +/// Returns `None` for specs without version constraints (e.g., bare `"requests"`). +fn parse_pep508_spec(spec: &str, section: DependencySection) -> Option { + let (name, rest) = split_pep508_name(spec); if name.is_empty() { return None; } - - let rest = spec[name_end..].trim(); + let rest = rest.trim(); // Remove extras like [security] before version let rest = if rest.starts_with('[') { @@ -226,6 +311,11 @@ fn parse_pep508_spec(spec: &str, section: DependencySection) -> Option Option bool { - let spec = spec.trim(); - let name_end = spec - .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.') - .unwrap_or(spec.len()); - let spec_name = &spec[..name_end]; + spec_name_matches_normalized(spec, &normalize_pep503(name)) +} + +/// Check if a PEP 508 spec string matches an already-PEP-503-normalized name. +/// +/// Callers that compare one name against many specs should normalize the name +/// once and use this directly, avoiding a loop-invariant allocation per spec. +fn spec_name_matches_normalized(spec: &str, normalized_name: &str) -> bool { + let (spec_name, _) = split_pep508_name(spec); // PEP 503 normalized comparison (case-insensitive, treat - _ . as equivalent) - normalize_pep503(spec_name) == normalize_pep503(name) + normalize_pep503(spec_name) == normalized_name } fn normalize_pep503(name: &str) -> String { @@ -255,15 +354,9 @@ fn normalize_pep503(name: &str) -> String { /// Replace the version constraint in a PEP 508 spec string. fn replace_version_in_pep508(spec: &str, new_version: &str) -> String { - let spec = spec.trim(); - let name_end = spec - .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.') - .unwrap_or(spec.len()); - - let name = &spec[..name_end]; + let (name, rest) = split_pep508_name(spec); // Check for extras - let rest = &spec[name_end..]; let (extras, rest) = if rest.starts_with('[') { rest.find(']') .map_or(("", rest), |i| (&rest[..=i], rest[i + 1..].trim_start())) @@ -302,6 +395,64 @@ fn extract_poetry_version(item: &Item) -> Option { } } +/// Patch a single Poetry dep entry across all three value shapes that +/// [`extract_poetry_version`] collects from: +/// +/// 1. `foo = "^2.0"` → `Item::Value(String)` +/// 2. `foo = {version = "^2.0", extras=[…]}` → `Item::Value(InlineTable)` +/// 3. `[tool.poetry.dependencies.foo]`/`version = …` → `Item::Table` +/// +/// Returns `true` when the name was found AND a `version` field existed to +/// rewrite (caller should stop searching). For shapes 2 and 3 the entry's +/// sibling keys (`extras`, `optional`, `source`, …) are left untouched, and +/// the `version` value's surrounding decor (leading whitespace, trailing +/// comments) is preserved byte-for-byte so format-preservation guarantees +/// hold. Mirrors the cargo-side [`update_dep_in_table`](../../../rust/src/parser.rs) +/// triple-shape `match` for behavioural parity. +fn apply_to_poetry_table(table: &mut toml_edit::Table, name: &str, new_version: &str) -> bool { + let Some(item) = table.get_mut(name) else { + return false; + }; + match item { + Item::Value(toml_edit::Value::String(s)) => { + dependency_check_updates_core::replace_string_preserving_decor( + s, + new_version.to_owned(), + ); + true + } + Item::Value(toml_edit::Value::InlineTable(t)) => { + let Some(v) = t.get_mut("version") else { + return false; + }; + if let toml_edit::Value::String(s) = v { + dependency_check_updates_core::replace_string_preserving_decor( + s, + new_version.to_owned(), + ); + } else { + *v = toml_edit::Value::String(toml_edit::Formatted::new(new_version.to_owned())); + } + true + } + Item::Table(t) => { + let Some(v) = t.get_mut("version") else { + return false; + }; + if let Item::Value(toml_edit::Value::String(s)) = v { + dependency_check_updates_core::replace_string_preserving_decor( + s, + new_version.to_owned(), + ); + } else { + *v = toml_edit::value(new_version); + } + true + } + _ => false, + } +} + /// Errors from pyproject.toml operations. #[derive(Debug, thiserror::Error)] pub enum PyProjectError { @@ -372,6 +523,7 @@ mod tests { #[case::empty_string("")] #[case::equals_wildcard("requests==*")] #[case::bare_star("requests *")] + #[case::direct_reference("requests @ https://example.com/requests-2.31.0.zip")] fn parse_pep508_spec_without_constraint_cases(#[case] spec: &str) { assert!(parse_pep508_spec(spec, DependencySection::ProjectDependencies).is_none()); } @@ -412,6 +564,36 @@ mod tests { 1, Some((None, None, Some(DependencySection::DevDependencies))), )] + // Regression: `python` is the interpreter version constraint Poetry + // tracks, not a PyPI package. The main-deps loop has always skipped it; + // the dev-deps loop now mirrors that guard so a `python = "^3.11"` pin + // under `[tool.poetry.dev-dependencies]` no longer leaks into the + // resolve pipeline (`pytest` remains the only surviving spec). + #[case::poetry_dev_dependencies_skips_python( + "\n[tool.poetry.dev-dependencies]\npython = \"^3.11\"\npytest = \"^7.0\"\n", + 1, + Some(( + Some("pytest"), + Some("^7.0"), + Some(DependencySection::DevDependencies), + )), + )] + // Regression: a single Poetry table exercising all three `continue` + // guards in `collect_poetry_table` at once — `python` (interpreter + // pin, not a package), `gitdep` (an inline table with no `version` + // key, so `extract_poetry_version` returns `None`), and + // `wildcarddep` (`"*"`, an unresolvable wildcard). If any guard + // regressed, one of these three would leak into the dependency list + // alongside `requests`. + #[case::poetry_skips_python_git_and_wildcard( + "\n[tool.poetry.dependencies]\npython = \"^3.11\"\ngitdep = { git = \"https://example.com/repo.git\" }\nwildcarddep = \"*\"\nrequests = \"^2.28.0\"\n", + 1, + Some(( + Some("requests"), + Some("^2.28.0"), + Some(DependencySection::Dependencies), + )), + )] #[case::dependency_groups( "\n[dependency-groups]\ntest = [\"pytest>=7.0\", \"coverage>=7.0\"]\n", 2, @@ -628,4 +810,234 @@ dependencies = [ let expected = "[project]\nname = \"demo\"\ndependencies = [\n \"pytz>=2026.2\",\n \"requests>=2.30.0\",\n]\n"; assert_eq!(result, expected); } + + // ---------- 0027: previously-dropped sections now patched ---------- + // + // Before this iteration, `apply_single_update` only patched PEP 621 main + // `dependencies` and Poetry main `dependencies`. The four tests below lock + // in the fix for the three sections that were silently dropped, plus a + // pure no-op guard so the new branches cannot accidentally panic or + // mutate when the dep is truly absent. + + #[test] + fn apply_updates_patches_pep621_optional_dependencies() { + // [project.optional-dependencies] dev = [...] — F1 in 0027-analyze.md. + let toml = "[project]\nname = \"demo\"\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=7.0\",\n \"black>=23.0\",\n]\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "pytest".to_owned(), + section: DependencySection::OptionalDependencies, + from: ">=7.0".to_owned(), + to: ">=8.0".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + // Newlines + 4-space indentation of every element preserved exactly. + let expected = "[project]\nname = \"demo\"\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=8.0\",\n \"black>=23.0\",\n]\n"; + assert_eq!(result, expected); + } + + #[test] + fn apply_updates_patches_pep735_dependency_groups() { + // PEP 735 [dependency-groups] — the modern standard for dev deps in + // PEP 621 projects. F1 in 0027-analyze.md. + let toml = + "[dependency-groups]\ntest = [\n \"pytest>=7.0\",\n \"coverage>=7.0\",\n]\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "coverage".to_owned(), + section: DependencySection::DevDependencies, + from: ">=7.0".to_owned(), + to: ">=7.5".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + let expected = + "[dependency-groups]\ntest = [\n \"pytest>=7.0\",\n \"coverage>=7.5\",\n]\n"; + assert_eq!(result, expected); + } + + #[test] + fn apply_updates_patches_poetry_dev_dependencies() { + // [tool.poetry.dev-dependencies] string-form dep. F1 in 0027-analyze.md. + let toml = "[tool.poetry.dev-dependencies]\npytest = \"^7.0\"\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "pytest".to_owned(), + section: DependencySection::DevDependencies, + from: "^7.0".to_owned(), + to: "^8.0".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + let expected = "[tool.poetry.dev-dependencies]\npytest = \"^8.0\"\n"; + assert_eq!(result, expected); + } + + #[test] + fn apply_updates_unknown_dep_remains_a_silent_noop() { + // Guard the new branches: an update for a name that exists in NO + // section must remain a pure silent no-op (no panic, no mutation, + // byte-equal output). + let toml = "[project]\nname = \"demo\"\ndependencies = [\n \"requests>=2.28.0\",\n]\n\n[project.optional-dependencies]\ndev = [\"pytest>=7.0\"]\n\n[dependency-groups]\ntest = [\"coverage>=7.0\"]\n\n[tool.poetry.dev-dependencies]\nblack = \"^23.0\"\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "totally-not-here".to_owned(), + section: DependencySection::ProjectDependencies, + from: ">=1.0".to_owned(), + to: ">=2.0".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + assert_eq!(result, toml); + } + + // ---------- 0036: Poetry inline-table / full-table dep updates ---------- + // + // Before this iteration, `apply_single_update` only patched the + // `Item::Value(String)` shape of Poetry deps even though + // `extract_poetry_version` (and therefore `compute_updates`) also + // recognises inline-table and full-table forms. The three tests below + // lock in the fix for those two previously-dropped shapes across both + // `[tool.poetry.dependencies]` and `[tool.poetry.dev-dependencies]`, + // and prove that sibling keys (`extras`, `optional`) are preserved + // byte-for-byte. See 0036-analyze.md F1. + + #[test] + fn apply_updates_patches_poetry_inline_table_in_dependencies() { + // `flask = {version = "^2.0", extras = ["async"], optional = true}` — + // only the `version` value bumps; every sibling key survives intact. + let toml = "[tool.poetry.dependencies]\npython = \"^3.8\"\nflask = {version = \"^2.0\", extras = [\"async\"], optional = true}\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "flask".to_owned(), + section: DependencySection::Dependencies, + from: "^2.0".to_owned(), + to: "^3.0".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + let expected = "[tool.poetry.dependencies]\npython = \"^3.8\"\nflask = {version = \"^3.0\", extras = [\"async\"], optional = true}\n"; + assert_eq!(result, expected); + } + + #[test] + fn apply_updates_patches_poetry_inline_table_in_dev_dependencies() { + // Same inline-table shape but under `[tool.poetry.dev-dependencies]`, + // proving the dev-deps Poetry branch now also handles inline tables. + let toml = + "[tool.poetry.dev-dependencies]\npytest = {version = \"^7.0\", extras = [\"toml\"]}\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "pytest".to_owned(), + section: DependencySection::DevDependencies, + from: "^7.0".to_owned(), + to: "^8.0".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + let expected = + "[tool.poetry.dev-dependencies]\npytest = {version = \"^8.0\", extras = [\"toml\"]}\n"; + assert_eq!(result, expected); + } + + #[test] + fn apply_updates_patches_poetry_full_table_form() { + // `[tool.poetry.dependencies.sqlalchemy]` with `version` + `extras` + // sub-keys — the version bumps, `extras = ["asyncio"]` survives, and + // the section header / blank line layout is preserved. + let toml = "[tool.poetry.dependencies]\npython = \"^3.8\"\n\n[tool.poetry.dependencies.sqlalchemy]\nversion = \"^2.0\"\nextras = [\"asyncio\"]\n"; + let mut manifest = PyProjectManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "sqlalchemy".to_owned(), + section: DependencySection::Dependencies, + from: "^2.0".to_owned(), + to: "^3.0".to_owned(), + }]; + let result = manifest.apply_updates(&updates); + let expected = "[tool.poetry.dependencies]\npython = \"^3.8\"\n\n[tool.poetry.dependencies.sqlalchemy]\nversion = \"^3.0\"\nextras = [\"asyncio\"]\n"; + assert_eq!(result, expected); + } + + // ---------- apply_to_poetry_table direct-call scenarios ---------- + // + // `apply_to_poetry_table` is a private free function, so these call it + // directly against a hand-built `toml_edit::Table` rather than going + // through the whole `PyProjectManifest::apply_updates` pipeline. That + // keeps each fixture down to a single line and the branch under test + // obvious. + + #[rstest] + // The requested name is absent from the table entirely — the common + // case where `apply_to_poetry_table` is probed for a dep that isn't a + // Poetry dependency at all. Must report "not found", not panic on the + // missing key. + #[case::name_absent("absent = \"1.0\"\n", "missing", "2.0")] + // Inline-table shape (`gitdep = { git = "..." }`, a Poetry VCS + // dependency) with no `version` key. Must fall through to "not found" + // instead of panicking when `t.get_mut("version")` comes back empty. + #[case::inline_table_missing_version( + "gitdep = { git = \"https://example.com/repo.git\" }\n", + "gitdep", + "2.0" + )] + // Full `[tool.poetry.dependencies.dep]` sub-table with no `version` + // key — same "not found" contract as the inline-table case, exercised + // through the other value shape `extract_poetry_version` recognises. + #[case::full_table_missing_version("[dep]\nextras = [\"x\"]\n", "dep", "2.0")] + // A Poetry dep value that is neither a string, an inline table, nor a + // full table (a bare integer here). The catch-all `_ => false` arm + // must handle an unexpected TOML shape without panicking. + #[case::unsupported_shape("dep = 1\n", "dep", "2.0")] + fn apply_to_poetry_table_not_found_cases( + #[case] fragment: &str, + #[case] name: &str, + #[case] new_version: &str, + ) { + let mut doc: DocumentMut = fragment.parse().expect("fragment should parse"); + let table = doc.as_table_mut(); + assert!( + !apply_to_poetry_table(table, name, new_version), + "expected no version field to patch for `{name}` in `{fragment}`" + ); + } + + #[test] + // Inline-table Poetry dep whose `version` value is not a string (e.g. + // `version = 1`, a malformed-but-parseable pin). Must still overwrite + // it with a proper string via the `Value::String(Formatted::new(...))` + // fallback instead of silently no-op'ing or panicking on the type + // mismatch, and must leave sibling keys untouched. + fn apply_to_poetry_table_inline_table_non_string_version_replaced() { + let mut doc: DocumentMut = "dep = { version = 1, extras = [\"x\"] }\n" + .parse() + .expect("fragment should parse"); + let table = doc.as_table_mut(); + assert!(apply_to_poetry_table(table, "dep", "2.0")); + let result = doc.to_string(); + assert!( + result.contains("version = \"2.0\""), + "non-string inline-table version should be overwritten with a string:\n{result}" + ); + assert!( + result.contains("extras = [\"x\"]"), + "sibling key should survive untouched:\n{result}" + ); + } + + #[test] + // Full-table Poetry dep (`[tool.poetry.dependencies.dep]`) whose + // `version` value is not a string (e.g. `version = 1`). Must overwrite + // via `toml_edit::value(...)` instead of leaving the wrong-typed value + // in place, and must leave sibling keys untouched. + fn apply_to_poetry_table_full_table_non_string_version_replaced() { + let mut doc: DocumentMut = "[dep]\nversion = 1\nextras = [\"x\"]\n" + .parse() + .expect("fragment should parse"); + let table = doc.as_table_mut(); + assert!(apply_to_poetry_table(table, "dep", "2.0")); + let result = doc.to_string(); + assert!( + result.contains("version = \"2.0\""), + "non-string full-table version should be overwritten with a string:\n{result}" + ); + assert!( + result.contains("extras = [\"x\"]"), + "sibling key should survive untouched:\n{result}" + ); + } } diff --git a/crates/python/src/registry.rs b/crates/python/src/registry.rs index c5e12ad..839fe1a 100644 --- a/crates/python/src/registry.rs +++ b/crates/python/src/registry.rs @@ -6,11 +6,11 @@ use std::sync::Arc; use reqwest::Client; use serde::Deserialize; use tokio::sync::Semaphore; -use tracing::debug; +use tracing::{debug, trace}; use dependency_check_updates_core::{ DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, ResolvedVersion, TargetLevel, - build_client, collect_task_results, select_version, strip_range_prefix, + build_client, current_req_is_prerelease, parse_and_select, send_checked, }; /// `PyPI` registry client. @@ -45,6 +45,17 @@ struct PyPiFile { yanked: bool, } +/// Predicate for a release with at least one non-yanked file. +/// +/// Centralises the filter used by both the `Newest` arm and the +/// version-sorted arm of [`PyPiRegistry::resolve_version`]'s slow path so +/// the two stay in sync without re-introducing the wasted +/// `(Version, &str)` tuple `Vec` the old code paid for on every non-Newest +/// lookup. +fn is_usable_release(files: &[PyPiFile]) -> bool { + files.iter().any(|f| !f.yanked) +} + impl PyPiRegistry { /// Create a new `PyPI` registry client. #[must_use] @@ -82,23 +93,8 @@ impl PyPiRegistry { let url = format!("{}/{normalized}/json", self.base_url); debug!(package = name, %url, "fetching PyPI package info"); - let response = - self.client - .get(&url) - .send() - .await - .map_err(|e| DcuError::RegistryLookup { - package: name.to_owned(), - detail: e.to_string(), - })?; - - if !response.status().is_success() { - let status = response.status(); - return Err(DcuError::RegistryLookup { - package: name.to_owned(), - detail: format!("HTTP {status}"), - }); - } + let request = self.client.get(&url); + let response = send_checked(request, name).await?; response.json().await.map_err(|e| DcuError::RegistryLookup { package: name.to_owned(), @@ -127,47 +123,80 @@ impl PyPiRegistry { // PyPI's `info.version` is the canonical latest stable; it doubles as // the fallback for `Latest`/empty-list and unparseable `Minor`/`Patch`. - let latest = Some(info.info.version.clone()); - - // (parsed PEP 440 version, max upload timestamp) for every release that - // has at least one non-yanked file and parses cleanly. - let mut candidates: Vec<(pep440_rs::Version, String)> = info - .releases - .iter() - .filter_map(|(ver_str, files)| { - if files.is_empty() || files.iter().all(|f| f.yanked) { - return None; - } - let parsed = pep440_rs::Version::from_str(ver_str).ok()?; - let upload = files - .iter() - .map(|f| f.upload_time_iso_8601.clone()) - .max() - .unwrap_or_default(); - Some((parsed, upload)) - }) - .collect(); - candidates.sort_by(|a, b| a.0.cmp(&b.0)); - - let versions: Vec = candidates.iter().map(|(v, _)| v.clone()).collect(); - - let selected = if target == TargetLevel::Newest { + let latest = Some(info.info.version); + + // Detect if the user's current requirement is a prerelease. When it + // is, we cannot use the `info.version` fast path because the user may + // be ahead of the canonical stable version (e.g. `2.0.0rc1` while + // `info.version` points at `1.1.20`), and we must consider the full + // release list to preserve the prerelease-tail policy that + // `parse_and_select` encodes. An unparseable requirement (e.g. `"*"`) + // is treated as stable, matching the slow path's `current = None` + // branch which also routes through `latest_for_stable = info.version`. + let current_is_prerelease = + current_req_is_prerelease::(&dep.current_req); + + // Fast path: Latest + current is stable → return PyPI's canonical + // `info.version` directly. The slow path's `parse_and_select` arm for + // (`Latest`, stable current) is documented to fall back to + // `latest_for_stable` (= `info.version`), so this is byte-equivalent — + // it just avoids enumerating + parsing + sorting every release. + // Mirrors the `dist-tags.latest` fast path in + // `crates/node/src/registry.rs::resolve_version`. + let selected = if target == TargetLevel::Latest && !current_is_prerelease { + trace!( + package = %dep.name, + latest = ?latest, + "fast path: using PyPI info.version directly" + ); + latest.clone() + } else if target == TargetLevel::Newest { // Most recently uploaded by date (ISO-8601 sorts chronologically), - // which can differ from the highest version number. - candidates + // which can differ from the highest version number. Stream the + // releases straight into `max_by` — no intermediate `Vec`, no + // sort, since the version ordering the old slow path computed + // was thrown away in this arm anyway. The upload `&str` is + // borrowed out of `info.releases` and dies with the iterator. + // `max_by` returns `None` only on an empty iterator, which + // already means there is nothing to fall back to. + info.releases .iter() - .max_by(|a, b| a.1.cmp(&b.1)) + .filter_map(|(ver_str, files)| { + if !is_usable_release(files) { + return None; + } + let parsed = pep440_rs::Version::from_str(ver_str).ok()?; + let upload = files + .iter() + .map(|f| f.upload_time_iso_8601.as_str()) + .max() + .unwrap_or(""); + Some((parsed, upload)) + }) + .max_by(|a, b| a.1.cmp(b.1)) .map(|(v, _)| v.to_string()) - .or_else(|| versions.last().map(ToString::to_string)) } else { - let current = pep440_rs::Version::from_str(strip_range_prefix(&dep.current_req)).ok(); - select_version( - current.as_ref(), - &versions, - target, - latest.clone(), - latest.clone(), - ) + // `parse_and_select` still wants an ascending list, so we sort + // — but we sort plain `pep440_rs::Version`s instead of + // `(Version, &str)` tuples whose `&str` half this arm never + // reads, dropping one allocation pass and a wider comparator. + // Shared strip→parse→select sequence centralised in `core`; + // PyPI's `info.version` (canonical latest stable) doubles as + // the fallback for the stable-`Latest` and unparseable- + // `Minor`/`Patch` cases. + let mut versions: Vec = Vec::with_capacity(info.releases.len()); + versions.extend(info.releases.iter().filter_map(|(ver_str, files)| { + if !is_usable_release(files) { + return None; + } + pep440_rs::Version::from_str(ver_str).ok() + })); + // Unstable sort matches the cargo/npm registry convention for + // these final, already-unique version lists; `pdqsort` skips + // `Timsort`'s auxiliary buffer for the same observable ordering. + // See 0007-analyze.md F2. + versions.sort_unstable(); + parse_and_select(&dep.current_req, &versions, target, latest.as_deref()) }; debug!( @@ -182,28 +211,21 @@ impl PyPiRegistry { } /// Resolve versions for a batch of dependencies concurrently. + /// + /// Delegates the `join_all` pipeline to + /// [`dependency_check_updates_core::resolve_batch_concurrent`] so the + /// concurrency model — no `tokio::spawn`, no per-dep `JoinHandle` / + /// `DependencySpec` clones, source-order preserved — lives in exactly one + /// place across every per-dep registry. pub async fn resolve_batch( &self, deps: &[DependencySpec], target: TargetLevel, ) -> Vec<(usize, Result)> { - let mut handles = Vec::with_capacity(deps.len()); - - for (idx, dep) in deps.iter().enumerate() { - let dep = dep.clone(); - let registry = self.clone(); - - let handle = tokio::spawn(async move { - let result = registry.resolve_version(&dep, target).await; - (idx, result) - }); - - handles.push(handle); - } - - let mut results = collect_task_results(handles).await; - results.sort_unstable_by_key(|(idx, _)| *idx); - results + dependency_check_updates_core::resolve_batch_concurrent(deps, |dep| { + self.resolve_version(dep, target) + }) + .await } } @@ -244,6 +266,7 @@ mod tests { name: name.to_owned(), current_req: current_req.to_owned(), section: DependencySection::ProjectDependencies, + path_version: None, } } @@ -487,4 +510,75 @@ mod tests { "all-yanked 1.9.0 must be excluded; Greatest should fall back to 1.5.0" ); } + + /// Covers the `if !is_usable_release(files) { return None; }` filter + /// inside the `TargetLevel::Newest` arm of `resolve_version` (registry.rs + /// line 166) — a distinct code path from `resolve_version_skips_all_yanked_release` + /// above, which only exercises the equivalent guard in the non-Newest + /// slow path (line 190). The `2.0.0` release has every file yanked and + /// carries the *most recent* upload timestamp; if the Newest arm's + /// yanked filter regressed, `max_by` would pick it purely on date and + /// `dcu -t newest` would recommend an unpublished release. + #[rstest] + #[tokio::test] + async fn resolve_version_newest_skips_all_yanked_release(#[future] mock_server: MockServer) { + let server = mock_server.await; + Mock::given(method("GET")) + .and(path("/newesty/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "info": {"version": "1.2.0"}, + "releases": { + "1.0.0": [{"upload_time_iso_8601": "2022-01-01T00:00:00Z", "yanked": false}], + "1.2.0": [{"upload_time_iso_8601": "2023-01-01T00:00:00Z", "yanked": false}], + "2.0.0": [ + {"upload_time_iso_8601": "2024-06-01T00:00:00Z", "yanked": true}, + {"upload_time_iso_8601": "2024-06-02T00:00:00Z", "yanked": true} + ] + } + }))) + .mount(&server) + .await; + + let registry = PyPiRegistry::with_base_url(&server.uri()); + let dep = make_dep("newesty", ">=1.0.0"); + let result = registry + .resolve_version(&dep, TargetLevel::Newest) + .await + .expect("resolve_version should succeed"); + assert_eq!( + result.selected.as_deref(), + Some("1.2.0"), + "all-yanked 2.0.0 must be excluded from Newest despite its later upload date" + ); + } + + /// `Latest` + stable current must short-circuit on `info.version` without + /// consulting `releases`. The mock body deliberately omits the `releases` + /// map; the fast path returns `info.version` regardless. Without the fast + /// path the empty-releases slow path would still fall back to + /// `info.version` via `parse_and_select`'s `latest_for_stable` slot, so + /// the assertion holds in both worlds — but the absence of a populated + /// `releases` block keeps this test pinned to the public behavior the + /// fast path is required to preserve. + #[rstest] + #[tokio::test] + async fn resolve_version_latest_fast_path_uses_info_version(#[future] mock_server: MockServer) { + let server = mock_server.await; + Mock::given(method("GET")) + .and(path("/requests/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "info": {"version": "2.31.0"} + }))) + .mount(&server) + .await; + + let registry = PyPiRegistry::with_base_url(&server.uri()); + let dep = make_dep("requests", ">=2.28.0"); + let result = registry + .resolve_version(&dep, TargetLevel::Latest) + .await + .expect("resolve_version should succeed"); + assert_eq!(result.latest.as_deref(), Some("2.31.0")); + assert_eq!(result.selected.as_deref(), Some("2.31.0")); + } } diff --git a/crates/rust/Cargo.toml b/crates/rust/Cargo.toml index 75b1386..f8fc8de 100644 --- a/crates/rust/Cargo.toml +++ b/crates/rust/Cargo.toml @@ -16,19 +16,20 @@ categories.workspace = true dependency-check-updates-core.workspace = true toml_edit.workspace = true serde.workspace = true -serde_json.workspace = true reqwest.workspace = true tokio = { workspace = true, features = ["sync"] } -semver = "1.0" +semver.workspace = true tracing.workspace = true thiserror.workspace = true [dev-dependencies] rstest.workspace = true +serde_json.workspace = true wiremock.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } rustls.workspace = true tracing-subscriber.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/crates/rust/src/lib.rs b/crates/rust/src/lib.rs index 3da3628..bd5407b 100644 --- a/crates/rust/src/lib.rs +++ b/crates/rust/src/lib.rs @@ -21,9 +21,11 @@ pub struct RustHandler; impl ManifestHandler for RustHandler { fn parse(&self, text: &str, path: &Path) -> Result { - let manifest = CargoTomlManifest::parse(text).map_err(|e| DcuError::ManifestParse { - path: path.to_path_buf(), - detail: e.to_string(), + let manifest = CargoTomlManifest::parse_in_dir(text, path.parent()).map_err(|e| { + DcuError::ManifestParse { + path: path.to_path_buf(), + detail: e.to_string(), + } })?; Ok(ParsedManifest { @@ -31,7 +33,6 @@ impl ManifestHandler for RustHandler { path: path.to_path_buf(), kind: ManifestKind::CargoToml, }, - original_text: manifest.original_text, dependencies: manifest.dependencies, }) } diff --git a/crates/rust/src/parser.rs b/crates/rust/src/parser.rs index 1fbbe11..5d80264 100644 --- a/crates/rust/src/parser.rs +++ b/crates/rust/src/parser.rs @@ -1,5 +1,7 @@ //! Cargo.toml parsing and format-preserving dependency updates via `toml_edit`. +use std::path::Path; + use dependency_check_updates_core::{DependencySection, DependencySpec, PlannedUpdate}; use toml_edit::{DocumentMut, Item, Table, Value}; @@ -13,8 +15,6 @@ const CARGO_SECTIONS: &[(DependencySection, &str)] = &[ /// A parsed Cargo.toml file. #[derive(Debug)] pub struct CargoTomlManifest { - /// The original raw text. - pub original_text: String, /// The `toml_edit` document (format-preserving). pub doc: DocumentMut, /// Collected dependencies. @@ -22,31 +22,44 @@ pub struct CargoTomlManifest { } impl CargoTomlManifest { - /// Parse a Cargo.toml from raw text. + /// Parse a Cargo.toml from raw text, without resolving local path + /// dependencies (used by the patch path, which only needs the document). /// /// # Errors /// /// Returns an error if the text is not valid TOML. pub fn parse(text: &str) -> Result { + Self::parse_in_dir(text, None) + } + + /// Parse a Cargo.toml from raw text, resolving local path dependencies + /// relative to `manifest_dir` when provided. + /// + /// A dependency such as `dep = { path = "../dep", version = "0.2.0" }` + /// has its target version read from `../dep/Cargo.toml` (the crate on + /// disk) instead of crates.io, so the declared `version` can be synced to + /// the local crate. When `manifest_dir` is `None`, path dependencies are + /// skipped entirely (never resolved against the registry). + /// + /// # Errors + /// + /// Returns an error if the text is not valid TOML. + pub fn parse_in_dir(text: &str, manifest_dir: Option<&Path>) -> Result { let doc: DocumentMut = text .parse() .map_err(|e: toml_edit::TomlError| CargoTomlError::ParseFailed(e.to_string()))?; - let dependencies = Self::collect_dependencies(&doc); + let dependencies = Self::collect_dependencies(&doc, manifest_dir); - Ok(Self { - original_text: text.to_owned(), - doc, - dependencies, - }) + Ok(Self { doc, dependencies }) } - fn collect_dependencies(doc: &DocumentMut) -> Vec { + fn collect_dependencies(doc: &DocumentMut, manifest_dir: Option<&Path>) -> Vec { let mut deps = Vec::new(); for &(section, key) in CARGO_SECTIONS { if let Some(table) = doc.get(key).and_then(Item::as_table) { - Self::collect_from_table(table, section, &mut deps); + Self::collect_from_table(table, section, manifest_dir, &mut deps); } } @@ -56,6 +69,7 @@ impl CargoTomlManifest { Self::collect_from_table( ws_deps, DependencySection::WorkspaceDependencies, + manifest_dir, &mut deps, ); } @@ -67,56 +81,39 @@ impl CargoTomlManifest { fn collect_from_table( table: &Table, section: DependencySection, + manifest_dir: Option<&Path>, deps: &mut Vec, ) { for (name, item) in table { - if let Some(version) = Self::extract_version(item) { - // Skip path/git dependencies without a version, and skip - // wildcard-only requirements like `*` which already mean - // "any version" — updating them would be a meaningless no-op. - if !version.is_empty() && version.trim() != "*" { + match classify_dependency(item, manifest_dir) { + Some(DepKind::Registry(version)) => { + // Skip wildcard-only requirements like `*` which already + // mean "any version" — updating them would be a no-op. + if !version.is_empty() && version.trim() != "*" { + deps.push(DependencySpec { + name: name.to_owned(), + current_req: version, + section, + path_version: None, + }); + } + } + Some(DepKind::Path { + current_req, + local_version, + }) => { deps.push(DependencySpec { name: name.to_owned(), - current_req: version, + current_req, section, + path_version: Some(local_version), }); } + None => {} } } } - /// Extract the version string from a dependency item. - /// - /// Handles: - /// - `dep = "1.0"` (string form) - /// - `dep = { version = "1.0", features = [...] }` (table form) - /// - `dep = { workspace = true }` → skipped - /// - `dep = { git = "..." }` → skipped (no version) - fn extract_version(item: &Item) -> Option { - match item { - Item::Value(Value::String(s)) => Some(s.value().to_owned()), - Item::Value(Value::InlineTable(t)) => { - // Skip workspace = true - if t.get("workspace").and_then(Value::as_bool).unwrap_or(false) { - return None; - } - // Skip git/path-only deps - t.get("version").and_then(Value::as_str).map(String::from) - } - Item::Table(t) => { - if t.get("workspace") - .and_then(Item::as_value) - .and_then(Value::as_bool) - .unwrap_or(false) - { - return None; - } - t.get("version").and_then(Item::as_str).map(String::from) - } - _ => None, - } - } - /// Apply planned updates to the document, returning the modified text. /// /// Uses `toml_edit` for format-preserving modifications. @@ -163,18 +160,36 @@ impl CargoTomlManifest { match item { Item::Value(Value::String(s)) => { - let decor = s.decor().clone(); - let mut new_s = toml_edit::Formatted::new(new_version.to_owned()); - *new_s.decor_mut() = decor; - *s = new_s; + dependency_check_updates_core::replace_string_preserving_decor( + s, + new_version.to_owned(), + ); } Item::Value(Value::InlineTable(t)) => { if let Some(v) = t.get_mut("version") { - *v = Value::String(toml_edit::Formatted::new(new_version.to_owned())); + if let Value::String(s) = v { + dependency_check_updates_core::replace_string_preserving_decor( + s, + new_version.to_owned(), + ); + } else { + *v = Value::String(toml_edit::Formatted::new(new_version.to_owned())); + } } } Item::Table(t) => { - t["version"] = toml_edit::value(new_version); + if let Some(v) = t.get_mut("version") { + if let Item::Value(Value::String(s)) = v { + dependency_check_updates_core::replace_string_preserving_decor( + s, + new_version.to_owned(), + ); + } else { + *v = toml_edit::value(new_version); + } + } else { + t["version"] = toml_edit::value(new_version); + } } _ => {} } @@ -183,6 +198,165 @@ impl CargoTomlManifest { } } +/// How a single dependency entry should be resolved. +enum DepKind { + /// Ordinary dependency resolved against crates.io. Carries the current + /// version requirement string. + Registry(String), + /// Local `path` dependency that also declares a `version`. The `version` + /// field is synced to `local_version` (the version of the crate on disk). + Path { + current_req: String, + local_version: String, + }, +} + +/// The relevant fields of a table-form dependency, normalised across the +/// inline (`{ ... }`) and full-table (`[deps.x]`) representations. +struct DepFields<'a> { + workspace: bool, + git: bool, + path: Option<&'a str>, + version: Option<&'a str>, +} + +impl<'a> DepFields<'a> { + fn from_item(item: &'a Item) -> Option { + match item { + Item::Value(Value::InlineTable(t)) => Some(Self { + workspace: t.get("workspace").and_then(Value::as_bool).unwrap_or(false), + git: t.get("git").is_some(), + path: t.get("path").and_then(Value::as_str), + version: t.get("version").and_then(Value::as_str), + }), + Item::Table(t) => Some(Self { + workspace: t.get("workspace").and_then(Item::as_bool).unwrap_or(false), + git: t.get("git").is_some(), + path: t.get("path").and_then(Item::as_str), + version: t.get("version").and_then(Item::as_str), + }), + _ => None, + } + } +} + +/// Classify a dependency entry into how its version should be resolved. +/// +/// Handles: +/// - `dep = "1.0"` (string form) → registry +/// - `dep = { version = "1.0", features = [...] }` → registry +/// - `dep = { path = "../dep", version = "0.2.0" }` → path (synced to the +/// local crate's version, resolved relative to `manifest_dir`) +/// - `dep = { workspace = true }` → skipped (resolved from `[workspace.dependencies]`) +/// - `dep = { git = "..." }` / `dep = { path = "../dep" }` → skipped (no version) +/// +/// A path dependency is **never** resolved against crates.io: if its local +/// version cannot be determined (no `version` key, no `manifest_dir`, or the +/// crate on disk is unreadable) the entry is skipped entirely. +fn classify_dependency(item: &Item, manifest_dir: Option<&Path>) -> Option { + if let Item::Value(Value::String(s)) = item { + return Some(DepKind::Registry(s.value().to_owned())); + } + + let fields = DepFields::from_item(item)?; + if fields.workspace { + return None; + } + if fields.git { + return None; + } + + if let Some(path) = fields.path { + let version = fields.version?; + let dir = manifest_dir?; + let local_version = resolve_path_dep_version(dir, path)?; + return Some(DepKind::Path { + current_req: version.to_owned(), + local_version, + }); + } + + Some(DepKind::Registry(fields.version?.to_owned())) +} + +/// A `[package].version` value: either a literal string or inherited from the +/// workspace (`version.workspace = true`). +enum PackageVersion { + Literal(String), + Inherited, +} + +/// Resolve the version of a local path dependency from its own `Cargo.toml`. +/// +/// `manifest_dir` is the directory of the manifest that declares the +/// dependency; `dep_path` is the dependency's `path` value (relative or +/// absolute). Returns `None` when the crate cannot be read or has no +/// resolvable `[package].version`. +fn resolve_path_dep_version(manifest_dir: &Path, dep_path: &str) -> Option { + let crate_dir = manifest_dir.join(dep_path); + let cargo_path = crate_dir.join("Cargo.toml"); + let text = std::fs::read_to_string(&cargo_path).ok()?; + let doc: DocumentMut = text.parse().ok()?; + + match package_version(&doc)? { + PackageVersion::Literal(v) => Some(v), + PackageVersion::Inherited => resolve_workspace_version(&crate_dir), + } +} + +/// Read `[package].version` from a parsed `Cargo.toml`. +fn package_version(doc: &DocumentMut) -> Option { + let version = doc + .get("package") + .and_then(Item::as_table)? + .get("version")?; + + if let Some(s) = version.as_str() { + return Some(PackageVersion::Literal(s.to_owned())); + } + if is_workspace_inherited(version) { + return Some(PackageVersion::Inherited); + } + None +} + +/// Whether a `version` item is `{ workspace = true }` / `version.workspace = true`. +fn is_workspace_inherited(item: &Item) -> bool { + let workspace = match item { + Item::Value(Value::InlineTable(t)) => t.get("workspace").and_then(Value::as_bool), + Item::Table(t) => t.get("workspace").and_then(Item::as_bool), + _ => None, + }; + workspace.unwrap_or(false) +} + +/// Walk up from `crate_dir` to find the nearest workspace root and read its +/// `[workspace.package].version` (the value a crate inherits via +/// `version.workspace = true`). +fn resolve_workspace_version(crate_dir: &Path) -> Option { + let mut dir = std::fs::canonicalize(crate_dir).ok()?; + loop { + let cargo_path = dir.join("Cargo.toml"); + if let Ok(text) = std::fs::read_to_string(&cargo_path) { + if let Ok(doc) = text.parse::() { + if let Some(version) = doc + .get("workspace") + .and_then(Item::as_table) + .and_then(|w| w.get("package")) + .and_then(Item::as_table) + .and_then(|p| p.get("version")) + .and_then(Item::as_str) + { + return Some(version.to_owned()); + } + } + } + if !dir.pop() { + return None; + } + } +} + /// Errors from Cargo.toml operations. #[derive(Debug, thiserror::Error)] pub enum CargoTomlError { @@ -281,6 +455,25 @@ tokio = "1.0" [dependencies] my-fork = { git = "https://github.com/user/repo" } tokio = "1.0" +"#, + &[("tokio", "1.0", DependencySection::Dependencies)] + )] + #[case::skip_git_deps_with_registry_fallback( + r#" +[dependencies] +local = { git = "https://github.com/user/repo", version = "1.0" } +tokio = "1.0" +"#, + &[("tokio", "1.0", DependencySection::Dependencies)] + )] + #[case::skip_git_deps_with_registry_fallback_full_table( + r#" +[dependencies.local] +git = "https://github.com/user/repo" +version = "1.0" + +[dependencies] +tokio = "1.0" "#, &[("tokio", "1.0", DependencySection::Dependencies)] )] @@ -500,6 +693,43 @@ serde = "1.0" // Error path: substrings ignored. &[] )] + #[case::inline_table_non_string_version_replaced( + // Inline-table `version = 1` (integer, not a string) hits the `else` + // branch that force-wraps the new version in a fresh `Value::String`; + // without it, updating a non-string version would leave the manifest + // with a numeric (or otherwise malformed) version value. + r" +[dependencies] +dep = { version = 1 } +", + &[("dep", DependencySection::Dependencies, "2.0")], + true, + &["\"2.0\""] + )] + #[case::full_table_non_string_version_replaced( + // Full-table `[dependencies.dep]` with `version = 1` (integer) hits + // the analogous `else` branch on the `Item::Table` side; without it + // the same numeric-version bug would exist for the full-table form. + r" +[dependencies.dep] +version = 1 +", + &[("dep", DependencySection::Dependencies, "2.0")], + true, + &["\"2.0\""] + )] + #[case::full_table_missing_version_key_inserted( + // `[dependencies.dep]` with no `version` key at all exercises the + // branch that inserts a brand-new `version` entry; without it, a + // versionless full-table dependency could never be updated at all. + r" +[dependencies.dep] +features = [] +", + &[("dep", DependencySection::Dependencies, "2.0")], + true, + &["\"2.0\""] + )] fn apply_updates_cases( #[case] toml: &str, #[case] updates: UpdateSpecs<'_>, @@ -521,4 +751,299 @@ serde = "1.0" assert!(result.is_err()); } } + + // ----- Local path dependency resolution ------------------------------- + + use tempfile::TempDir; + + /// Write `content` to `/`, creating parent directories. + fn write_file(dir: &Path, rel: &str, content: &str) { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + /// `{ path = "../hwp", version = "0.2.0" }` resolves `path_version` from the + /// local crate's literal `[package].version` (here a higher 0.3.0), keeping + /// `current_req` as the manifest's declared version. + #[test] + fn path_dep_syncs_to_local_literal_version() { + let tmp = TempDir::new().unwrap(); + write_file( + tmp.path(), + "hwp/Cargo.toml", + "[package]\nname = \"hwp\"\nversion = \"0.3.0\"\n", + ); + let app_dir = tmp.path().join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let manifest = "[dependencies]\nhwp = { path = \"../hwp\", version = \"0.2.0\" }\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(&app_dir)).unwrap(); + + assert_eq!(parsed.dependencies.len(), 1); + let dep = &parsed.dependencies[0]; + assert_eq!(dep.name, "hwp"); + assert_eq!(dep.current_req, "0.2.0"); + assert_eq!(dep.path_version.as_deref(), Some("0.3.0")); + } + + /// A path dep whose local crate is *older* than the declared version still + /// resolves — exact-sync (and its allowed downgrade) is decided later in the + /// pipeline; the parser just reports the on-disk version. + #[test] + fn path_dep_reports_lower_local_version() { + let tmp = TempDir::new().unwrap(); + write_file( + tmp.path(), + "hwp/Cargo.toml", + "[package]\nname = \"hwp\"\nversion = \"0.1.0\"\n", + ); + let app_dir = tmp.path().join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let manifest = "[dependencies]\nhwp = { path = \"../hwp\", version = \"0.2.0\" }\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(&app_dir)).unwrap(); + + assert_eq!( + parsed.dependencies[0].path_version.as_deref(), + Some("0.1.0") + ); + } + + /// `version.workspace = true` in the local crate resolves by walking up to + /// the workspace root's `[workspace.package].version`. + #[test] + fn path_dep_resolves_workspace_inherited_version() { + let tmp = TempDir::new().unwrap(); + write_file( + tmp.path(), + "Cargo.toml", + "[workspace]\nmembers = [\"crates/hwp\", \"crates/app\"]\n\n[workspace.package]\nversion = \"1.2.3\"\n", + ); + write_file( + tmp.path(), + "crates/hwp/Cargo.toml", + "[package]\nname = \"hwp\"\nversion.workspace = true\n", + ); + let app_dir = tmp.path().join("crates/app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let manifest = "[dependencies]\nhwp = { path = \"../hwp\", version = \"1.0.0\" }\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(&app_dir)).unwrap(); + + assert_eq!( + parsed.dependencies[0].path_version.as_deref(), + Some("1.2.3") + ); + } + + /// The full-table form `[dependencies.hwp]` with `path` + `version` is + /// resolved the same way as the inline form. + #[test] + fn path_dep_full_table_form_resolved() { + let tmp = TempDir::new().unwrap(); + write_file( + tmp.path(), + "hwp/Cargo.toml", + "[package]\nname = \"hwp\"\nversion = \"0.5.0\"\n", + ); + let app_dir = tmp.path().join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let manifest = "[dependencies.hwp]\npath = \"../hwp\"\nversion = \"0.2.0\"\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(&app_dir)).unwrap(); + + assert_eq!(parsed.dependencies.len(), 1); + assert_eq!( + parsed.dependencies[0].path_version.as_deref(), + Some("0.5.0") + ); + } + + /// `[workspace.dependencies]` path deps (the publish-ready monorepo pattern) + /// are resolved against the local crate too. + #[test] + fn workspace_dependency_path_resolved() { + let tmp = TempDir::new().unwrap(); + write_file( + tmp.path(), + "crates/core/Cargo.toml", + "[package]\nname = \"core\"\nversion = \"0.1.20\"\n", + ); + + let manifest = + "[workspace.dependencies]\ncore = { path = \"crates/core\", version = \"0.1.15\" }\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(tmp.path())).unwrap(); + + assert_eq!(parsed.dependencies.len(), 1); + assert_eq!( + parsed.dependencies[0].section, + DependencySection::WorkspaceDependencies + ); + assert_eq!( + parsed.dependencies[0].path_version.as_deref(), + Some("0.1.20") + ); + } + + /// A path dep whose local crate cannot be found is skipped entirely — it is + /// never resolved against crates.io. Sibling registry deps are unaffected. + #[test] + fn path_dep_missing_crate_is_skipped() { + let tmp = TempDir::new().unwrap(); + let app_dir = tmp.path().join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let manifest = + "[dependencies]\nhwp = { path = \"../hwp\", version = \"0.2.0\" }\ntokio = \"1.0\"\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(&app_dir)).unwrap(); + + assert_eq!(parsed.dependencies.len(), 1); + assert_eq!(parsed.dependencies[0].name, "tokio"); + } + + /// Without a manifest directory (e.g. the patch path) path deps are skipped + /// rather than resolved against the registry. + #[test] + fn path_dep_without_manifest_dir_is_skipped() { + let manifest = + "[dependencies]\nhwp = { path = \"../hwp\", version = \"0.2.0\" }\ntokio = \"1.0\"\n"; + let parsed = CargoTomlManifest::parse(manifest).unwrap(); + + assert_eq!(parsed.dependencies.len(), 1); + assert_eq!(parsed.dependencies[0].name, "tokio"); + } + + /// A `path` dep with no `version` key has nothing to sync and is skipped, + /// even when the local crate exists. + #[test] + fn path_dep_without_version_key_is_skipped() { + let tmp = TempDir::new().unwrap(); + write_file( + tmp.path(), + "hwp/Cargo.toml", + "[package]\nname = \"hwp\"\nversion = \"0.3.0\"\n", + ); + let app_dir = tmp.path().join("app"); + std::fs::create_dir_all(&app_dir).unwrap(); + + let manifest = "[dependencies]\nhwp = { path = \"../hwp\" }\ntokio = \"1.0\"\n"; + let parsed = CargoTomlManifest::parse_in_dir(manifest, Some(&app_dir)).unwrap(); + + assert_eq!(parsed.dependencies.len(), 1); + assert_eq!(parsed.dependencies[0].name, "tokio"); + } + + /// Applying an update to an inline path dep replaces only `version`, leaving + /// the `path` key intact (format-preserving). + #[test] + fn apply_update_preserves_path_key() { + let toml = "[dependencies]\nhwp = { path = \"../hwp\", version = \"0.2.0\" }\n"; + let mut manifest = CargoTomlManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "hwp".to_owned(), + section: DependencySection::Dependencies, + from: "0.2.0".to_owned(), + to: "0.3.0".to_owned(), + }]; + let out = manifest.apply_updates(&updates).unwrap(); + assert!( + out.contains("path = \"../hwp\""), + "path key dropped:\n{out}" + ); + assert!( + out.contains("version = \"0.3.0\""), + "version not synced:\n{out}" + ); + } + + /// Inline-table form: the `version` value's surrounding decor (the space + /// after `=`) must survive an update, so the inline table stays + /// byte-for-byte identical except for the bumped version. Without decor + /// preservation the value collapses to `version ="1.0.228"`. + #[test] + fn apply_updates_inline_table_preserves_decor_byte_for_byte() { + let toml = "[dependencies]\nserde = { version = \"1.0\", features = [\"derive\"] }\n"; + let mut manifest = CargoTomlManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "serde".to_owned(), + section: DependencySection::Dependencies, + from: "1.0".to_owned(), + to: "1.0.228".to_owned(), + }]; + let out = manifest.apply_updates(&updates).unwrap(); + let expected = + "[dependencies]\nserde = { version = \"1.0.228\", features = [\"derive\"] }\n"; + assert_eq!(out, expected); + } + + /// Full-table form: `[dependencies.serde]\nversion = "1.0"\n…` must update + /// the value while preserving leading/trailing decor on the `version` + /// line, so the file stays byte-identical except for the bumped value. + #[test] + fn apply_updates_full_table_preserves_decor_byte_for_byte() { + let toml = "[dependencies.serde]\nversion = \"1.0\"\nfeatures = [\"derive\"]\n"; + let mut manifest = CargoTomlManifest::parse(toml).unwrap(); + let updates = vec![PlannedUpdate { + name: "serde".to_owned(), + section: DependencySection::Dependencies, + from: "1.0".to_owned(), + to: "1.0.228".to_owned(), + }]; + let out = manifest.apply_updates(&updates).unwrap(); + let expected = "[dependencies.serde]\nversion = \"1.0.228\"\nfeatures = [\"derive\"]\n"; + assert_eq!(out, expected); + } + + // ----- package_version / is_workspace_inherited / resolve_workspace_version ----- + + /// A `[package].version` that is neither a plain string nor + /// `{ workspace = true }` (e.g. an integer) must resolve to `None` — the + /// path-dependency resolver then treats the crate as having no usable + /// version rather than panicking or silently coercing the value. + #[test] + fn package_version_returns_none_for_non_string_non_workspace() { + let doc: DocumentMut = "[package]\nversion = 1\n".parse().unwrap(); + assert!(package_version(&doc).is_none()); + } + + /// `version = { workspace = true }` (inline-table form) must be + /// recognised as workspace-inherited via the inline-table arm; without + /// it, a version declared this way would be misread as a literal (or + /// simply ignored), breaking workspace-inherited path deps that use the + /// inline syntax instead of `version.workspace = true`. + #[test] + fn is_workspace_inherited_true_for_inline_table_form() { + let doc: DocumentMut = "[package]\nversion = { workspace = true }\n" + .parse() + .unwrap(); + let version_item = doc + .get("package") + .unwrap() + .as_table() + .unwrap() + .get("version") + .unwrap(); + assert!(is_workspace_inherited(version_item)); + } + + /// A path dependency with `version.workspace = true` but no workspace + /// root anywhere above it must resolve to `None` once the walk exhausts + /// every ancestor directory up to the filesystem root, instead of + /// looping forever or panicking when `Path::pop` finally fails. + /// + /// A `TempDir` lives under the OS temp directory, which — unlike this + /// repo's own crates — has no ancestor `Cargo.toml` at all, so the walk + /// is guaranteed to bottom out without finding a workspace. + #[test] + fn resolve_workspace_version_none_when_no_workspace_root_found() { + let tmp = TempDir::new().unwrap(); + + assert!( + resolve_workspace_version(tmp.path()).is_none(), + "expected no workspace root above a bare temp directory" + ); + } } diff --git a/crates/rust/src/registry.rs b/crates/rust/src/registry.rs index b312385..8738c05 100644 --- a/crates/rust/src/registry.rs +++ b/crates/rust/src/registry.rs @@ -9,7 +9,7 @@ use tracing::{debug, trace}; use dependency_check_updates_core::{ DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, ResolvedVersion, TargetLevel, - build_client, collect_task_results, strip_range_prefix, + build_client, send_checked, }; /// crates.io registry client. @@ -70,23 +70,8 @@ impl CratesIoRegistry { let url = format!("{}/crates/{name}/versions", self.base_url); debug!(crate_name = name, %url, "fetching crate versions"); - let response = - self.client - .get(&url) - .send() - .await - .map_err(|e| DcuError::RegistryLookup { - package: name.to_owned(), - detail: e.to_string(), - })?; - - if !response.status().is_success() { - let status = response.status(); - return Err(DcuError::RegistryLookup { - package: name.to_owned(), - detail: format!("HTTP {status}"), - }); - } + let request = self.client.get(&url); + let response = send_checked(request, name).await?; let resp: CratesIoResponse = response @@ -110,14 +95,32 @@ impl CratesIoRegistry { dep: &DependencySpec, target: TargetLevel, ) -> Result { + // Local path dependency: the version is dictated by the crate on disk + // (resolved at parse time), not crates.io. Short-circuit before any + // network call so the declared `version` is synced to that local crate. + if let Some(local) = &dep.path_version { + return Ok(ResolvedVersion { + latest: Some(local.clone()), + selected: Some(local.clone()), + }); + } + let crate_versions = self.fetch_versions(&dep.name).await?; - let yanked_count = crate_versions.iter().filter(|v| v.yanked).count(); - let mut versions: Vec = crate_versions - .iter() - .filter(|v| !v.yanked) - .filter_map(|v| semver::Version::parse(&v.num).ok()) - .collect(); + // One pass instead of two: the previous form walked + // `crate_versions` once to count yanked entries (purely for the + // `trace!` diagnostic below) and once more to parse the non-yanked + // ones. Pre-size `versions` to skip the grow-loop reallocations on + // long version lists. See 0007-analyze.md F3. + let mut yanked_count = 0usize; + let mut versions: Vec = Vec::with_capacity(crate_versions.len()); + for v in &crate_versions { + if v.yanked { + yanked_count += 1; + } else if let Ok(parsed) = semver::Version::parse(&v.num) { + versions.push(parsed); + } + } versions.sort_unstable(); trace!( @@ -128,11 +131,7 @@ impl CratesIoRegistry { "fetched version list" ); - let latest = versions - .iter() - .rev() - .find(|v| v.pre.is_empty()) - .map(std::string::ToString::to_string); + let latest = dependency_check_updates_core::highest_stable(&versions); let selected = if target == TargetLevel::Newest { // "Newest" = most recently published by date, which can differ from @@ -141,7 +140,15 @@ impl CratesIoRegistry { // version, so resolve it from dates rather than semver ordering. newest_by_date(&crate_versions).or_else(|| versions.last().map(ToString::to_string)) } else { - select_version(&dep.current_req, latest.as_ref(), &versions, target) + // Shared strip→parse→select sequence centralised in `core`; + // crates.io's `latest` (highest stable) doubles as the fallback for + // the stable-`Latest` and unparseable-`Minor`/`Patch` cases. + dependency_check_updates_core::parse_and_select( + &dep.current_req, + &versions, + target, + latest.as_deref(), + ) }; // NOTE: we do NOT filter out versions that satisfy the current requirement. @@ -160,28 +167,21 @@ impl CratesIoRegistry { } /// Resolve versions for a batch of dependencies concurrently. + /// + /// Delegates the `join_all` pipeline to + /// [`dependency_check_updates_core::resolve_batch_concurrent`] so the + /// concurrency model — no `tokio::spawn`, no per-dep `JoinHandle` / + /// `DependencySpec` clones, source-order preserved — lives in exactly one + /// place across every per-dep registry. pub async fn resolve_batch( &self, deps: &[DependencySpec], target: TargetLevel, ) -> Vec<(usize, Result)> { - let mut handles = Vec::with_capacity(deps.len()); - - for (idx, dep) in deps.iter().enumerate() { - let dep = dep.clone(); - let registry = self.clone(); - - let handle = tokio::spawn(async move { - let result = registry.resolve_version(&dep, target).await; - (idx, result) - }); - - handles.push(handle); - } - - let mut results = collect_task_results(handles).await; - results.sort_unstable_by_key(|(idx, _)| *idx); - results + dependency_check_updates_core::resolve_batch_concurrent(deps, |dep| { + self.resolve_version(dep, target) + }) + .await } } @@ -210,32 +210,6 @@ fn newest_by_date(crate_versions: &[CrateVersion]) -> Option { .map(|(_, parsed)| parsed.to_string()) } -/// Select the appropriate version based on target level. -/// -/// Thin wrapper over [`dependency_check_updates_core::select_version`]. The -/// crates.io `latest` is already the highest stable version, which doubles as -/// the fallback for both the stable-`Latest` and unparseable-`Minor`/`Patch` -/// cases. -fn select_version( - current_req_str: &str, - latest: Option<&String>, - all_versions: &[semver::Version], - target: TargetLevel, -) -> Option { - let current = parse_base_version(current_req_str); - dependency_check_updates_core::select_version( - current.as_ref(), - all_versions, - target, - latest.cloned(), - latest.cloned(), - ) -} - -fn parse_base_version(req_str: &str) -> Option { - semver::Version::parse(strip_range_prefix(req_str)).ok() -} - #[cfg(test)] mod tests { use super::*; @@ -269,6 +243,7 @@ mod tests { name: "serde".to_owned(), current_req: current_req.to_owned(), section: DependencySection::Dependencies, + path_version: None, } } @@ -284,15 +259,7 @@ mod tests { .await; } - #[rstest] - #[case::caret("^1.2.3", (1, 2, 3))] - #[case::tilde("~1.2.3", (1, 2, 3))] - fn parse_base_version_cases(#[case] req: &str, #[case] expected: (u64, u64, u64)) { - let v = parse_base_version(req).unwrap(); - assert_eq!((v.major, v.minor, v.patch), expected); - } - - /// Pure-function `select_version` cases. `expected_eq` and `expected_ne` + /// Pure-function `parse_and_select` cases. `expected_eq` and `expected_ne` /// are independent: when `Some`, the assertion runs; when `None`, it is /// skipped. This faithfully preserves the original mix of `assert_eq!` / /// `assert_ne!` / both per test, with no added or dropped assertions. @@ -326,9 +293,15 @@ mod tests { #[case] expected_eq: Option<&str>, #[case] expected_ne: Option<&str>, ) { - let latest_owned = latest.to_owned(); let versions = make_versions(versions); - let result = select_version(req, Some(&latest_owned), &versions, target); + // Drives the same algorithm the registry now calls directly: the + // ecosystem-agnostic helper in `core` that fuses strip→parse→select. + let result = dependency_check_updates_core::parse_and_select::( + req, + &versions, + target, + Some(latest), + ); if let Some(eq) = expected_eq { assert_eq!(result.as_deref(), Some(eq), "expected_eq mismatch"); } @@ -510,6 +483,7 @@ mod tests { name: "nonexistent".to_owned(), current_req: "^1.0.0".to_owned(), section: DependencySection::Dependencies, + path_version: None, }; let result = registry.resolve_version(&dep, TargetLevel::Latest).await; assert!(result.is_err()); @@ -547,11 +521,13 @@ mod tests { name: "serde".to_owned(), current_req: "^1.0.0".to_owned(), section: DependencySection::Dependencies, + path_version: None, }, DependencySpec { name: "tokio".to_owned(), current_req: "^1.0.0".to_owned(), section: DependencySection::Dependencies, + path_version: None, }, ]; let results = registry.resolve_batch(&deps, TargetLevel::Latest).await; @@ -628,4 +604,26 @@ mod tests { .unwrap(); assert_eq!(result.latest, Some("2.0.0".to_owned())); } + + /// A dependency carrying `path_version` is resolved from that local version + /// without any registry call. The base URL points at an unbindable port, so + /// a successful result proves the network was never touched. + #[rstest] + #[tokio::test] + async fn resolve_version_path_dep_short_circuits_without_network() { + install_tls_provider(); + let registry = CratesIoRegistry::with_base_url("http://127.0.0.1:1"); + let dep = DependencySpec { + name: "hwp".to_owned(), + current_req: "0.2.0".to_owned(), + section: DependencySection::Dependencies, + path_version: Some("0.3.0".to_owned()), + }; + let result = registry + .resolve_version(&dep, TargetLevel::Latest) + .await + .unwrap(); + assert_eq!(result.latest.as_deref(), Some("0.3.0")); + assert_eq!(result.selected.as_deref(), Some("0.3.0")); + } }