diff --git a/.changepacks/changepack_log_android_gradle_toolchains.json b/.changepacks/changepack_log_android_gradle_toolchains.json new file mode 100644 index 0000000..1ffb1a4 --- /dev/null +++ b/.changepacks/changepack_log_android_gradle_toolchains.json @@ -0,0 +1,15 @@ +{ + "changes": { + "bridge/node/package.json": "Minor", + "bridge/python/pyproject.toml": "Minor", + "crates/cli/Cargo.toml": "Minor", + "crates/core/Cargo.toml": "Minor", + "crates/node/Cargo.toml": "Minor", + "crates/rust/Cargo.toml": "Minor", + "crates/python/Cargo.toml": "Minor", + "crates/github/Cargo.toml": "Minor", + "crates/docker/Cargo.toml": "Minor" + }, + "note": "Add static Android/Gradle and project toolchain updates, explicit local tool queries, compatibility-aware selection, integrity checks, recoverable transactions, structured diagnostics, and deterministic cross-platform packaging regressions. Minimum supported Rust is now 1.88; legacy JSON output remains available with --json-legacy.", + "date": "2026-10-01T01:43:51Z" +} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1abc37b..39cf2f1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,6 +19,9 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + project-regressions: + uses: ./.github/workflows/project-regressions.yml + test: name: Test runs-on: ${{ matrix.platform }} @@ -111,6 +114,7 @@ jobs: pull-requests: write contents: write needs: + - project-regressions - test - coverage-test steps: @@ -125,6 +129,7 @@ jobs: outputs: changepacks: ${{ steps.changepacks.outputs.changepacks }} release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }} + pending_releases: ${{ steps.changepacks.outputs.pending_releases }} # node node-build: @@ -363,6 +368,31 @@ jobs: upload_url: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['bridge/python/pyproject.toml'] }} asset_path: "*/*.whl" + finalize-releases: + name: Finalize published releases + runs-on: ubuntu-latest + needs: [changepacks, node-publish, python-publish, upload-assets] + permissions: + contents: write + # Current changepacks creates durable draft receipts for downstream npm/PyPI + # publishers. Publish those releases only after their packages and assets + # are available; failed publication must leave the drafts recoverable. + if: >- + ${{ always() && github.ref == 'refs/heads/main' + && needs.changepacks.result == 'success' + && needs.changepacks.outputs.pending_releases != '' + && needs.changepacks.outputs.pending_releases != '{}' + && (needs.node-publish.result == 'success' + || !contains(needs.changepacks.outputs.pending_releases, 'bridge/node/package.json')) + && (needs.python-publish.result == 'success' + || !contains(needs.changepacks.outputs.pending_releases, 'bridge/python/pyproject.toml')) + && (needs.upload-assets.result == 'success' + || !contains(needs.changepacks.outputs.changepacks, 'crates/cli/Cargo.toml')) }} + steps: + - uses: changepacks/action@main + with: + finalize_releases: ${{ needs.changepacks.outputs.pending_releases }} + upload-assets: needs: changepacks if: ${{ contains(needs.changepacks.outputs.changepacks, 'crates/cli/Cargo.toml') }} diff --git a/.github/workflows/project-regressions.yml b/.github/workflows/project-regressions.yml new file mode 100644 index 0000000..14e704a --- /dev/null +++ b/.github/workflows/project-regressions.yml @@ -0,0 +1,66 @@ +name: Project regression tests + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + minimum-rust: + name: Minimum Rust 1.88 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.88.0" + - run: cargo check --locked --workspace --all-targets --all-features + - run: cargo test --locked --workspace + + project-regressions: + name: Deterministic regressions (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Format + run: cargo fmt --all -- --check + - name: Lint + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings + - name: Fixed-response tests and native filesystem regressions + run: cargo test --locked --workspace + - name: Both CLI aliases + run: | + cargo run --locked -p dependency-check-updates --bin dcu -- --help + cargo run --locked -p dependency-check-updates --bin dependency-check-updates -- --help + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Fixed-response native bridge and packed npm installation + run: | + bun install --frozen-lockfile --ignore-scripts + bun run --cwd bridge/node test + - uses: astral-sh/setup-uv@v10.0.1 + with: + enable-cache: false + - name: Build and install Python wheel, exercise both aliases + run: | + uvx --from "maturin>=1.8,<2" maturin build --locked --manifest-path bridge/python/Cargo.toml --out target/wheel-smoke + uv run --no-project python bridge/python/test_wheel.py target/wheel-smoke + + crate-packaging: + name: Verify distributable Rust crates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - run: cargo package --locked --workspace --exclude dependency-check-updates-napi --exclude dependency-check-updates-python-bridge diff --git a/Cargo.lock b/Cargo.lock index 93ef856..9ecd5f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.13.1" @@ -329,6 +338,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.8.2" @@ -395,6 +413,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "1.0.13" @@ -421,8 +449,9 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "dependency-check-updates" -version = "0.1.15" +version = "0.2.0" dependencies = [ + "base64", "clap", "dependency-check-updates-core", "dependency-check-updates-docker", @@ -430,22 +459,33 @@ dependencies = [ "dependency-check-updates-node", "dependency-check-updates-python", "dependency-check-updates-rust", + "fs2", "futures", "indicatif", "owo-colors", + "quick-xml", + "regex", + "reqwest", "rstest", "rustls", "semver", + "serde", "serde_json", + "sha1", + "sha2", "tempfile", "tokio", + "toml_edit", "tracing", "tracing-subscriber", + "windows-sys 0.61.2", + "wiremock", + "xattr", ] [[package]] name = "dependency-check-updates-core" -version = "0.1.15" +version = "0.2.0" dependencies = [ "criterion", "futures", @@ -455,16 +495,19 @@ dependencies = [ "pep440_rs", "reqwest", "rstest", + "rustls", "semver", "tempfile", "thiserror 2.0.20", + "tokio", "toml_edit", "tracing", + "wiremock", ] [[package]] name = "dependency-check-updates-docker" -version = "0.1.15" +version = "0.2.0" dependencies = [ "dependency-check-updates-core", "futures", @@ -481,7 +524,7 @@ dependencies = [ [[package]] name = "dependency-check-updates-github" -version = "0.1.15" +version = "0.2.0" dependencies = [ "dependency-check-updates-core", "dependency-check-updates-docker", @@ -509,7 +552,7 @@ dependencies = [ [[package]] name = "dependency-check-updates-node" -version = "0.1.15" +version = "0.2.0" dependencies = [ "criterion", "dependency-check-updates-core", @@ -528,7 +571,7 @@ dependencies = [ [[package]] name = "dependency-check-updates-python" -version = "0.1.15" +version = "0.2.0" dependencies = [ "dependency-check-updates-core", "pep440_rs", @@ -555,7 +598,7 @@ dependencies = [ [[package]] name = "dependency-check-updates-rust" -version = "0.1.15" +version = "0.2.0" dependencies = [ "dependency-check-updates-core", "reqwest", @@ -573,6 +616,16 @@ dependencies = [ "wiremock", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -648,6 +701,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.34" @@ -742,6 +805,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1545,6 +1618,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -1902,6 +1984,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2358,6 +2462,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2436,6 +2546,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2731,6 +2847,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 59592d9..2c69eb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ default-members = ["crates/cli", "crates/core", "crates/node", "crates/rust", "c [workspace.package] edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "MIT" repository = "https://github.com/dev-five-git/dependency-check-updates" homepage = "https://github.com/dev-five-git/dependency-check-updates" diff --git a/README.md b/README.md index 5382f51..a73ad58 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![crates.io](https://img.shields.io/crates/v/dependency-check-updates?logo=rust&label=crates.io&style=flat-square)](https://crates.io/crates/dependency-check-updates) [![npm](https://img.shields.io/npm/v/@dependency-check-updates/cli?logo=npm&label=npm&style=flat-square)](https://www.npmjs.com/package/@dependency-check-updates/cli) [![PyPI](https://img.shields.io/pypi/v/dependency-check-updates?logo=pypi&logoColor=white&label=PyPI&style=flat-square)](https://pypi.org/project/dependency-check-updates/) -[![Rust 1.85+](https://img.shields.io/badge/rust-1.85%2B-dea584?logo=rust&style=flat-square)](https://www.rust-lang.org/) +[![Rust 1.88+](https://img.shields.io/badge/rust-1.88%2B-dea584?logo=rust&style=flat-square)](https://www.rust-lang.org/) [![Python 3.11+](https://img.shields.io/pypi/pyversions/dependency-check-updates?logo=python&logoColor=white&style=flat-square)](https://pypi.org/project/dependency-check-updates/) [![Node](https://img.shields.io/node/v/@dependency-check-updates/cli?logo=node.js&logoColor=white&label=node&style=flat-square)](https://www.npmjs.com/package/@dependency-check-updates/cli) @@ -66,7 +66,7 @@ All four accept the same flags described in [Usage](#usage). ## Features -- **Multi-ecosystem** — `package.json`, `Cargo.toml`, `pyproject.toml`, `.github/workflows/*.yml`, `Dockerfile`, and `compose.yaml` all handled by a single binary +- **Multi-ecosystem** — Node, Rust, Python, GitHub Actions, containers, Android/Gradle and project tool declarations 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`) @@ -82,9 +82,184 @@ All four accept the same flags described in [Usage](#usage). | Node.js | `package.json` | [npm](https://www.npmjs.com/) | [`@dependency-check-updates/cli`](https://www.npmjs.com/package/@dependency-check-updates/cli) | | 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/) | +| Android / Gradle | `build.gradle[.kts]`, `settings.gradle[.kts]`, `gradle/libs.versions.toml`, `gradle.properties` | Declared Google Maven, Maven Central, Gradle Plugin Portal | *(built-in)* | +| Gradle distribution | `gradle/wrapper/gradle-wrapper.properties` | Official Gradle release metadata and SHA-256 sidecars | *(built-in)* | +| Project development tools | `package.json` `packageManager`, `.nvmrc`, `.node-version`, `rust-toolchain[.toml]`, `.tool-versions`, `mise.toml`, `.mise.toml` | Official Node/Rust/Bun/Yarn/Temurin release metadata and npm | *(built-in)* | | 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)* | +### Android and Gradle + +The same default lookup, `-d`, `-u`, `--manifest`, targets, positional filters, `--reject`, table and JSON output apply to these files. For a Tauri monorepo, `bunx @dependency-check-updates/cli -d` also finds scripts under `apps/app/src-tauri/gen/android/`, including `buildSrc/build.gradle.kts`. Repeated declarations are all patched; version references patch their original source, keeping the references intact. + +Supported static syntax: + +| Declaration | Supported forms | +|---|---| +| Maven coordinates | Kotlin `implementation("group:artifact:1.2.3")`, Groovy `implementation 'group:artifact:1.2.3'`; `classpath`, `api`, `*Implementation`, `compileOnly`, `runtimeOnly`, `annotationProcessor`, `kapt`, `ksp` | +| Plugins | `id("example.plugin") version "1.2.3"`, `id 'example.plugin' version '1.2.3'`, and a simple version variable | +| Script version variables | Separate-line `val version = "1.2.3"`, `var` / `def`, `ext.version = '1.2.3'`, `extra["version"] = "1.2.3"`, `val version: String = "1.2.3"`, `val version: String by extra("1.2.3")`, and `$version` / `${version}` references | +| Properties | `version = 1.2.3` or `version: 1.2.3` in the nearest ancestor `gradle.properties`; separate-line `val v = providers.gradleProperty("key").get()`, `val v = property("key")`, or `def v = findProperty('key')`, optionally `as String` or `.toInt()` | +| Version catalog | `[versions]` string values; `[libraries]` `module`, `group` + `name`, or string coordinates; `[plugins]` `id`; inline `version` and `version.ref` | +| Android SDK | Integer `compileSdk`, `compileSdkVersion`, `targetSdk`, `targetSdkVersion`, or a simple integer variable declaration | +| Wrapper | Official `services.gradle.org` / `downloads.gradle.org` distribution URLs ending in `-bin.zip` or `-all.zip`; optional `distributionSha256Sum` | + +Gradle scripts are never executed. Comments and unrelated strings, including triple-quoted example text, are excluded. The listed literal property-provider bindings are resolved by reading project files, not by calling Gradle. Arbitrary computed expressions, concatenation, function calls, unresolved or reassigned variables, provider `.map`/defaults/environment lookups, Maven version ranges/dynamic selectors, classifiers, rich catalog constraints and custom distribution URLs are reported with a reason and preserved. This is a bounded static scanner, not a Groovy/Kotlin interpreter. Simple `implementation(libs.android.webkit)` and `alias(libs.plugins.kotlin.android)` accessors resolve to the nearest ancestor `gradle/libs.versions.toml`; alias hyphens/underscores become dots, and updates patch the catalog source. Custom catalog names, imported convention scripts and arbitrary dependency configurations are outside the supported syntax. Only the documented `String`/`Int` annotations and literal `by extra(...)` initializers are resolved; arbitrary delegates are not. + +Repository declarations supported are `google()`, `mavenCentral()`, `gradlePluginPortal()`, and literal `maven("URL")` / `maven { url = uri("URL") }` / Groovy `url 'URL'` forms. Local and ancestor build/settings files supply repository context. By default, only the public Google Maven, Maven Central and Plugin Portal endpoints are queried; use explicit [private Maven access](#private-maven-access) for other literal endpoints. A missing, inaccessible or unsupported declared repository is visible. HTTP 404 in one repository is normal when another declares the artifact; other lookup failures prevent applying a partial result. Android/Kotlin plugin IDs map to their official artifacts; other IDs use Plugin Portal marker coordinates. Gradle credential code, content filters and dynamically calculated URLs are not evaluated. + +`compileSdk` and `targetSdk` are shown in the `android-sdk` section and use stable platform packages from Google's SDK repository metadata. `minSdk` is never raised. Updating an API level changes project declarations; installing that SDK remains your build tooling's responsibility. + +The wrapper retains the host, escaped colon, `bin`/`all` type and surrounding properties. When a SHA-256 property exists, the matching official distribution checksum must be obtained before applying the update. Failed checksum requests preserve the URL and checksum together and trigger another compatibility check before any project file is written. + +### Private Maven access + +`--maven-config repositories.json` authorizes exact literal Maven base URLs already declared by the selected build. It does not add repositories, read Gradle credentials, execute scripts or scan global settings. The configuration uses schema 1, rejects unknown fields and duplicate URLs, and is limited to 1 MiB / 128 endpoints: + +```json +{ + "schemaVersion": 1, + "repositories": [ + { "url": "https://repo.example.com/maven", "tokenEnv": "DCU_MAVEN_TOKEN" }, + { "url": "https://repo.example.com/releases", "usernameEnv": "DCU_MAVEN_USER", "passwordEnv": "DCU_MAVEN_PASSWORD" }, + { "url": "https://repo.example.com/public" } + ] +} +``` + +Supply either a Bearer token environment-variable name, both HTTP Basic credential names, or no credentials for an explicitly authorized anonymous endpoint. Never put secret values in the file or URL. Missing variables and invalid headers fail before writes. HTTPS is required except for explicit loopback HTTP repositories. Embedded URL credentials, query strings and fragments are rejected. Private requests refuse all redirects; point to the final metadata endpoint rather than forwarding credentials. Authorization headers are sensitive, cache keys include credentials, and diagnostic messages omit credential values and request URLs. Private metadata uses the same bounded response/in-flight cache policy in a separate redirect-disabled client. + +```bash +dcu -d --maven-config repositories.json --format json-report +dcu -d -u --maven-config repositories.json --fail-on-incomplete +``` + +### Development tool declarations and local installations + +Project tool pins are shown in the `toolchain` section. Supported tools in `.tool-versions` and mise's `[tools]` table are `node` / `nodejs`, `npm`, `pnpm`, `yarn`, `bun`, `rust`, and `java` / `jdk`. Numeric versions, a leading `v`, short release-line pins, and Temurin's `temurin-` prefix are preserved. Vendor-specific JDK formats other than Temurin, multiple installed-version lists and complex mise tool tables are reported as unsupported. + +Moving channels such as `stable`, `beta`, `nightly`, `lts/*` and `lts/jod` remain channels and are reported separately from numeric pins. A project's `engines` and other support ranges are not rewritten into a new minimum. Unsupported compound constraints are preserved. Supported hidden files are included by `-d`; `.gitignore` / `.ignore`, other hidden directories, and existing dependency/build directory exclusions still apply. + +`packageManager` supports npm, pnpm, Yarn and Bun. Existing Corepack hashes (`sha1`, `sha224`, `sha256`, `sha384`, `sha512`) are recalculated from the selected release's bytes: npm/pnpm/Yarn Classic tarballs, or the official `yarn.js` for Yarn 2+. The algorithm is retained. Failure or an unknown integrity scheme preserves the declaration; a hash is never silently removed. Bun declarations without hashes are supported; hashed Bun declarations are preserved because no Corepack integrity convention applies. URL-based and escaped/ambiguous `packageManager` declarations are outside the supported syntax. + +```bash +# Project declarations, including generated Tauri Android scripts +bunx @dependency-check-updates/cli -d +dcu -d -u --reject junit +dcu -d androidx.webkit pnpm node +dcu --manifest apps/app/src-tauri/gen/android/app/build.gradle.kts -u -t patch +dcu --manifest gradle/wrapper/gradle-wrapper.properties -u + +# Installed tool versions; works even without a project manifest +dcu --local-tools +dcu --local-tools node bun pnpm rust jdk --format json +``` + +`--manifest` can also update a referenced ancestor property or catalog source. Other discovered Gradle declarations participate in compatibility and shared-source checks (respecting ignore/build-directory exclusions), without entering the update allowlist. A shared source is preserved if consumers select different targets, a consumer is unselected/filtered/rejected, or any consumer's lookup fails. Even repeated declarations of the same artifact retain their individual repository context. + +Context discovery is lazy: a Node/Rust/Python-only selection does not recursively scan for Gradle files. A targeted Gradle selection scans the nearest build and relevant ancestor sources; if a shared source belongs to an ancestor build, that scope expands to find its other consumers. A deep scan reuses the files already discovered. Nearest settings/wrapper boundaries stop repository inheritance from unrelated builds, and catalog aliases use the consuming build's repositories. Ignore rules still apply to these context scans. Arbitrary `includeBuild(...)` and applied external convention scripts are not followed: they are reported and leave compatibility unverified, even if the visible pins otherwise match. + +`--local-tools` runs only bounded version queries for Node, Bun, pnpm, Rust and JDK, reporting missing tools, failed queries and failed registry lookups. It compares installed versions with release metadata and provides update guidance. Probes run in a temporary neutral directory; Corepack project selection, network downloads and automatic pinning, and rustup automatic toolchain installation are disabled. Project tool pins therefore do not select or install the probed toolchain. It cannot be combined with `-u`, `--manifest`, `-d` or cleanup flags. `-u` continues to modify project files only. Local updates remain explicit user actions: [`bun upgrade`](https://bun.sh/docs/installation), [`pnpm self-update`](https://pnpm.io/cli/self-update), or `rustup update stable`; Node and JDK have no universal self-update command, so use the [Node installer](https://nodejs.org/en/download) or [Temurin installation instructions](https://adoptium.net/installation). + +### Compatibility and report statuses + +Reports distinguish the latest published release (`latest`), the target selection (`selected`), and a safe project change (`to`). Before writing, DCU checks the complete proposed combination within each Gradle build, including ancestor wrapper and declared JDK pins. AGP/Kotlin versions referenced from ancestor properties or catalogs are checked in their consuming builds. Definite conflicts preserve the coupled AGP/Gradle/Kotlin/JDK/SDK changes, including their ancestor sources. Missing required pins and combinations beyond the checked tables are `unverified`; this is visible and does not claim universal build compatibility. A plain Gradle/JDK project does not require Android pins; standalone tool declarations do not require a Gradle compatibility check. + +The bounded rules cover AGP 8.0–8.13 and 9.0–9.4 minimum Gradle versions, their JDK 17 requirement, integer SDK 34–37 minimum AGP versions, Kotlin 1.9.20–1.9.25 and explicitly listed 2.0–2.4 KGP compatibility ranges, bundled Kotlin bytecode/R8 minimum AGP requirements, and Gradle's documented runtime support for JDK 17–27 (JDK 27 requires Gradle 9.8.0). Kotlin combinations above fully supported maximums are unverified. AGP 9 plus an applied external Kotlin Android/KMP plugin is blocked until an explicit migration; DCU does not edit plugin application or opt-out configuration. Custom R8 overrides, JDK selection by Gradle/Android Studio, plugin migrations and general dependency solving are outside these checks. + +Rules are based on the official [AGP/Gradle and SDK requirements](https://developer.android.com/build/releases/about-agp), [Kotlin compiler requirements](https://developer.android.com/build/kotlin-support), [KGP compatibility ranges](https://kotlinlang.org/docs/gradle-configure-project.html), [Gradle Java matrix](https://docs.gradle.org/current/userguide/compatibility.html) and [AGP 9 migration notes](https://developer.android.com/build/releases/agp-9-0-0-release-notes). The versioned [built-in snapshot](crates/cli/data/compatibility-v1.json) records its review date and sources, and ships inside the CLI crate. The checker consumes this data rather than hard-coded release tables. + +`--compatibility-file rules.json` extends coverage without rebuilding DCU. Use the snapshot's schema 1 layout: required `verifiedAt` and official `sources`, optional `agp` (major.minor -> minimum Gradle/JDK), `sdk` (API -> minimum AGP), `jdk` (major -> minimum Gradle), and `kotlin` (explicit version ranges, fully supported Gradle/AGP bounds and minimum bytecode AGP). Empty/omitted maps add no rules. Invalid versions, inverted/overlapping ranges, unknown fields and attempts to replace built-in conditions are rejected before writes. Identical repeated built-in rules are allowed. Review new conditions against their official documentation before supplying the file; URLs and dates are provenance, not authentication. Reports explicitly identify user-supplied rule extensions as not independently authenticated. Unsupported future releases are never guessed from a previous release's conditions. + +`--format json` continues to emit one JSON array on stdout across all manifests, without table headers/footers. Rows include `manifest`, `name`, `section`, `from`, `latest`, `selected`, `compatible`, `to`, `status`, `reason`, `compatibility`, `selectionPolicy`, and `updated`. Statuses are `update`, `current`, `channel`, `unsupported`, `failed`, `blocked`, or `unverified`; installed-tool rows also use `missing`. Failed/unhandled declarations are never represented as “all up to date”. See [JSON contracts](#json-contracts) for a versioned envelope and an explicit legacy migration format. + +`newest` uses Gradle build timestamps, Node release dates, Bun release timestamps and npm publish dates. Maven, SDK platform and JDK metadata in this scanner have no per-version publish-date selection, so `newest` falls back to `greatest`; modern Yarn tags use the same fallback. Rust uses the current official stable manifest for `latest` / `newest` / `greatest` and the first 100 GitHub releases for `minor` / `patch`. This does not offer exhaustive historical or prerelease Rust selection. + +### Strict CI checks + +`--fail-on-incomplete` exits 2 when a selected declaration is unsupported, failed, blocked, unverified, or a selected local tool is missing. Unverified compatibility counts even when a row otherwise has an available update. Run-level diagnostics, including pending recovery receipts, also count. Intentional channels such as `stable`/LTS alone do not fail, and excluded items are not counted. With `-u`, any incomplete result aborts the entire update batch before writes and skips requested cleanup. + +`--strict-compatibility` blocks unverified coupled Gradle/AGP/Kotlin/JDK/SDK changes. Independent library and tool updates may still proceed. It does not change version-target selection or claim to solve dependency compatibility; use it together with `--fail-on-incomplete` to require a fully checked batch. It cannot be combined with `--local-tools`. + +#### Verified combination suggestions + +Gradle-tool rows also expose `compatible`: a candidate from a combination that passes the documented rules. A normal query leaves `selected` (the `--target` result) and project files unchanged. Use `--compatible -u` explicitly to select these suggestions instead of the individually selected latest tool versions. Independent libraries and standalone tools still use ordinary target selection. Filters, `--reject`, numeric prefixes and short JDK pin precision are retained; channels remain channels, and existing pins are never downgraded. + +```bash +dcu -d --format json-report # compare latest, selected and compatible +dcu -d --compatible -u --fail-on-incomplete # apply a verified retained batch +dcu -d --compatible --target minor # restrict candidate release lines +``` + +This is a bounded, deterministic search, not a general dependency solver or a build execution: published candidates at or below the ordinary selection are tried in descending AGP, Kotlin, Gradle, JDK and SDK order. There are at most 64 candidates per dimension plus a current-pin fallback and 4,096 search steps per connected build component. Shared sources are checked across their consumers; separate components are checked independently. A suggestion is the first verified combination in that order, not a promise of a globally optimal version vector. Missing pins, dynamic build connections or exhausted searches produce no verified suggestion. An exhausted step budget is a diagnostic; `--compatible` leaves affected coupled changes unapplied when no verified combination is found. Normal compatibility/shared-source guards run again after selection and integrity preparation. + +Rule dates are validated as Gregorian dates, including leap years. `json-report.compatibilityRules` records each built-in/extension source set, `verifiedAt`, `userSupplied`, UTC `ageDays`, `stale` and `future`. Data older than 180 days or future-dated data makes compatibility unverified and emits a diagnostic; strict compatibility and verified-combination selection cannot approve it. User-provided official-looking source URLs remain user-reviewed provenance, not independently authenticated evidence. + +Exit policy is shared by the binary and bridges: execution/apply failures exit 1; otherwise `--fail-on-incomplete` takes priority and exits 2 for incomplete checks; otherwise `-e 2` exits 1 for available updates; otherwise exit 0. Argument errors also exit 2. Without the new flags, item-level lookup failures remain visible but keep the existing exit policy. + +```bash +# Read-only CI check: distinguish incomplete checks (2) from available updates (1) +dcu -d --fail-on-incomplete -e 2 --format json-report + +# Apply only a fully checked batch; no cleanup or writes on incomplete checks +dcu -d -u --strict-compatibility --fail-on-incomplete --format json-report + +# Missing or failed installed-tool probes fail the check without installing anything +dcu --local-tools --fail-on-incomplete --format json-report +``` + +### Recoverable project updates + +All ecosystem patches are prepared before any project file is changed. DCU stages each replacement and an original-byte backup beside its target, checks the original bytes again, and writes `.dcu-transaction-active.json` in the working directory. The receipt and stable sibling `.dcu-lock-` files use OS locks: another DCU run cannot update the same target even from a different working directory. Lock files remain as empty coordination identities; the OS releases their locks when the process exits. Do not delete them while an updater is running. Each file is atomically replaced; `updated` becomes true only after the whole batch commits. Regular apply failures roll back already-replaced files. A file changed by another process is never overwritten during rollback: backups and the receipt remain, and the report says `recovery-required`. + +The batch is recoverable, **not crash-atomic**: existing independent files cannot be made visible as one atomic filesystem operation. A subsequent query reports the receipt without modifying files; `-u` refuses to proceed until recovery is resolved. Use the explicit recovery command below: it preflights every current target, validates source SHA-256 hashes and scoped artifact paths, refuses live transactions and external edits, and requires no registry/network requests. Rollback restores recorded original bytes; finish completes the previously prepared replacements, without selecting newer versions. Already-restored/completed files are accepted, so recovery can be retried. Original backups are copied rather than consumed during recovery. Corrupt/missing required artifacts leave the receipt intact. Recovery never runs implicitly during a read-only query. + +```bash +dcu --recover rollback --format json-report # restore the recorded original batch +dcu --recover finish --format json-report # finish the recorded prepared batch +``` + +Run from the directory that contains the receipt. Recovery cannot combine with filters, manifests, `-u`, `-d`, cleanup, local probes or registry/rule configuration. `json-report` records `rolled-back`, `committed`, `no-changes` or `recovery-required`; recovery failures exit 1. Updates and recovery targets must stay within the working directory tree (canonical parent paths are checked). Run from an external manifest's own project root instead of writing outside the recovery scope. After an external edit, deliberately reconcile it with the recorded versions first; no command blindly overwrites it or discards the receipt. + +Successful commits and ordinary successful rollbacks remove their staged/backup/receipt artifacts (stable sibling lock identities remain). Symbolic-link and hard-linked targets are rejected; Windows read-only targets are also rejected. Unix staging preserves owner/group, mode and exposed extended attributes/ACLs, refusing the update if those cannot be copied. Unix parent directories are synced after staging and replacement. Windows stages and backups receive the source DACL/protection before any content is written; native replacement retains the target's ACL and creation metadata. These guards coordinate cooperating DCU processes; unrelated editors do not honor DCU's locks, so there remains a narrow comparison/replacement race. DCU does not claim multi-file power-loss atomicity or overwrite permissions to force a write. + +Before replacing targets, DCU syncs sibling `.dcu-pending-.json` links to the recovery receipt. Another DCU invocation detects these links even from a different working directory and refuses to modify an interrupted batch. Updates with pending recovery evidence stop before registry queries. Read-only queries report the evidence and do not remove it. A link whose receipt was already removed is reclaimed only during a later update while holding the target lock. Recovery still runs from the receipt's directory and validates recorded targets/artifacts before writing. If files were committed but finalization fails, the report retains their `updated` state and returns a failure instead of claiming no write occurred. + +Requested lockfile/environment cleanup happens only after non-aborted, non-failed processing; it remains an explicitly destructive action outside the rollback transaction. Android/tool support adds no global-cache or installation deletion targets. + +Cleanup failures are `cleanup-failed` diagnostics with the target path and exit 1; successful removals remain listed. A cleanup failure does not undo committed project updates, so inspect both `updated`/`applyOutcome` and diagnostics. Filesystem traversal and invalid ignore-rule errors also fail the invocation rather than silently hiding manifests. The CLI and context discovery use the checked Scanner APIs; legacy Vec-returning Scanner helpers remain available for callers explicitly accepting a best-effort scan. Integrity preparation failures block only the matching declaration and sidecar, preserving independent updates and already-current statuses in the same file. `--fail-on-incomplete` still aborts the entire batch before writes. + +### JSON contracts + +All JSON modes emit exactly one value on stdout; verbose logs and cleanup progress use stderr. Project lookup results are read-only unless `-u` is supplied. + +| Format | Contract | +|---|---| +| `json` | Declaration-status array (retained); empty selections produce `[]`. Run-level diagnostics go to stderr. | +| `json-report` | Envelope with `schemaVersion: 2`, `summary`, `items`, `diagnostics`, `compatibilityRules`, and `applyOutcome`; see [report-v2.schema.json](crates/cli/schemas/report-v2.schema.json). Fatal execution errors also emit a report envelope. | +| `json-legacy` | Update-only `{ "package": "version" }` object for at most one effective project manifest; empty selections produce `{}`. Incomplete results are explained on stderr. | + +`latest` is the latest published release; `selected` follows `--target`; `compatible` is a verified coupled-tool combination candidate or null; `to` is the retained project update or null when no update is planned. A non-null `to` is not proof of a write: check `updated` and `applyOutcome`. Local rows instead contain `scope: "local"`, `installed`, `latest`, `selected`, `status`, `reason`, `updateCommand`, and `updated: false`. + +Summary counts are declaration rows, not unique packages or files: `checked`, planned `updates`, committed `updated`, and `incomplete` (incomplete rows plus run diagnostics). `manifests` counts effective manifest jobs, including referenced sources and empty context manifests. Outcomes are `not-requested`, `no-changes`, `committed`, `aborted`, `rolled-back`, or `recovery-required`. + +`json-legacy` is a migration option for consumers of the old update-only map, not a lossless monorepo report. Multiple effective manifests (even empty context manifests), local-tool reports, or conflicting selected versions for the same name are rejected **before writes**. Repeated names with the same target collapse to one map entry. Use `json`/`json-report` to retain per-file statuses and failed checks; do not interpret an empty legacy map as a successful complete scan. + +```bash +dcu -d --format json # stable declaration array +dcu -d --format json-report # versioned automation contract +dcu --manifest package.json --format json-legacy +dcu --local-tools --format json-report +``` + +### Request sharing and limits + +One invocation shares in-flight public metadata requests and responses across repeated declarations, ordinary npm packages and `packageManager`, Maven libraries/plugins, tool pins and integrity sidecars. npm, crates.io and PyPI share the same bounded request layer. URL, explicit request headers and response-size limit are part of the key; versions are still selected independently for each pin and target. Failures are shared without changing the package name in each diagnostic. A new invocation starts fresh: no stale cross-run or user-wide disk cache is created. + +The shared layer allows 10 concurrent requests, retains at most 512 keys and 64 MiB of body data, and bounds individual metadata/integrity responses to 32/50 MiB. When retention is saturated, new lookups still run with response/concurrency bounds. GitHub Actions and OCI images retain their repository/auth-aware batch implementation, now batched across manifests to avoid duplicate tag queries; authenticated token exchanges are not blindly cached by URL. + +Unretained responses do not consume the retained-body budget: reaching the key limit cannot falsely exhaust the byte budget. Candidate search reuses the same repository/header-aware cache as normal lookup. + ### GitHub Actions specifics - Discovers every `*.yml` / `*.yaml` under `.github/workflows/` and any `action.yml` / `action.yaml` at the repo root automatically. Composite actions nested under `.github/actions/**/` are picked up with `-d`. @@ -184,7 +359,7 @@ pipx run dependency-check-updates [flags] ## Usage -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. +Run from a directory containing a supported manifest or tool declaration, including `package.json`, `Cargo.toml`, `pyproject.toml`, Gradle build/settings files, `.nvmrc`, `.tool-versions`, `.github/workflows/*.yml`, `Dockerfile`, or `compose.yaml`. Canonical manifests, root `gradle/` catalogs/wrappers and supported tool declarations are auto-detected; use `-d` for nested projects. `--local-tools` also works without a project manifest. All examples below use the short `dcu` alias. The long form `dependency-check-updates` works identically. @@ -211,16 +386,23 @@ Usage: dcu [OPTIONS] [FILTER]... | Flag | Description | Default | |---|---|---| | `[FILTER]...` | Positional package names to include (allowlist; repeatable) | *(all)* | -| `-u, --upgrade` | Write updated versions back to the manifest file | off | +| `-u, --upgrade` | Apply project-file updates as a recoverable multi-file batch | off | | `-d, --deep` | Recursively scan subdirectories, respecting `.gitignore` | off | | `-t, --target ` | Version target: `patch` · `minor` · `latest` · `newest` · `greatest` | `latest` | | `-x, --reject ` | Exclude packages by name (repeatable) | — | | `--manifest ` | Operate on a single specific manifest file | *(auto)* | -| `--format ` | Output format: `table` or `json` | `table` | +| `--format ` | `table`, `json` array, `json-report` v2 envelope, or single-manifest `json-legacy` map | `table` | +| `--local-tools` | Read-only installed Node/Bun/pnpm/Rust/JDK versions, release comparison and update guidance | off | +| `--recover ` | `rollback` or `finish` a recorded interrupted update, verifying hashes without network requests | off | +| `--maven-config ` | Explicit literal Maven endpoints and credential environment-variable names (schema 1 JSON) | off | +| `--compatibility-file ` | Validated rule extensions; cannot replace built-in compatibility conditions | off | +| `--fail-on-incomplete` | Exit 2 for incomplete checks; with `-u`, abort all writes and cleanup | off | +| `--strict-compatibility` | Block unverified coupled Gradle-tool changes; independent updates may proceed | off | +| `--compatible` | Select a bounded verified Gradle-tool combination; latest/target remain separately visible | off | | `--remove-lockfile` | Delete lockfiles next to each manifest so the package manager re-resolves transitive deps on the next install | off | | `--remove-installed` | Delete installed-dependency directories next to each manifest for a clean install | off | | `--rm` | Shortcut for `--remove-lockfile --remove-installed` — wipes both in one go | off | -| `-e, --error-level ` | `1` = always exit 0 · `2` = exit 1 when updates exist (CI gate) | `1` | +| `-e, --error-level ` | `1` = no update-availability failure; `2` = exit 1 when updates exist (execution/strict failures still apply) | `1` | | `-v, --verbose` | Increase verbosity: `-v` info · `-vv` debug · `-vvv` trace | off | | `-h, --help` | Print help | — | | `-V, --version` | Print version | — | @@ -239,6 +421,8 @@ Usage: dcu [OPTIONS] [FILTER]... ### Examples +Cleanup remains scoped to the existing manifest-sibling lockfiles and installed directories listed by `--help`: Node lockfiles and `node_modules`, `Cargo.lock` and `target`, Python lockfiles and local environments. Android/Gradle/tool declarations add no cleanup targets. `--rm` never removes user-wide Gradle caches, wrapper distributions, Android SDKs, Corepack caches, rustup toolchains or other global tool installations. Deletion is permanent; the removed lockfiles/environments must be regenerated by the corresponding package manager. + ```bash # Target specific update level dcu -t patch # patch only @@ -263,9 +447,13 @@ dcu --manifest docker-compose.override.yml # Machine-readable output for scripting/CI dcu --format json +dcu -d --format json-report +dcu --manifest package.json --format json-legacy # CI gate: exit 1 if any updates are available dcu -e 2 +dcu -d --fail-on-incomplete -e 2 --format json-report +dcu -d -u --strict-compatibility --fail-on-incomplete # Verbose logging (accumulating) dcu -v # info @@ -337,6 +525,9 @@ Follows the [changepacks](https://github.com/changepacks/changepacks) pattern - **TOML** (`Cargo.toml`, `pyproject.toml`): `toml_edit` document model preserves comments, table ordering, inline-table formatting, and whitespace. - **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. +- **Gradle and project tools**: Static byte-range patches to numeric declarations and resolved source spans preserve comments, quotes, indentation, prefixes and CRLF; wrapper URL/checksum and package-manager version/integrity are prepared together. + +The CLI combines prepared patches into a recoverable transaction and uses one typed run report for table/JSON output and exit policy. Scanner context is scoped and lazy; the shared registry metadata layer is bounded and invocation-local. There is no Gradle execution or general dependency solver. ### Shared Traits @@ -351,9 +542,26 @@ Before reporting an update, the resolver checks whether the selected version alr ## Development +The Tauri-style fixture in `crates/cli/tests/fixtures/tauri` exercises real discovery, repeated Gradle declarations, catalogs/properties, hidden tool files and existing ecosystems. Tests materialize `Cargo.toml.fixture` as a real `Cargo.toml` in a temporary monorepo; the template suffix prevents Cargo packaging from excluding the fixture as a nested Rust package. Registry responses are fixed in Rust, Node bridge and installed Python-wheel tests. Regression coverage includes read-only scans, CRLF/comment preservation, wrapper checksums, verified combination selection, compatibility/shared-source blocking, scoped discovery and traversal errors, request sharing and saturated-cache accounting, JSON contracts/exit codes, per-declaration integrity failure, cleanup failures, multi-file rollback, explicit crash recovery across working directories, finalization failures, external edits, credential isolation and redirects. + +The reusable [project-regressions workflow](.github/workflows/project-regressions.yml) runs locked workspace tests, lint, format, both CLI aliases, fixed-response native bridge tests, packed npm/native-package installation and installed wheel query/update on Linux, macOS and Windows. It also runs the complete Rust 1.88 workspace tests and verifies all distributable Rust crate archives. The main CI calls this workflow and requires it before `changepacks`, thereby gating dependent package publication. Platform-specific filesystem tests run only on their OS; remote CI results are separate from a local Windows run. Packaging checks do not publish packages. + +```bash +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +git diff --check +cargo +1.88.0 test --locked --workspace +cargo package --locked --workspace --exclude dependency-check-updates-napi --exclude dependency-check-updates-python-bridge --allow-dirty +bun install --frozen-lockfile --ignore-scripts +bun run --cwd bridge/node test +uvx --from 'maturin>=1.8,<2' maturin build --locked --manifest-path bridge/python/Cargo.toml --out target/wheel-smoke +uv run --no-project python bridge/python/test_wheel.py target/wheel-smoke +``` + Build prerequisites: -- Rust 1.85+ (stable toolchain) +- Rust 1.88+ (matches the locked NAPI/benchmark dependencies; tested as the minimum workspace toolchain) - Bun 1.0+ *(or Node.js 18+ with npm)* - Python 3.11+ with [`maturin`](https://www.maturin.rs/) *(only for the Python wheel step)* - Windows: Visual Studio 2022 Build Tools (MSVC linker) diff --git a/bridge/node/Cargo.toml b/bridge/node/Cargo.toml index ebaac65..fc8a555 100644 --- a/bridge/node/Cargo.toml +++ b/bridge/node/Cargo.toml @@ -3,6 +3,7 @@ name = "dependency-check-updates-napi" version = "0.1.0" description = "Node.js bridge for dependency-check-updates (N-API bindings)" edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true diff --git a/bridge/node/test/main.test.js b/bridge/node/test/main.test.js index db1fbea..04b4c25 100644 --- a/bridge/node/test/main.test.js +++ b/bridge/node/test/main.test.js @@ -1,49 +1,49 @@ const { describe, expect, test } = require("bun:test"); -const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require("node:fs"); +const { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } = 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"); +const mainJs = join(__dirname, "..", "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 } +async function run(main, root, args) { + const child = Bun.spawn(["node", main, ...args], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [status, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { status, stdout, stderr }; +} -[dependency-groups] -dev = ["pytest>=9.0.3"] -`, - ); - - const result = spawnSync("node", [mainJs, "-d", "-u", "--rm"], { - cwd: root, - encoding: "utf8", - }); +describe("node CLI bridge (fixed metadata, no public registry)", () => { + test("query is read-only and upgrade edits every Android declaration", async () => { + const root = mkdtempSync(join(tmpdir(), "dcu-node-android-")); + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + if (new URL(request.url).pathname !== "/maven/sample/lib/maven-metadata.xml") return new Response("not found", { status: 404 }); + return new Response("1.0.01.1.0"); + }}); + const paths = ["apps/app/src-tauri/gen/android/app", "apps/app/src-tauri/gen/android/buildSrc"]; + const original = `repositories { maven { url = uri("http://127.0.0.1:${server.port}/maven") } }\r\nimplementation("sample:lib:1.0.0") // retained\r\n// implementation("sample:lib:0.0.0")\r\n`; + try { + for (const path of paths) { mkdirSync(join(root, path), { recursive: true }); writeFileSync(join(root, path, "build.gradle.kts"), original); } + writeFileSync(join(root, "maven.json"), JSON.stringify({ schemaVersion: 1, repositories: [{ url: `http://127.0.0.1:${server.port}/maven` }] })); + const args = ["-d", "sample:lib", "--maven-config", "maven.json", "--format", "json-report", "--fail-on-incomplete"]; + const query = await run(mainJs, root, args); + expect(query.status, query.stderr).toBe(0); + const report = JSON.parse(query.stdout); + expect(report.items.length).toBe(2); expect(report.summary.updates).toBe(2); + for (const path of paths) expect(readFileSync(join(root, path, "build.gradle.kts"), "utf8")).toBe(original); + const applied = await run(mainJs, root, [...args, "-u"]); + expect(applied.status, applied.stderr).toBe(0); + expect(JSON.parse(applied.stdout).applyOutcome).toBe("committed"); + for (const path of paths) expect(readFileSync(join(root, path, "build.gradle.kts"), "utf8")).toBe(original.replace('implementation("sample:lib:1.0.0")', 'implementation("sample:lib:1.1.0")')); + } finally { server.stop(true); rmSync(root, { recursive: true, force: true }); } + }, 30000); - 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 }); - } + test("cleanup failures are structured and produce a failure exit", async () => { + const root = mkdtempSync(join(tmpdir(), "dcu-node-cleanup-")); + try { + writeFileSync(join(root, "package.json"), "{}"); + mkdirSync(join(root, "package-lock.json")); + const result = await run(mainJs, root, ["--rm", "--format", "json-report"]); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).diagnostics.some(d => d.code === "cleanup-failed")).toBe(true); + } finally { rmSync(root, { recursive: true, force: true }); } }); }); diff --git a/bridge/node/test/packaging.test.js b/bridge/node/test/packaging.test.js new file mode 100644 index 0000000..ea692d3 --- /dev/null +++ b/bridge/node/test/packaging.test.js @@ -0,0 +1,45 @@ +const { expect, test } = require("bun:test"); +const { mkdtempSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync } = require("node:fs"); +const { join } = require("node:path"); +const { tmpdir } = require("node:os"); + +async function command(args, cwd) { + const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env, NAPI_RS_ENFORCE_VERSION_CHECK: "1" } }); + const [status, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(status, `${args.join(" ")}\n${stderr}\n${stdout}`).toBe(0); + return stdout; +} + +test("packed npm CLI loads its separately installed native platform package", async () => { + const repo = join(__dirname, ".."); + const root = mkdtempSync(join(tmpdir(), "dcu-npm-pack-")); + const copy = join(root, "package"); mkdirSync(copy); + try { + const suffix = { "win32-x64": "win32-x64-msvc", "darwin-x64": "darwin-x64", "darwin-arm64": "darwin-arm64", "linux-x64": "linux-x64-gnu" }[`${process.platform}-${process.arch}`]; + expect(suffix).toBeDefined(); + const binary = `dependency-check-updates.${suffix}.node`; + for (const file of ["package.json", "main.js", "index.js", "index.d.ts", binary]) cpSync(join(repo, file), join(copy, file)); + // This smoke test packages the runner's real artifact, not absent foreign + // targets. Release CI separately collects every configured target. + const config = JSON.parse(readFileSync(join(copy, "package.json"), "utf8")); + config.napi.targets = [{ "win32-x64-msvc": "x86_64-pc-windows-msvc", "darwin-x64": "x86_64-apple-darwin", "darwin-arm64": "aarch64-apple-darwin", "linux-x64-gnu": "x86_64-unknown-linux-gnu" }[suffix]]; + writeFileSync(join(copy, "package.json"), JSON.stringify(config)); + await command(["bun", "x", "--no-install", "napi", "create-npm-dirs", "--cwd", copy], repo); + await command(["bun", "x", "--no-install", "napi", "artifacts", "--cwd", copy, "--output-dir", "."], repo); + const npm = process.platform === "win32" ? ["cmd.exe", "/d", "/c", "npm"] : ["npm"]; + const pack = async dir => JSON.parse(await command([...npm, "pack", "--ignore-scripts", "--json", "--pack-destination", root], dir))[0]; + const cli = await pack(copy); + const native = await pack(join(copy, "npm", suffix)); + expect(cli.files.some(f => f.path === "main.js")).toBe(true); + expect(native.files.some(f => f.path === binary)).toBe(true); + const installed = join(root, "installed"); mkdirSync(installed); + await command([...npm, "install", "--offline", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev", "--prefix", installed, join(root, cli.filename), join(root, native.filename)], installed); + const installedMain = join(installed, "node_modules", "@dependency-check-updates", "cli", "main.js"); + expect(readdirSync(join(installed, "node_modules", "@dependency-check-updates", "cli")).some(f => f.endsWith(".node"))).toBe(false); + const help = await command(["node", installedMain, "--help"], root); + expect(help).toContain("--compatible"); + const report = JSON.parse(await command(["node", installedMain, "--local-tools", "node", "--reject", "node", "--format", "json-report"], root)); + expect(report.schemaVersion).toBe(2); expect(report.items).toEqual([]); + expect(JSON.parse(readFileSync(join(installed, "node_modules", "@dependency-check-updates", `cli-${suffix}`, "package.json"), "utf8")).version).toBe(JSON.parse(readFileSync(join(copy, "package.json"), "utf8")).version); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 120000); diff --git a/bridge/python/Cargo.toml b/bridge/python/Cargo.toml index 730d31b..08d4a38 100644 --- a/bridge/python/Cargo.toml +++ b/bridge/python/Cargo.toml @@ -3,6 +3,7 @@ name = "dependency-check-updates-python-bridge" version = "0.1.0" description = "Python bridge for dependency-check-updates (standalone binary)" edition.workspace = true +rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true diff --git a/bridge/python/test_wheel.py b/bridge/python/test_wheel.py new file mode 100644 index 0000000..6d8ffc9 --- /dev/null +++ b/bridge/python/test_wheel.py @@ -0,0 +1,67 @@ +"""Build/install smoke test: only local wheel archives and fixed HTTP metadata.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Metadata(BaseHTTPRequestHandler): + def do_GET(self): + body = b"1.0.01.1.0" + self.send_response(200 if self.path == "/maven/sample/lib/maven-metadata.xml" else 404) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +def smoke(wheel_dir): + wheels = list(wheel_dir.glob("*.whl")) + if len(wheels) != 1: + raise RuntimeError("expected exactly one built wheel") + with tempfile.TemporaryDirectory(prefix="dcu-wheel-") as directory: + root = Path(directory) + environment = root / "env" + subprocess.run(["uv", "venv", "--no-project", str(environment)], check=True) + python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + subprocess.run(["uv", "pip", "install", "--python", str(python), "--no-index", "--no-deps", str(wheels[0].resolve())], check=True) + project = root / "project" + project.mkdir() + scripts = environment / ("Scripts" if os.name == "nt" else "bin") + server = ThreadingHTTPServer(("127.0.0.1", 0), Metadata) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + repo = f"http://127.0.0.1:{server.server_port}/maven" + config = project / "maven.json" + config.write_text(json.dumps({"schemaVersion": 1, "repositories": [{"url": repo}]})) + target = project / "build.gradle.kts" + original = f'repositories {{ maven {{ url = uri("{repo}") }} }}\r\nimplementation("sample:lib:1.0.0") // retained\r\n' + target.write_bytes(original.encode()) + for alias in ["dcu", "dependency-check-updates"]: + executable = scripts / (alias + ".exe" if os.name == "nt" else alias) + help_text = subprocess.run([str(executable), "--help"], cwd=project, check=True, capture_output=True, encoding="utf-8", timeout=30).stdout + assert "--compatible" in help_text + command = [str(executable), "--maven-config", str(config), "--format", "json-report", "--fail-on-incomplete"] + report = json.loads(subprocess.run(command, cwd=project, check=True, capture_output=True, encoding="utf-8", timeout=30).stdout) + assert report["items"][0]["latest"] == "1.1.0" + assert target.read_bytes() == original.encode() + report = json.loads(subprocess.run(command + ["-u"], cwd=project, check=True, capture_output=True, encoding="utf-8", timeout=30).stdout) + assert report["applyOutcome"] == "committed" + assert target.read_bytes() == original.replace("sample:lib:1.0.0", "sample:lib:1.1.0").encode() + print("Wheel installation, both aliases, fixed metadata query/update and CRLF: PASS") + finally: + server.shutdown() + server.server_close() + thread.join() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("wheel_dir", type=Path) + smoke(parser.parse_args().wheel_dir) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 740f111..b80e0dc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -11,6 +11,7 @@ documentation.workspace = true readme.workspace = true keywords.workspace = true categories = ["command-line-utilities", "development-tools"] +include = ["src/**", "tests/**", "data/**", "schemas/**", "Cargo.toml"] [[bin]] name = "dependency-check-updates" @@ -34,6 +35,7 @@ dependency-check-updates-docker.workspace = true rustls.workspace = true clap.workspace = true serde_json.workspace = true +serde.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true @@ -41,10 +43,25 @@ futures.workspace = true owo-colors.workspace = true semver.workspace = true indicatif.workspace = true +reqwest.workspace = true +toml_edit.workspace = true +regex = "1.12" +quick-xml = "0.39" +sha2 = "0.10" +sha1 = "0.10" +tempfile.workspace = true +fs2 = "0.4.3" +base64 = "0.22" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Security", "Win32_Security_Authorization"] } + +[target.'cfg(unix)'.dependencies] +xattr = "1.6.1" [dev-dependencies] rstest.workspace = true -tempfile.workspace = true +wiremock.workspace = true [lints] workspace = true diff --git a/crates/cli/data/compatibility-v1.json b/crates/cli/data/compatibility-v1.json new file mode 100644 index 0000000..51bccbf --- /dev/null +++ b/crates/cli/data/compatibility-v1.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "verifiedAt": "2026-10-01", + "sources": [ + "https://developer.android.com/build/releases/about-agp", + "https://developer.android.com/build/kotlin-support", + "https://kotlinlang.org/docs/gradle-configure-project.html", + "https://docs.gradle.org/current/userguide/compatibility.html" + ], + "agp": { + "8.0": { "minGradle": "8.0", "minJdk": 17 }, + "8.1": { "minGradle": "8.0", "minJdk": 17 }, + "8.2": { "minGradle": "8.2", "minJdk": 17 }, + "8.3": { "minGradle": "8.4", "minJdk": 17 }, + "8.4": { "minGradle": "8.6", "minJdk": 17 }, + "8.5": { "minGradle": "8.7", "minJdk": 17 }, + "8.6": { "minGradle": "8.7", "minJdk": 17 }, + "8.7": { "minGradle": "8.9", "minJdk": 17 }, + "8.8": { "minGradle": "8.10.2", "minJdk": 17 }, + "8.9": { "minGradle": "8.11.1", "minJdk": 17 }, + "8.10": { "minGradle": "8.11.1", "minJdk": 17 }, + "8.11": { "minGradle": "8.13", "minJdk": 17 }, + "8.12": { "minGradle": "8.13", "minJdk": 17 }, + "8.13": { "minGradle": "8.13", "minJdk": 17 }, + "9.0": { "minGradle": "9.1.0", "minJdk": 17 }, + "9.1": { "minGradle": "9.3.1", "minJdk": 17 }, + "9.2": { "minGradle": "9.4.1", "minJdk": 17 }, + "9.3": { "minGradle": "9.5.0", "minJdk": 17 }, + "9.4": { "minGradle": "9.6.0", "minJdk": 17 } + }, + "sdk": { "34": "8.1.1", "35": "8.6.0", "36": "8.9.1", "37": "9.1.1" }, + "jdk": { "17": "7.3", "18": "7.5", "19": "7.6", "20": "8.3", "21": "8.5", "22": "8.8", "23": "8.10", "24": "8.14", "25": "9.1.0", "26": "9.4.0", "27": "9.8.0" }, + "kotlin": [ + { "from": "1.9.20", "to": "1.9.25", "minGradle": "6.8.3", "maxGradle": "8.1.1", "minAgp": "4.2.2", "maxAgp": "8.1.0", "bytecodeMinAgp": "8.0" }, + { "from": "2.0.0", "to": "2.0.10", "minGradle": "6.8.3", "maxGradle": "8.5", "minAgp": "7.1.3", "maxAgp": "8.3.1", "bytecodeMinAgp": "8.5" }, + { "from": "2.0.20", "to": "2.0.21", "minGradle": "6.8.3", "maxGradle": "8.8", "minAgp": "7.1.3", "maxAgp": "8.5", "bytecodeMinAgp": "8.5" }, + { "from": "2.1.0", "to": "2.1.10", "minGradle": "7.6.3", "maxGradle": "8.10", "minAgp": "7.3.1", "maxAgp": "8.7.2", "bytecodeMinAgp": "8.6" }, + { "from": "2.1.20", "to": "2.1.21", "minGradle": "7.6.3", "maxGradle": "8.12.1", "minAgp": "7.3.1", "maxAgp": "8.7.2", "bytecodeMinAgp": "8.6" }, + { "from": "2.2.0", "to": "2.2.10", "minGradle": "7.6.3", "maxGradle": "8.14", "minAgp": "7.3.1", "maxAgp": "8.10.0", "bytecodeMinAgp": "8.10" }, + { "from": "2.2.20", "to": "2.2.21", "minGradle": "7.6.3", "maxGradle": "8.14", "minAgp": "7.3.1", "maxAgp": "8.11.1", "bytecodeMinAgp": "8.10" }, + { "from": "2.3.0", "to": "2.3.0", "minGradle": "7.6.3", "maxGradle": "9.0.0", "minAgp": "8.2.2", "maxAgp": "8.13.0", "bytecodeMinAgp": "8.13.2" }, + { "from": "2.3.10", "to": "2.3.10", "minGradle": "7.6.3", "maxGradle": "9.0.0", "minAgp": "8.2.2", "maxAgp": "9.0.0", "bytecodeMinAgp": "8.13.2" }, + { "from": "2.3.20", "to": "2.3.21", "minGradle": "7.6.3", "maxGradle": "9.3.0", "minAgp": "8.2.2", "maxAgp": "9.0.0", "bytecodeMinAgp": "8.13.2" }, + { "from": "2.4.0", "to": "2.4.10", "minGradle": "7.6.3", "maxGradle": "9.5.0", "minAgp": "8.5.2", "maxAgp": "9.1.0", "bytecodeMinAgp": "9.1.0" }, + { "from": "2.4.20", "to": "2.4.20", "minGradle": "7.6.3", "maxGradle": "9.7.0", "minAgp": "8.5.2", "maxAgp": "9.3.1", "bytecodeMinAgp": "9.1.0" } + ] +} diff --git a/crates/cli/schemas/report-v2.schema.json b/crates/cli/schemas/report-v2.schema.json new file mode 100644 index 0000000..e883917 --- /dev/null +++ b/crates/cli/schemas/report-v2.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:dependency-check-updates:report:v2", + "title": "dependency-check-updates versioned report v2", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "summary", "items", "diagnostics", "applyOutcome", "compatibilityRules"], + "properties": { + "schemaVersion": { "const": 2 }, + "summary": { "$ref": "#/$defs/summary" }, + "items": { "type": "array", "items": { "oneOf": [{ "$ref": "#/$defs/project" }, { "$ref": "#/$defs/local" }] } }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } }, + "compatibilityRules": { "type": "array", "items": { "$ref": "#/$defs/provenance" } }, + "applyOutcome": { "enum": ["not-requested", "no-changes", "committed", "aborted", "rolled-back", "recovery-required"] } + }, + "$defs": { + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["verifiedAt", "sources", "userSupplied", "ageDays", "stale", "future"], + "properties": { + "verifiedAt": { "type": "string", "format": "date" }, + "sources": { "type": "array", "items": { "type": "string", "format": "uri" } }, + "userSupplied": { "type": "boolean" }, + "ageDays": { "type": "integer" }, + "stale": { "type": "boolean" }, + "future": { "type": "boolean" } + } + }, + "nullableString": { "type": ["string", "null"] }, + "status": { "enum": ["update", "current", "channel", "unsupported", "failed", "blocked", "unverified", "missing"] }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["manifests", "checked", "updates", "updated", "incomplete"], + "properties": { + "manifests": { "type": "integer", "minimum": 0 }, + "checked": { "type": "integer", "minimum": 0 }, + "updates": { "type": "integer", "minimum": 0 }, + "updated": { "type": "integer", "minimum": 0 }, + "incomplete": { "type": "integer", "minimum": 0 } + } + }, + "project": { + "type": "object", + "additionalProperties": false, + "required": ["manifest", "name", "section", "from", "to", "latest", "selected", "compatible", "status", "reason", "compatibility", "selectionPolicy", "updated"], + "properties": { + "manifest": { "type": "string" }, + "name": { "type": "string" }, + "section": { "type": "string" }, + "from": { "type": "string" }, + "to": { "$ref": "#/$defs/nullableString" }, + "latest": { "$ref": "#/$defs/nullableString" }, + "selected": { "$ref": "#/$defs/nullableString" }, + "compatible": { "$ref": "#/$defs/nullableString" }, + "status": { "$ref": "#/$defs/status" }, + "reason": { "$ref": "#/$defs/nullableString" }, + "compatibility": { "$ref": "#/$defs/nullableString" }, + "selectionPolicy": { "$ref": "#/$defs/nullableString" }, + "updated": { "type": "boolean" } + } + }, + "local": { + "type": "object", + "additionalProperties": false, + "required": ["name", "scope", "installed", "latest", "selected", "status", "reason", "updateCommand", "updated"], + "properties": { + "name": { "type": "string" }, + "scope": { "const": "local" }, + "installed": { "$ref": "#/$defs/nullableString" }, + "latest": { "$ref": "#/$defs/nullableString" }, + "selected": { "$ref": "#/$defs/nullableString" }, + "status": { "$ref": "#/$defs/status" }, + "reason": { "$ref": "#/$defs/nullableString" }, + "updateCommand": { "type": "string" }, + "updated": { "const": false } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "path"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" }, + "path": { "$ref": "#/$defs/nullableString" } + } + } + } +} diff --git a/crates/cli/src/cleanup.rs b/crates/cli/src/cleanup.rs index 8376efc..a11f44b 100644 --- a/crates/cli/src/cleanup.rs +++ b/crates/cli/src/cleanup.rs @@ -21,9 +21,14 @@ pub(crate) fn lockfiles_for(kind: ManifestKind) -> &'static [&'static str] { ManifestKind::PyProjectToml => &["uv.lock", "poetry.lock", "Pipfile.lock"], // 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 => { - &[] - } + ManifestKind::GitHubWorkflow + | ManifestKind::Dockerfile + | ManifestKind::DockerCompose + | ManifestKind::Gradle + | ManifestKind::GradleCatalog + | ManifestKind::GradleProperties + | ManifestKind::GradleWrapper + | ManifestKind::ToolVersions => &[], } } @@ -43,9 +48,14 @@ pub(crate) fn installed_dirs_for(kind: ManifestKind) -> &'static [&'static str] // 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 => { - &[] - } + ManifestKind::GitHubWorkflow + | ManifestKind::Dockerfile + | ManifestKind::DockerCompose + | ManifestKind::Gradle + | ManifestKind::GradleCatalog + | ManifestKind::GradleProperties + | ManifestKind::GradleWrapper + | ManifestKind::ToolVersions => &[], } } diff --git a/crates/cli/src/cleanup_progress.rs b/crates/cli/src/cleanup_progress.rs index 761a192..06142ee 100644 --- a/crates/cli/src/cleanup_progress.rs +++ b/crates/cli/src/cleanup_progress.rs @@ -63,10 +63,16 @@ pub(crate) fn targets_for_job( targets } -pub(crate) async fn cleanup_with_progress(targets: Vec) -> String { +#[derive(Default)] +pub(crate) struct CleanupReport { + pub summary: String, + pub diagnostics: Vec, +} + +pub(crate) async fn cleanup_with_progress(targets: Vec) -> CleanupReport { let len = targets.len(); if len == 0 { - return String::new(); + return CleanupReport::default(); } let pb = ProgressBar::new(len as u64); @@ -81,13 +87,28 @@ pub(crate) async fn cleanup_with_progress(targets: Vec) -> String let mut removals = FuturesUnordered::new(); for target in targets { - removals.push(tokio::task::spawn_blocking(move || remove_target(target))); + removals.push(async move { + let worker_target = target.clone(); + ( + target, + tokio::task::spawn_blocking(move || remove_target(worker_target)).await, + ) + }); } let mut removed = Vec::with_capacity(len); let mut total_bytes = 0_u64; - - while let Some(outcome) = removals.next().await { + let mut diagnostics = Vec::new(); + + while let Some((target, outcome)) = removals.next().await { + let error = removal_error(&outcome); + if let Some(error) = error { + diagnostics.push(crate::report::Diagnostic { + code: "cleanup-failed".into(), + message: format!("{}: {error}", target.label), + path: Some(target.path.display().to_string()), + }); + } if let Some(message) = absorb_outcome(outcome, &mut removed, &mut total_bytes) { pb.set_message(message); } @@ -95,7 +116,11 @@ pub(crate) async fn cleanup_with_progress(targets: Vec) -> String } pb.finish_and_clear(); - render_cleanup_summary(&mut removed, total_bytes) + diagnostics.sort_by(|a, b| a.path.cmp(&b.path)); + CleanupReport { + summary: render_cleanup_summary(&mut removed, total_bytes), + diagnostics, + } } /// Fold one worker's result into the running tally, returning the progress @@ -106,6 +131,16 @@ pub(crate) async fn cleanup_with_progress(targets: Vec) -> String /// 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 removal_error( + outcome: &Result>, tokio::task::JoinError>, +) -> Option { + match outcome { + Ok(Some(Err(e))) => Some(e.to_string()), + Err(e) => Some(e.to_string()), + _ => None, + } +} + fn absorb_outcome( outcome: Result>, tokio::task::JoinError>, removed: &mut Vec, @@ -413,7 +448,9 @@ mod tests { 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()); + let outcome = Err(join_error); + assert!(removal_error(&outcome).unwrap().contains("worker exploded")); + assert!(absorb_outcome(outcome, &mut removed, &mut total).is_none()); assert!(removed.is_empty()); assert_eq!(total, 0); @@ -421,7 +458,9 @@ mod tests { #[tokio::test] async fn cleanup_with_progress_is_silent_when_there_is_nothing_to_remove() { - assert_eq!(cleanup_with_progress(Vec::new()).await, ""); + let report = cleanup_with_progress(Vec::new()).await; + assert_eq!(report.summary, ""); + assert!(report.diagnostics.is_empty()); } #[tokio::test] @@ -467,6 +506,10 @@ mod tests { // Only the two successful removals are listed, sorted by label, and // the total is their sum. + assert_eq!(summary.diagnostics.len(), 1); + assert_eq!(summary.diagnostics[0].code, "cleanup-failed"); + assert_eq!(summary.diagnostics[0].path.as_deref(), undeletable.to_str()); + let summary = summary.summary; let lines: Vec<&str> = summary.lines().collect(); assert_eq!(lines.len(), 3, "got: {summary}"); assert!(lines[0].contains("app:bun.lock")); @@ -487,8 +530,9 @@ mod tests { }, display_path: String::new(), text: String::new(), - handler: &dependency_check_updates_node::NodeHandler, + handler: Box::new(dependency_check_updates_node::NodeHandler), deps: Vec::new(), + document: None, }; assert!(targets_for_job(&job, true, true).is_empty()); @@ -516,8 +560,9 @@ mod tests { }, display_path: "package.json".to_owned(), text: String::new(), - handler: &dependency_check_updates_node::NodeHandler, + handler: Box::new(dependency_check_updates_node::NodeHandler), deps: Vec::new(), + document: None, }; let targets = targets_for_job(&job, true, true); diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 3053a3f..bc4f134 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -21,7 +21,7 @@ pub struct Cli { /// Package names to check (acts as filter) pub filter: Vec, - /// Update package file with new versions + /// Apply project-file updates as a recoverable multi-file batch #[arg(short, long)] pub upgrade: bool, @@ -29,6 +29,34 @@ pub struct Cli { #[arg(short, long)] pub deep: bool, + /// Recover an interrupted project update, validating all hashes before writing + #[arg(long, value_enum, conflicts_with_all = ["upgrade", "deep", "local_tools", "manifest", "rm", "remove_lockfile", "remove_installed", "filter", "reject", "strict_compatibility", "maven_config", "compatibility_file", "compatible", "target"])] + pub recover: Option, + + /// Explicit Maven repository allowlist and environment-variable credential names (JSON) + #[arg(long, conflicts_with = "local_tools")] + pub maven_config: Option, + + /// Validated compatibility rule extensions from a local JSON file + #[arg(long, conflicts_with = "local_tools")] + pub compatibility_file: Option, + + /// Inspect installed Node, Bun, pnpm, Rust and JDK; never install or modify tools + #[arg(long, conflicts_with_all = ["upgrade", "rm", "remove_lockfile", "remove_installed", "manifest", "deep"])] + pub local_tools: bool, + + /// Exit 2 on incomplete checks; with -u, abort all writes before applying + #[arg(long)] + pub fail_on_incomplete: bool, + + /// Block changes to coupled Gradle tools whose compatibility is unverified + #[arg(long, conflicts_with = "local_tools")] + pub strict_compatibility: bool, + + /// Select a verified bounded Gradle-tool combination, without changing channels or filters + #[arg(long, conflicts_with = "local_tools")] + pub compatible: bool, + /// Target version level #[arg(short, long, default_value = "latest", value_parser = parse_target_level)] pub target: TargetLevel, @@ -78,7 +106,8 @@ pub struct Cli { #[arg(long = "rm")] pub rm: bool, - /// Exit code behavior: 1 = exit 0 always, 2 = exit 1 if upgrades exist + /// Update-availability policy: 1 = no failure, 2 = exit 1 if updates exist; + /// execution failures and --fail-on-incomplete take priority #[arg(short, long, default_value = "1")] pub error_level: u8, @@ -107,13 +136,32 @@ impl Cli { } /// How results are rendered to stdout. -#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] pub enum OutputFormat { /// Human-readable ncu-style table (default). #[default] Table, - /// Machine-readable JSON object (`{ "name": "to" }`). + /// Declaration-status JSON array; run-level diagnostics use stderr. Json, + /// Versioned JSON report with summary, items, diagnostics and apply outcome. + JsonReport, + /// Legacy update-only object; requires one effective project manifest. + JsonLegacy, +} + +/// Explicit recovery action for an interrupted project update. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum RecoveryMode { + /// Restore every recorded target to its original bytes + Rollback, + /// Finish the already-prepared update without fetching new versions + Finish, +} + +impl OutputFormat { + pub(crate) fn is_json(self) -> bool { + self != Self::Table + } } /// Parse a `TargetLevel` from a string. diff --git a/crates/cli/src/compatibility.rs b/crates/cli/src/compatibility.rs new file mode 100644 index 0000000..31f23fb --- /dev/null +++ b/crates/cli/src/compatibility.rs @@ -0,0 +1,401 @@ +//! Deliberately bounded official compatibility checks, not a dependency solver. +use crate::compatibility_rules::Rules; +use crate::project::Document; +use dependency_check_updates_core::{DependencySpec, PlannedUpdate, pad_to_three_segments}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub(crate) type Plans = HashMap>; + +pub(crate) fn related(name: &str) -> bool { + matches!( + name, + "com.android.tools.build:gradle" + | "org.jetbrains.kotlin:kotlin-gradle-plugin" + | "gradle" + | "jdk" + | "android.compileSdk" + | "android.targetSdk" + ) || matches!(name, "com.android.application" | "com.android.library") + || name.starts_with("org.jetbrains.kotlin.") +} +fn agp(name: &str) -> bool { + name == "com.android.tools.build:gradle" + || matches!(name, "com.android.application" | "com.android.library") +} +fn kotlin(name: &str) -> bool { + name == "org.jetbrains.kotlin:kotlin-gradle-plugin" || name.starts_with("org.jetbrains.kotlin.") +} +fn ver(v: &str) -> Option { + semver::Version::parse(&pad_to_three_segments(v.trim_start_matches('v'))).ok() +} + +pub(crate) fn scope(path: &Path, documents: &HashMap) -> PathBuf { + for parent in path.ancestors().skip(1) { + if documents.contains_key(&parent.join("gradle/wrapper/gradle-wrapper.properties")) + || documents.contains_key(&parent.join("settings.gradle")) + || documents.contains_key(&parent.join("settings.gradle.kts")) + { + return parent.to_owned(); + } + } + path.parent().unwrap_or(path).to_owned() +} + +fn proposed<'a>(plans: &'a Plans, path: &Path, dep: &'a DependencySpec) -> &'a str { + plans + .get(path) + .and_then(|us| { + us.iter().find(|u| { + u.name == dep.name && u.from == dep.current_req && u.section == dep.section + }) + }) + .map_or(&dep.current_req, |u| &u.to) +} + +/// Return per-build status and block all coupled changes on a definite conflict. +/// A second pass using retained current versions avoids inconsistent fallbacks. +pub(crate) fn guard( + documents: &HashMap, + plans: &mut Plans, + strict: bool, + rules: &Rules, +) -> HashMap { + let mut statuses = HashMap::new(); + let mut scopes: Vec<_> = documents.keys().map(|p| scope(p, documents)).collect(); + scopes.sort(); + scopes.dedup(); + for root in scopes { + let members: Vec<_> = documents + .values() + .filter(|d| scope(&d.path, documents) == root) + .collect(); + let mut values = Vec::new(); + for d in &members { + for issue in &d.context_issues { + values.push(("unsupported-build-connection", issue.as_str())); + } + for plugin in &d.applied_plugins { + values.push(("applied-kotlin-android", plugin.as_str())); + } + for e in &d.entries { + if related(&e.dep.name) { + values.push((e.dep.name.as_str(), proposed(plans, &d.path, &e.dep))); + } + } + for (source, dep) in &d.resolved_uses { + if related(&dep.name) { + values.push((dep.name.as_str(), proposed(plans, source, dep))); + } + } + } + // Tool pins declared at an ancestor project root apply to nested builds. + for d in documents.values().filter(|d| { + root.starts_with(d.path.parent().unwrap_or(&d.path)) + && !members.iter().any(|m| m.path == d.path) + }) { + for e in &d.entries { + if e.dep.name == "jdk" { + values.push(("jdk", proposed(plans, &d.path, &e.dep))); + } + } + } + if !values.iter().any(|(n, _)| *n == "gradle") { + for parent in root.ancestors() { + if let Some(wrapper) = + documents.get(&parent.join("gradle/wrapper/gradle-wrapper.properties")) + { + for e in &wrapper.entries { + if e.dep.name == "gradle" { + values.push(("gradle", proposed(plans, &wrapper.path, &e.dep))); + } + } + break; + } + } + } + let coupled = values.iter().any(|(n, _)| { + agp(n) + || kotlin(n) + || matches!(*n, "gradle" | "android.compileSdk" | "android.targetSdk") + }); + if !coupled { + continue; + } + let mut status = check_with_rules(&values, rules); + if strict && coupled && status.starts_with("unverified:") { + status = format!( + "unverified: strict compatibility blocks this combination; {}", + status.trim_start_matches("unverified: ") + ); + } + if status.starts_with("conflict:") + || (strict && coupled && status.starts_with("unverified:")) + { + for path in block_coupled(documents, plans, &root, &members) { + statuses.insert(path, status.clone()); + } + } + for d in members { + if !statuses.get(&d.path).is_some_and(|s: &String| { + s.starts_with("conflict:") || s.contains("strict compatibility blocks") + }) { + statuses.insert(d.path.clone(), status.clone()); + } + } + } + statuses +} + +fn block_coupled( + documents: &HashMap, + plans: &mut Plans, + root: &Path, + members: &[&Document], +) -> Vec { + let before: HashMap<_, _> = plans.iter().map(|(p, u)| (p.clone(), u.len())).collect(); + for d in members { + if let Some(updates) = plans.get_mut(&d.path) { + updates.retain(|u| !related(&u.name)); + } + for (source, dep) in &d.resolved_uses { + if related(&dep.name) + && let Some(updates) = plans.get_mut(source) + { + updates.retain(|u| { + u.name != dep.name || u.from != dep.current_req || u.section != dep.section + }); + } + } + } + // Ancestor JDK/wrapper pins apply to this build too. + for (path, updates) in plans.iter_mut() { + if root.starts_with(path.parent().unwrap_or(path)) { + updates.retain(|u| u.name != "jdk"); + } + let wrapper_root = path.parent().and_then(Path::parent).and_then(Path::parent); + if documents + .get(path) + .is_some_and(|d| d.distribution.is_some()) + && wrapper_root.is_some_and(|p| root.starts_with(p)) + { + updates.retain(|u| u.name != "gradle"); + } + } + plans + .iter() + .filter(|(path, u)| before.get(*path).is_some_and(|n| *n != u.len())) + .map(|(path, _)| path.clone()) + .collect() +} + +#[cfg(test)] +fn check(values: &[(&str, &str)]) -> String { + check_with_rules(values, &Rules::builtin()) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn check_with_rules(values: &[(&str, &str)], rules: &Rules) -> String { + let agps: Vec<_> = values + .iter() + .filter(|(n, _)| agp(n)) + .filter_map(|(_, v)| ver(v)) + .collect(); + let gradles: Vec<_> = values + .iter() + .filter(|(n, _)| *n == "gradle") + .filter_map(|(_, v)| ver(v)) + .collect(); + let kotlins: Vec<_> = values + .iter() + .filter(|(n, _)| kotlin(n)) + .filter_map(|(_, v)| ver(v)) + .collect(); + let jdks: Vec<_> = values + .iter() + .filter(|(n, _)| *n == "jdk") + .filter_map(|(_, v)| ver(v)) + .collect(); + let android = !agps.is_empty() + || values.iter().any(|(n, _)| { + matches!( + *n, + "applied-kotlin-android" | "android.compileSdk" | "android.targetSdk" + ) + }); + let mut unknown = (android && agps.is_empty()) + || values.iter().any(|(n, v)| related(n) && ver(v).is_none()) + || gradles.is_empty() + || jdks.is_empty() + || values + .iter() + .any(|(n, _)| *n == "unsupported-build-connection"); + for a in &agps { + if let Some(rule) = rules.agp.get(&format!("{}.{}", a.major, a.minor)) { + let min = rule.min_gradle.as_str(); + if gradles.iter().any(|g| g < &ver(min).unwrap()) { + return format!("conflict: AGP {a} requires Gradle >= {min}"); + } + if jdks.iter().any(|j| j.major < rule.min_jdk) { + return format!("conflict: AGP {a} requires JDK >= {}", rule.min_jdk); + } + } else { + unknown = true; + } + for (name, value) in values + .iter() + .filter(|(n, _)| matches!(*n, "android.compileSdk" | "android.targetSdk")) + { + let minimum = match value.parse::() { + Ok(1..=33) => None, + Ok(api) if rules.sdk.contains_key(&api) => Some(rules.sdk[&api].as_str()), + _ => { + unknown = true; + None + } + }; + if let Some(min) = minimum + && a < &ver(min).unwrap() + { + return format!("conflict: {name} {value} requires AGP >= {min}"); + } + } + if a.major >= 9 && values.iter().any(|(n, _)| *n == "applied-kotlin-android") { + return "conflict: AGP 9 built-in Kotlin requires an explicit migration of external Kotlin Android plugins; automatic migration is unsupported".into(); + } + } + for k in &kotlins { + let bounds = rules + .kotlin + .iter() + .find(|r| k >= &ver(&r.from).unwrap() && k <= &ver(&r.to).unwrap()); + let min_agp = bounds + .or_else(|| { + rules.kotlin.iter().find(|r| { + let from = ver(&r.from).unwrap(); + from.major == k.major && from.minor == k.minor + }) + }) + .map(|r| r.bytecode_min_agp.as_str()); + if let Some(min) = min_agp { + if agps.iter().any(|a| a < &ver(min).unwrap()) { + return format!("conflict: Kotlin {k} bytecode requires AGP >= {min}"); + } + } else { + unknown = true; + } + if let Some(rule) = bounds { + let (gmin, gmax, amin, amax) = ( + rule.min_gradle.as_str(), + rule.max_gradle.as_str(), + rule.min_agp.as_str(), + rule.max_agp.as_str(), + ); + if gradles.iter().any(|g| g < &ver(gmin).unwrap()) + || agps.iter().any(|a| a < &ver(amin).unwrap()) + { + return format!("conflict: Kotlin {k} requires Gradle >= {gmin}, AGP >= {amin}"); + } + if gradles.iter().any(|g| g > &ver(gmax).unwrap()) + || agps.iter().any(|a| a > &ver(amax).unwrap()) + { + unknown = true; + } + } else { + unknown = true; + } + } + for g in &gradles { + for j in &jdks { + if g.major >= 9 && j.major < 17 { + return "conflict: Gradle 9 requires JDK >= 17".into(); + } + let min = rules.jdk.get(&j.major).map(String::as_str); + if let Some(min) = min { + if g < &ver(min).unwrap() { + return format!("conflict: JDK {} requires Gradle >= {min}", j.major); + } + } else { + unknown = true; + } + } + } + if rules.provenance.iter().any(|p| p.stale || p.future) { + "unverified: compatibility rule verification date is stale or in the future".into() + } else if unknown { + "unverified: missing tool pins or combination outside documented compatibility coverage" + .into() + } else if rules.extensions { + "verified: supported conditions checked against built-in and explicitly supplied rule data (extensions are user-reviewed, not independently authenticated)".into() + } else { + "verified: supported AGP/Gradle/Kotlin/JDK/SDK conditions checked".into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn undocumented_sdk_kotlin_and_jdk_versions_remain_unverified() { + let base = [ + ("com.android.tools.build:gradle", "8.7.2"), + ("gradle", "8.9"), + ("jdk", "17"), + ]; + for item in [ + ("android.compileSdk", "999"), + ("android.targetSdk", "preview"), + ("org.jetbrains.kotlin:kotlin-gradle-plugin", "99.0.0"), + ("org.jetbrains.kotlin:kotlin-gradle-plugin", "2.1.99"), + ("jdk", "99"), + ] { + let mut values = base.to_vec(); + values.push(item); + assert!(check(&values).starts_with("unverified:"), "{item:?}"); + } + assert!( + check(&[ + ("org.jetbrains.kotlin:kotlin-gradle-plugin", "2.1.21"), + ("gradle", "1.0"), + ("jdk", "17") + ]) + .starts_with("conflict:") + ); + assert!(check(&[("gradle", "9.0"), ("jdk", "11")]).starts_with("conflict:")); + } + #[test] + fn known_conflicts_and_unknown() { + assert!( + check(&[ + ("com.android.tools.build:gradle", "8.7.3"), + ("gradle", "8.8"), + ("jdk", "17") + ]) + .starts_with("conflict:") + ); + assert!( + check(&[ + ("com.android.tools.build:gradle", "8.7.2"), + ("gradle", "8.9"), + ("jdk", "17"), + ("org.jetbrains.kotlin:kotlin-gradle-plugin", "2.1.21") + ]) + .starts_with("verified:") + ); + assert!( + check(&[ + ("com.android.tools.build:gradle", "42.0"), + ("gradle", "40.0") + ]) + .starts_with("unverified:") + ); + assert!(check(&[("gradle", "8.9"), ("jdk", "25")]).starts_with("conflict:")); + assert!( + check(&[ + ("com.android.tools.build:gradle", "8.5.1"), + ("org.jetbrains.kotlin:kotlin-gradle-plugin", "2.2.0") + ]) + .starts_with("conflict:") + ); + } +} diff --git a/crates/cli/src/compatibility_rules.rs b/crates/cli/src/compatibility_rules.rs new file mode 100644 index 0000000..db2113c --- /dev/null +++ b/crates/cli/src/compatibility_rules.rs @@ -0,0 +1,367 @@ +//! Versioned, reviewable compatibility data; extensions cannot weaken built-ins. +use crate::project::error; +use dependency_check_updates_core::{DcuError, pad_to_three_segments}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AgpRule { + pub min_gradle: String, + pub min_jdk: u64, +} + +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct KotlinRule { + pub from: String, + pub to: String, + pub min_gradle: String, + pub max_gradle: String, + pub min_agp: String, + pub max_agp: String, + pub bytecode_min_agp: String, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Rules { + schema_version: u32, + verified_at: String, + sources: Vec, + #[serde(default)] + pub agp: BTreeMap, + #[serde(default)] + pub sdk: BTreeMap, + #[serde(default)] + pub jdk: BTreeMap, + #[serde(default)] + pub kotlin: Vec, + #[serde(skip)] + pub extensions: bool, + #[serde(skip)] + pub provenance: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Provenance { + pub verified_at: String, + pub sources: Vec, + pub user_supplied: bool, + pub age_days: i64, + pub stale: bool, + pub future: bool, +} + +// Gregorian validation and days since 0001-01-01. No local timezone dependence. +fn date_days(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() != 10 + || bytes[4] != b'-' + || bytes[7] != b'-' + || !bytes + .iter() + .enumerate() + .all(|(i, b)| matches!(i, 4 | 7) || b.is_ascii_digit()) + { + return None; + } + let year = text[..4].parse::().ok()?; + let month = text[5..7].parse::().ok()?; + let day = text[8..].parse::().ok()?; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let months = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if year == 0 || !(1..=12).contains(&month) || day < 1 || day > months[month - 1] { + return None; + } + let prior = year - 1; + Some( + 365 * prior + prior / 4 - prior / 100 + + prior / 400 + + months[..month - 1].iter().sum::() + + day + - 1, + ) +} + +#[cfg(not(test))] +fn today_days() -> i64 { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + / 86400; + 719_162 + i64::try_from(elapsed).unwrap_or(i64::MAX - 719_162) +} + +#[cfg(test)] +fn today_days() -> i64 { + // Keep fixed-response unit tests independent of the wall clock. Production + // binaries (including CLI process tests) use the real UTC date above. + date_days("2026-10-01").expect("fixture clock") +} + +impl Provenance { + fn new(date: &str, sources: Vec, user_supplied: bool, today: i64) -> Self { + let age_days = today - date_days(date).expect("validated Gregorian date"); + Self { + verified_at: date.into(), + sources, + user_supplied, + age_days, + stale: age_days > 180, + future: age_days < 0, + } + } +} + +pub(crate) fn version(value: &str) -> Option { + semver::Version::parse(&pad_to_three_segments(value)) + .ok() + .filter(|v| v.pre.is_empty() && v.build.is_empty()) +} + +impl Rules { + pub fn builtin() -> Self { + Self::parse(include_str!("../data/compatibility-v1.json")) + .expect("checked built-in compatibility data") + } + + pub fn load(path: Option<&Path>) -> Result { + let mut rules = Self::builtin(); + if let Some(path) = path { + if std::fs::metadata(path) + .map_err(|source| DcuError::Io { + path: path.to_owned(), + source, + })? + .len() + > 1024 * 1024 + { + return Err(error("compatibility rules", "file exceeds size limit")); + } + let text = std::fs::read_to_string(path).map_err(|source| DcuError::Io { + path: path.to_owned(), + source, + })?; + let extra = Self::parse(&text)?; + rules + .provenance + .extend(extra.provenance.into_iter().map(|mut p| { + p.user_supplied = true; + p + })); + rules.extensions = true; + merge_map(&mut rules.agp, extra.agp)?; + merge_map(&mut rules.sdk, extra.sdk)?; + merge_map(&mut rules.jdk, extra.jdk)?; + for rule in extra.kotlin { + if rules.kotlin.iter().any(|r| overlap(r, &rule) && r != &rule) { + return Err(error( + "compatibility rules", + "overlapping Kotlin rule cannot replace built-in conditions", + )); + } + if !rules.kotlin.contains(&rule) { + rules.kotlin.push(rule); + } + } + } + Ok(rules) + } + + fn parse(text: &str) -> Result { + let mut rules: Self = serde_json::from_str(text) + .map_err(|_| error("compatibility rules", "invalid rule document"))?; + if rules.schema_version != 1 + || date_days(&rules.verified_at).is_none() + || rules.sources.is_empty() + || rules.sources.iter().any(|s| { + reqwest::Url::parse(s).ok().is_none_or(|u| { + u.scheme() != "https" + || !u.username().is_empty() + || u.password().is_some() + || !matches!( + u.host_str(), + Some("developer.android.com" | "kotlinlang.org" | "docs.gradle.org") + ) + }) + }) + { + return Err(error( + "compatibility rules", + "schema 1, verification date and official source URLs required", + )); + } + let valid = |v: &str| version(v).is_some(); + if rules.agp.iter().any(|(key, r)| { + key.split('.').count() != 2 + || !valid(key) + || !valid(&r.min_gradle) + || !(8..=100).contains(&r.min_jdk) + }) || rules.sdk.iter().any(|(n, v)| *n == 0 || !valid(v)) + || rules + .jdk + .iter() + .any(|(n, v)| !(8..=100).contains(n) || !valid(v)) + || rules.kotlin.iter().any(|r| { + ![ + &r.from, + &r.to, + &r.min_gradle, + &r.max_gradle, + &r.min_agp, + &r.max_agp, + &r.bytecode_min_agp, + ] + .iter() + .all(|v| valid(v)) + || version(&r.from) > version(&r.to) + || version(&r.min_gradle) > version(&r.max_gradle) + || version(&r.min_agp) > version(&r.max_agp) + }) + { + return Err(error( + "compatibility rules", + "invalid versions or inverted compatibility bounds", + )); + } + for (i, r) in rules.kotlin.iter().enumerate() { + if rules + .kotlin + .iter() + .skip(i + 1) + .any(|other| overlap(r, other)) + { + return Err(error("compatibility rules", "overlapping Kotlin ranges")); + } + } + rules.provenance.push(Provenance::new( + &rules.verified_at, + rules.sources.clone(), + false, + today_days(), + )); + Ok(rules) + } +} + +fn overlap(a: &KotlinRule, b: &KotlinRule) -> bool { + version(&a.from) <= version(&b.to) && version(&b.from) <= version(&a.to) +} + +fn merge_map( + base: &mut BTreeMap, + extra: BTreeMap, +) -> Result<(), DcuError> { + for (key, value) in extra { + if base.get(&key).is_some_and(|existing| existing != &value) { + return Err(error( + "compatibility rules", + "extension cannot replace built-in conditions", + )); + } + base.insert(key, value); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn kotlin_extension_bounds_overlap_and_file_limits_are_enforced() { + let text = include_str!("../data/compatibility-v1.json"); + let mut value: serde_json::Value = serde_json::from_str(text).unwrap(); + let original = value["kotlin"][0].clone(); + value["kotlin"] + .as_array_mut() + .unwrap() + .push(original.clone()); + assert!(Rules::parse(&value.to_string()).is_err()); + value["kotlin"] = serde_json::json!([original.clone()]); + value["kotlin"][0]["maxGradle"] = "1.0".into(); + assert!(Rules::parse(&value.to_string()).is_err()); + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("rules.json"); + value["kotlin"] = serde_json::json!([original.clone()]); + value["kotlin"][0]["bytecodeMinAgp"] = "8.1".into(); + std::fs::write(&path, value.to_string()).unwrap(); + assert!(Rules::load(Some(&path)).is_err()); + value["kotlin"] = serde_json::json!([original]); + for key in ["from", "to"] { + value["kotlin"][0][key] = "99.0.0".into(); + } + std::fs::write(&path, value.to_string()).unwrap(); + assert!( + Rules::load(Some(&path)) + .unwrap() + .kotlin + .iter() + .any(|r| r.from == "99.0.0") + ); + std::fs::write(&path, vec![b' '; 1024 * 1024 + 1]).unwrap(); + assert!( + Rules::load(Some(&path)) + .err() + .unwrap() + .to_string() + .contains("size limit") + ); + } + #[test] + fn gregorian_dates_and_freshness_are_checked_deterministically() { + for invalid in [ + "2026-99-99", + "2026-02-29", + "1900-02-29", + "0000-01-01", + "2026-04-31", + "2026-10-1", + ] { + assert!(date_days(invalid).is_none(), "{invalid}"); + } + assert!(date_days("2000-02-29").is_some()); + assert_eq!(date_days("1970-01-01"), Some(719_162)); + let today = date_days("2026-10-01").unwrap(); + let stale = Provenance::new("2026-01-01", Vec::new(), false, today); + assert!(stale.stale); + assert!(!stale.future); + let future = Provenance::new("2026-10-02", Vec::new(), true, today); + assert!(future.future); + assert!(!future.stale); + assert_eq!( + Provenance::new("2026-10-01", Vec::new(), false, today).age_days, + 0 + ); + let text = + include_str!("../data/compatibility-v1.json").replace("2026-10-01", "2026-99-99"); + assert!(Rules::parse(&text).is_err()); + } + #[test] + fn builtin_and_extensions_are_validated_and_cannot_weaken_rules() { + let builtin = Rules::builtin(); + assert_eq!(builtin.jdk[&27], "9.8.0"); + assert!(Rules::parse(r#"{"schemaVersion":1,"verifiedAt":"2026-10-01","sources":["https://private.invalid"],"agp":{}}"#).is_err()); + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("rules.json"); + std::fs::write(&path, r#"{"schemaVersion":1,"verifiedAt":"2026-10-01","sources":["https://developer.android.com/build/releases/about-agp"],"agp":{"10.0":{"minGradle":"10.1","minJdk":21}}}"#).unwrap(); + assert_eq!(Rules::load(Some(&path)).unwrap().agp["10.0"].min_jdk, 21); + std::fs::write(&path, r#"{"schemaVersion":1,"verifiedAt":"2026-10-01","sources":["https://developer.android.com/build/releases/about-agp"],"agp":{"8.5":{"minGradle":"1.0","minJdk":8}}}"#).unwrap(); + assert!(Rules::load(Some(&path)).is_err()); + } +} diff --git a/crates/cli/src/compatible.rs b/crates/cli/src/compatible.rs new file mode 100644 index 0000000..86aeda2 --- /dev/null +++ b/crates/cli/src/compatible.rs @@ -0,0 +1,686 @@ +//! Bounded search over published pins, using only the existing compatibility rules. +use crate::{ + compatibility, + compatibility_rules::Rules, + pipeline::compute_updates, + project::Document, + report::Diagnostic, + run::{ManifestJob, ResolvedBatch, source_entry}, + tool_registry::ToolRegistry, +}; +use dependency_check_updates_core::{ + DependencySpec, PlannedUpdate, ResolvedVersion, TargetLevel, pad_to_three_segments, +}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + path::PathBuf, +}; + +const MAX_CANDIDATES: usize = 64; +const MAX_STEPS: usize = 4096; + +#[derive(Default)] +pub(crate) struct Suggestions { + pub choices: HashMap<(usize, usize), String>, + pub updates: compatibility::Plans, + pub diagnostics: Vec, + pub coupled: HashSet<(usize, usize)>, +} + +struct Input { + job: usize, + dep_index: usize, + path: PathBuf, + dep: DependencySpec, + candidates: Vec, +} + +#[derive(Default)] +struct Build { + fixed: Vec<(String, String)>, + variables: Vec, +} + +fn category(name: &str) -> u8 { + match name { + "com.android.tools.build:gradle" | "com.android.application" | "com.android.library" => 0, + "gradle" => 2, + "jdk" => 3, + "android.compileSdk" | "android.targetSdk" => 4, + _ => 1, + } +} + +fn key(value: &str) -> Option { + semver::Version::parse(&pad_to_three_segments(value.trim_start_matches('v'))) + .ok() + .map(|mut v| { + v.build = semver::BuildMetadata::EMPTY; + v + }) +} + +fn add_value(build: &mut Build, path: &std::path::Path, dep: &DependencySpec, inputs: &[Input]) { + if !compatibility::related(&dep.name) { + return; + } + let matching: Vec<_> = inputs + .iter() + .enumerate() + .filter(|(_, i)| { + i.path == path + && i.dep.name == dep.name + && i.dep.current_req == dep.current_req + && i.dep.section == dep.section + }) + .map(|(i, _)| i) + .collect(); + if matching.is_empty() { + build + .fixed + .push((dep.name.clone(), dep.current_req.clone())); + } else { + for i in matching { + if !build.variables.contains(&i) { + build.variables.push(i); + } + } + } +} + +fn builds(documents: &HashMap, inputs: &[Input]) -> Vec { + let mut scopes: Vec<_> = documents + .keys() + .map(|p| compatibility::scope(p, documents)) + .collect(); + scopes.sort(); + scopes.dedup(); + scopes + .into_iter() + .filter_map(|root| { + let members: Vec<_> = documents + .values() + .filter(|d| compatibility::scope(&d.path, documents) == root) + .collect(); + let mut build = Build::default(); + let mut has_gradle = false; + let mut coupled = false; + for d in &members { + for issue in &d.context_issues { + build + .fixed + .push(("unsupported-build-connection".into(), issue.clone())); + } + for plugin in &d.applied_plugins { + build + .fixed + .push(("applied-kotlin-android".into(), plugin.clone())); + } + for (path, dep) in d + .entries + .iter() + .map(|e| (&d.path, &e.dep)) + .chain(d.resolved_uses.iter().map(|(p, dep)| (p, dep))) + { + has_gradle |= dep.name == "gradle"; + coupled |= compatibility::related(&dep.name) && dep.name != "jdk"; + add_value(&mut build, path, dep, inputs); + } + } + if !coupled { + return None; + } + for d in documents.values().filter(|d| { + root.starts_with(d.path.parent().unwrap_or(&d.path)) + && !members.iter().any(|m| m.path == d.path) + }) { + for e in d.entries.iter().filter(|e| e.dep.name == "jdk") { + add_value(&mut build, &d.path, &e.dep, inputs); + } + } + if !has_gradle { + for parent in root.ancestors() { + if let Some(wrapper) = + documents.get(&parent.join("gradle/wrapper/gradle-wrapper.properties")) + { + for e in &wrapper.entries { + add_value(&mut build, &wrapper.path, &e.dep, inputs); + } + break; + } + } + } + Some(build) + }) + .collect() +} + +fn update(input: &Input, candidate: &str) -> Option { + let selected = if input.dep.section + == dependency_check_updates_core::DependencySection::Toolchain + && input.dep.name != "gradle" + { + candidate + .split('.') + .take( + input + .dep + .current_req + .trim_start_matches('v') + .split('.') + .count(), + ) + .collect::>() + .join(".") + } else { + candidate.to_owned() + }; + compute_updates( + std::slice::from_ref(&input.dep), + &[( + 0, + Ok(ResolvedVersion { + latest: None, + selected: Some(selected), + }), + )], + ) + .pop() +} + +struct Dimension { + members: Vec, + candidates: Vec, +} + +fn dimensions(inputs: &[Input], builds: &[Build]) -> Vec { + let mut owners: Vec<_> = (0..inputs.len()).collect(); + for build in builds { + for &a in &build.variables { + for &b in &build.variables { + if category(&inputs[a].dep.name) == category(&inputs[b].dep.name) { + let old = owners[b]; + let new = owners[a]; + for owner in &mut owners { + if *owner == old { + *owner = new; + } + } + } + } + } + } + let mut groups = BTreeMap::>::new(); + for (i, owner) in owners.into_iter().enumerate() { + if builds.iter().any(|b| b.variables.contains(&i)) { + groups.entry(owner).or_default().push(i); + } + } + let mut dims: Vec<_> = groups + .into_values() + .map(|members| { + let mut candidates = inputs[members[0]].candidates.clone(); + candidates.retain(|c| { + members + .iter() + .all(|&i| inputs[i].candidates.iter().any(|v| key(v) == key(c))) + }); + // Keep the current common pin as an explicit non-mutating fallback. + let current = candidates + .iter() + .find(|v| key(v) == key(&inputs[members[0]].dep.current_req)) + .cloned(); + candidates.truncate(MAX_CANDIDATES); + if let Some(current) = current + && !candidates.contains(¤t) + { + candidates.push(current); + } + Dimension { + members, + candidates, + } + }) + .collect(); + dims.sort_by_key(|d| (category(&inputs[d.members[0]].dep.name), d.members[0])); + dims +} + +fn search( + dims: &[Dimension], + inputs: &[Input], + builds: &[Build], + rules: &Rules, + assigned: &mut HashMap, + depth: usize, + steps: &mut usize, +) -> bool { + for build in builds { + let mut values: Vec<_> = build + .fixed + .iter() + .map(|(n, v)| (n.as_str(), v.as_str())) + .collect(); + for i in &build.variables { + if let Some(v) = assigned.get(i) { + values.push((&inputs[*i].dep.name, v)); + } + } + let status = compatibility::check_with_rules(&values, rules); + if status.starts_with("conflict:") + || (depth == dims.len() && !status.starts_with("verified:")) + { + return false; + } + } + if depth == dims.len() { + return true; + } + let dim = &dims[depth]; + for candidate in &dim.candidates { + if *steps >= MAX_STEPS { + return false; + } + *steps += 1; + for &i in &dim.members { + let own = inputs[i] + .candidates + .iter() + .find(|v| key(v) == key(candidate)) + .unwrap(); + let value = + update(&inputs[i], own).map_or_else(|| inputs[i].dep.current_req.clone(), |u| u.to); + assigned.insert(i, value); + } + if search(dims, inputs, builds, rules, assigned, depth + 1, steps) { + return true; + } + for i in &dim.members { + assigned.remove(i); + } + } + false +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn suggest( + jobs: &[ManifestJob], + resolved: &[Option], + documents: &HashMap, + registry: &ToolRegistry, + target: TargetLevel, + rules: &Rules, +) -> Suggestions { + let mut result = Suggestions::default(); + let mut inputs = Vec::new(); + for (job_idx, job) in jobs.iter().enumerate() { + for (i, dep) in job + .deps + .iter() + .enumerate() + .filter(|(_, d)| compatibility::related(&d.name)) + { + let Some(entry) = + source_entry(job, i).filter(|e| e.reason.is_none() && e.span.is_some()) + else { + continue; + }; + let Some(selected) = resolved[job_idx] + .as_ref() + .and_then(|b| b.iter().find(|(idx, _)| *idx == i)) + .and_then(|(_, r)| r.as_ref().ok()) + .and_then(|r| r.selected.as_deref()) + else { + continue; + }; + match registry.candidates(entry, target, selected).await { + Ok(candidates) => inputs.push(Input { + job: job_idx, + dep_index: i, + path: job.manifest_ref.path.clone(), + dep: dep.clone(), + candidates, + }), + Err(e) => { + result.diagnostics.push(Diagnostic { + code: "compatible-lookup-failed".into(), + message: e.to_string(), + path: Some(job.display_path.clone()), + }); + inputs.push(Input { + job: job_idx, + dep_index: i, + path: job.manifest_ref.path.clone(), + dep: dep.clone(), + candidates: Vec::new(), + }); + } + } + } + } + if inputs.is_empty() { + return result; + } + let builds = builds(documents, &inputs); + for build in &builds { + for &i in &build.variables { + result.coupled.insert((inputs[i].job, inputs[i].dep_index)); + } + } + // Search each connected component independently; an unrelated broken build + // must not suppress verified suggestions for a separate build. + let mut components: Vec> = (0..builds.len()).map(|i| vec![i]).collect(); + let mut a = 0; + while a < components.len() { + let mut b = a + 1; + while b < components.len() { + if components[a].iter().any(|&x| { + components[b].iter().any(|&y| { + builds[x] + .variables + .iter() + .any(|i| builds[y].variables.contains(i)) + }) + }) { + let other = components.remove(b); + components[a].extend(other); + a = 0; + b = 1; + } else { + b += 1; + } + } + a += 1; + } + for component in components { + let scoped: Vec<_> = component + .iter() + .map(|&i| Build { + fixed: builds[i].fixed.clone(), + variables: builds[i].variables.clone(), + }) + .collect(); + let dims = dimensions(&inputs, &scoped); + if dims.is_empty() { + continue; + } + let mut assigned = HashMap::new(); + let mut steps = 0; + if !search(&dims, &inputs, &scoped, rules, &mut assigned, 0, &mut steps) { + if steps >= MAX_STEPS { + result.diagnostics.push(Diagnostic { + code: "compatible-search-limit".into(), + message: + "bounded compatibility search exhausted 4096 steps; no suggestion applied" + .into(), + path: None, + }); + } + continue; + } + for (i, candidate) in assigned { + let input = &inputs[i]; + result + .choices + .insert((input.job, input.dep_index), candidate.clone()); + if let Some(update) = update(input, &candidate) { + result + .updates + .entry(input.path.clone()) + .or_default() + .push(update); + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use dependency_check_updates_core::DependencySection; + + #[test] + fn candidate_limit_keeps_the_current_pin_as_a_non_mutating_fallback() { + let mut candidates: Vec<_> = (0..=MAX_CANDIDATES).map(|i| format!("9.0.{i}")).collect(); + candidates.push("8.9".into()); + let input = Input { + job: 0, + dep_index: 0, + path: PathBuf::from("wrapper"), + dep: DependencySpec { + name: "gradle".into(), + current_req: "8.9".into(), + section: DependencySection::Toolchain, + path_version: None, + }, + candidates, + }; + let dims = dimensions( + &[input], + &[Build { + fixed: Vec::new(), + variables: vec![0], + }], + ); + assert_eq!(dims[0].candidates.len(), MAX_CANDIDATES + 1); + assert_eq!(dims[0].candidates.last().unwrap(), "8.9"); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn suggestion_failures_report_lookup_and_search_limits_without_choices() { + use crate::{project, tool_registry::Endpoints}; + use dependency_check_updates_core::{ManifestKind, ManifestRef}; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + let gradle: Vec<_> = (0..65) + .map(|i| serde_json::json!({"version":format!("8.14.{i}")})) + .collect(); + let jdk: Vec<_> = (0..65) + .map(|i| serde_json::json!({"semver":format!("17.0.{i}")})) + .collect(); + let sdk = (1..=65).fold(String::new(), |mut xml, i| { + use std::fmt::Write; + write!( + xml, + "" + ) + .unwrap(); + xml + }); + for (route, body) in [ + ("/gradle", serde_json::to_string(&gradle).unwrap()), + ("/jdk", serde_json::json!({"versions":jdk}).to_string()), + ("/sdk", sdk), + ] { + Mock::given(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + let registry = ToolRegistry::with_endpoints(Endpoints { + gradle: format!("{}/gradle", server.uri()), + jdk: format!("{}/jdk", server.uri()), + android: format!("{}/sdk", server.uri()), + ..Endpoints::default() + }); + let root = tempfile::tempdir().unwrap(); + let mut jobs = Vec::new(); + let mut documents = HashMap::new(); + for (file, text, kind) in [ + ( + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.14.0-bin.zip\n", + ManifestKind::GradleWrapper, + ), + ( + ".tool-versions", + "java 17.0.0\n", + ManifestKind::ToolVersions, + ), + ( + "build.gradle.kts", + "compileSdk = 1\nincludeBuild(dynamicPath)\n", + ManifestKind::Gradle, + ), + ] { + let file_path = root.path().join(file); + let doc = project::parse(text, &file_path).unwrap(); + documents.insert(file_path.clone(), doc.clone()); + jobs.push(ManifestJob { + manifest_ref: ManifestRef { + path: file_path, + kind, + }, + display_path: file.into(), + text: text.into(), + deps: doc.dependencies(), + handler: Box::new(project::ProjectHandler(doc.clone())), + document: Some(doc), + }); + } + assert!(documents.values().any(|d| !d.context_issues.is_empty())); + let resolved: Vec<_> = ["8.14.64", "17.0.64", "65"] + .into_iter() + .zip(&jobs) + .map(|(selected, job)| { + Some(vec![( + job.deps + .iter() + .position(|d| compatibility::related(&d.name)) + .unwrap(), + Ok(ResolvedVersion { + latest: Some(selected.into()), + selected: Some(selected.into()), + }), + )]) + }) + .collect(); + let result = suggest( + &jobs, + &resolved, + &documents, + ®istry, + TargetLevel::Latest, + &Rules::builtin(), + ) + .await; + assert!(result.choices.is_empty()); + assert_eq!(result.coupled.len(), 3); + assert!( + result + .diagnostics + .iter() + .any(|d| d.code == "compatible-search-limit") + ); + let failed = ToolRegistry::with_endpoints(Endpoints { + gradle: format!("{}/missing", server.uri()), + ..Endpoints::default() + }); + let result = suggest( + &jobs[..1], + &resolved[..1], + &documents, + &failed, + TargetLevel::Latest, + &Rules::builtin(), + ) + .await; + assert!(result.choices.is_empty()); + assert_eq!(result.diagnostics[0].code, "compatible-lookup-failed"); + let result = suggest( + &jobs[..1], + &[None], + &documents, + ®istry, + TargetLevel::Latest, + &Rules::builtin(), + ) + .await; + assert!(result.choices.is_empty()); + jobs[0].document.as_mut().unwrap().entries[0].span = None; + let result = suggest( + &jobs[..1], + &resolved[..1], + &documents, + ®istry, + TargetLevel::Latest, + &Rules::builtin(), + ) + .await; + assert!(result.coupled.is_empty()); + } + + #[test] + fn incomplete_combinations_exhaust_a_bounded_budget_without_leaving_assignments() { + let inputs: Vec<_> = (0..5) + .map(|i| Input { + job: 0, + dep_index: i, + path: PathBuf::from("wrapper"), + dep: DependencySpec { + name: "gradle".into(), + current_req: "8.9".into(), + section: DependencySection::Toolchain, + path_version: None, + }, + candidates: vec![ + "8.13".into(), + "8.12".into(), + "8.11".into(), + "8.10".into(), + "8.9".into(), + "8.8".into(), + ], + }) + .collect(); + let dims: Vec<_> = inputs + .iter() + .enumerate() + .map(|(i, input)| Dimension { + members: vec![i], + candidates: input.candidates.clone(), + }) + .collect(); + let build = Build { + fixed: vec![ + ("jdk".into(), "17".into()), + ("unsupported-build-connection".into(), "dynamic".into()), + ], + variables: (0..5).collect(), + }; + let mut assigned = HashMap::new(); + let mut steps = 0; + assert!(!search( + &dims, + &inputs, + &[build], + &Rules::builtin(), + &mut assigned, + 0, + &mut steps + )); + assert_eq!(steps, MAX_STEPS); + assert!(assigned.is_empty()); + } + + #[test] + fn short_tool_precision_and_prefixes_are_preserved() { + let input = Input { + job: 0, + dep_index: 0, + path: PathBuf::from("tools"), + dep: DependencySpec { + name: "jdk".into(), + current_req: "v17".into(), + section: DependencySection::Toolchain, + path_version: None, + }, + candidates: Vec::new(), + }; + assert_eq!(update(&input, "27.0.1+4").unwrap().to, "v27"); + assert!(update(&input, "11.0.1").is_none()); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index e0e7288..b798553 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5,12 +5,23 @@ mod cleanup; mod cleanup_progress; mod cli; +mod compatibility; +mod compatibility_rules; +mod compatible; +mod local_tools; mod logging; +mod maven_access; mod output; mod pipeline; +mod project; +#[cfg(test)] +mod project_tests; +mod report; mod run; +mod tool_registry; +mod transaction; -pub use cli::{Cli, OutputFormat, parse_args}; +pub use cli::{Cli, OutputFormat, RecoveryMode, parse_args}; pub use run::{main, run, run_cli}; // Re-exported so bridge crates (napi, maturin) can name the unified error diff --git a/crates/cli/src/local_tools.rs b/crates/cli/src/local_tools.rs new file mode 100644 index 0000000..dd21b08 --- /dev/null +++ b/crates/cli/src/local_tools.rs @@ -0,0 +1,331 @@ +//! Explicit read-only installed-tool inspection, with bounded command execution. +use crate::cli::Cli; +use crate::project::Entry; +use crate::report::{Item, LocalRow, RunReport, Status}; +use crate::tool_registry::ToolRegistry; +use dependency_check_updates_core::{ + DcuError, DependencySection, DependencySpec, pad_to_three_segments, +}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +pub(crate) fn probe(program: &str, args: &[&str]) -> Result { + let directory = tempfile::TempDir::new() + .map_err(|e| format!("cannot create neutral probe directory: {e}"))?; + let mut child = Command::new(program) + .args(args) + .current_dir(directory.path()) + .env("COREPACK_ENABLE_NETWORK", "0") + .env("COREPACK_ENABLE_AUTO_PIN", "0") + .env("COREPACK_ENABLE_PROJECT_SPEC", "0") + .env("COREPACK_ENV_FILE", "0") + .env("COREPACK_DEFAULT_TO_LATEST", "0") + .env("RUSTUP_AUTO_INSTALL", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "not installed".to_owned() + } else { + format!("version query failed: {e}") + } + })?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if start.elapsed() < Duration::from_secs(5) => { + std::thread::sleep(Duration::from_millis(20)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err("version query failed or timed out after 5s".into()); + } + } + } + let result = child.wait_with_output().map_err(|e| e.to_string())?; + let text = format!( + "{} {}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + if !result.status.success() { + return Err(format!("version query failed: {}", text.trim())); + } + regex::Regex::new(r#"(?:^|[\s"])([0-9]+(?:\.[0-9]+){0,2})"#) + .unwrap() + .captures(&text) + .map(|c| c[1].to_owned()) + .ok_or_else(|| format!("unrecognized version output: {}", text.trim())) +} + +pub(crate) async fn run(cli: &Cli, registry: &ToolRegistry) -> Result { + run_with_probe(cli, registry, probe).await +} + +#[allow(clippy::too_many_lines)] +async fn run_with_probe( + cli: &Cli, + registry: &ToolRegistry, + probe_tool: F, +) -> Result +where + F: Fn(&str, &[&str]) -> Result + Send + Sync + Copy + 'static, +{ + if cli.format == crate::cli::OutputFormat::JsonLegacy { + return Err(crate::project::error( + "json-legacy", + "local tools require --format json or json-report", + )); + } + let specs = [ + ( + "node", + "node", + vec!["--version"], + "Node has no self-update command; use its official installer: https://nodejs.org/en/download", + ), + ("bun", "bun", vec!["--version"], "bun upgrade"), + ("pnpm", "pnpm", vec!["--version"], "pnpm self-update"), + ("rust", "rustc", vec!["--version"], "rustup update stable"), + ( + "jdk", + "java", + vec!["-version"], + "JDK has no self-update command; use the official installer or configured Adoptium repository: https://adoptium.net/installation", + ), + ]; + let mut rows = Vec::new(); + for (name, program, args, guide) in specs { + if (!cli.filter.is_empty() && !cli.filter.iter().any(|f| name.contains(f))) + || cli.reject.iter().any(|f| name.contains(f)) + { + continue; + } + let program = program.to_owned(); + let args: Vec<_> = args.into_iter().map(str::to_owned).collect(); + let installed = tokio::task::spawn_blocking(move || { + let refs: Vec<_> = args.iter().map(String::as_str).collect(); + probe_tool(&program, &refs).or_else(|e| { + if cfg!(windows) && program == "pnpm" && e == "not installed" { + probe_tool("pnpm.cmd", &refs) + } else { + Err(e) + } + }) + }) + .await + .map_err(|e| crate::project::error(name, e.to_string()))?; + let mut row = LocalRow { + name: name.into(), + scope: "local".into(), + installed: None, + latest: None, + selected: None, + status: Status::Missing, + reason: None, + update_command: guide.into(), + updated: false, + }; + match installed { + Ok(version) => { + row.installed = Some(version.clone()); + let entry = Entry { + requested: true, + dep: DependencySpec { + name: name.into(), + current_req: version.clone(), + section: DependencySection::Toolchain, + path_version: None, + }, + span: None, + reason: None, + repositories: Vec::new(), + integrity: None, + }; + match registry.resolve(&entry, cli.target).await { + Ok(r) => { + let cmp = r + .selected + .as_ref() + .and_then(|v| semver::Version::parse(&pad_to_three_segments(v)).ok()) + .zip(semver::Version::parse(&pad_to_three_segments(&version)).ok()); + let newer = cmp + .as_ref() + .is_some_and(|(latest, current)| latest > current); + row.latest = r.latest; + row.selected = r.selected; + row.status = if newer { + Status::Update + } else if cmp.is_some() { + Status::Current + } else { + Status::Unverified + }; + } + Err(e) => { + row.status = Status::Failed; + row.reason = Some(e.to_string()); + } + } + } + Err(e) => { + if e != "not installed" { + row.status = Status::Failed; + } + row.reason = Some(e); + } + } + rows.push(row); + } + let report = RunReport { + items: rows.into_iter().map(Item::Local).collect(), + ..RunReport::default() + }; + if cli.format.is_json() { + report.print_json(cli.format)?; + } else { + for item in &report.items { + if let Item::Local(row) = item { + println!( + "{}: installed={} latest={} [{:?}]\n {}", + row.name, + row.installed.as_deref().unwrap_or("unknown"), + row.latest.as_deref().unwrap_or("unknown"), + row.status, + row.reason.as_deref().unwrap_or(&row.update_command) + ); + } + } + } + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[tokio::test] + async fn local_reports_use_fixed_metadata_and_never_confuse_missing_failed_and_current() { + use crate::tool_registry::Endpoints; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/node")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + {"version":"v22.0.0", "lts":"Fixed"} + ]))) + .mount(&server) + .await; + let registry = ToolRegistry::with_endpoints(Endpoints { + node: format!("{}/node", server.uri()), + ..Endpoints::default() + }); + for (installed, expected) in [ + (Ok("20.0.0"), Status::Update), + (Ok("22.0.0"), Status::Current), + (Ok("unparseable"), Status::Unverified), + (Err("not installed"), Status::Missing), + (Err("version query failed"), Status::Failed), + ] { + let cli = Cli::parse_from(["dcu", "--local-tools", "node"]); + let report = run_with_probe(&cli, ®istry, move |_, _| { + installed.map(str::to_owned).map_err(str::to_owned) + }) + .await + .unwrap(); + let Item::Local(row) = &report.items[0] else { + panic!("expected local item") + }; + assert_eq!(row.status, expected); + assert!(!row.updated); + } + let cli = Cli::parse_from([ + "dcu", + "--local-tools", + "node", + "--reject", + "node", + "--format", + "json", + ]); + assert!( + run_with_probe(&cli, ®istry, |_, _| panic!("filtered tool was probed")) + .await + .unwrap() + .items + .is_empty() + ); + let cli = Cli::parse_from(["dcu", "--local-tools", "node", "--format", "json-legacy"]); + assert!( + run_with_probe(&cli, ®istry, |_, _| panic!("legacy tool was probed")) + .await + .is_err() + ); + let registry = ToolRegistry::with_endpoints(Endpoints { + node: format!("{}/missing", server.uri()), + ..Endpoints::default() + }); + let cli = Cli::parse_from(["dcu", "--local-tools", "node", "--format", "json-report"]); + let report = run_with_probe(&cli, ®istry, |_, _| Ok("20.0.0".into())) + .await + .unwrap(); + let Item::Local(row) = &report.items[0] else { + panic!("expected local item") + }; + assert_eq!(row.status, Status::Failed); + assert!(row.reason.as_ref().unwrap().contains("404")); + } + #[test] + fn missing_tool_is_reported() { + assert_eq!( + probe("dcu-intentionally-missing-tool-0d215b", &["--version"]).unwrap_err(), + "not installed" + ); + } + + #[cfg(unix)] + #[test] + fn probe_reports_command_failures_and_disables_automatic_installs() { + assert!( + probe("sh", &["-c", "exit 7"]) + .unwrap_err() + .contains("version query failed") + ); + assert!( + probe("sh", &["-c", "printf unrecognized"]) + .unwrap_err() + .contains("unrecognized version output") + ); + let script = "test \"$COREPACK_ENABLE_NETWORK\" = 0 && test \"$COREPACK_ENABLE_PROJECT_SPEC\" = 0 && test \"$RUSTUP_AUTO_INSTALL\" = 0 && printf 'rustc 1.85.0'"; + assert_eq!(probe("sh", &["-c", script]).unwrap(), "1.85.0"); + } + + #[cfg(unix)] + #[test] + fn probe_times_out_without_waiting_for_a_hung_tool() { + let started = Instant::now(); + assert!( + probe("sh", &["-c", "exec sleep 30"]) + .unwrap_err() + .contains("timed out") + ); + assert!(started.elapsed() < Duration::from_secs(15)); + } + + #[cfg(windows)] + #[test] + fn probe_reports_command_failures_and_disables_automatic_installs() { + assert!( + probe("cmd", &["/d", "/c", "exit 7"]) + .unwrap_err() + .contains("version query failed") + ); + let script = "if %COREPACK_ENABLE_NETWORK%==0 if %COREPACK_ENABLE_PROJECT_SPEC%==0 if %RUSTUP_AUTO_INSTALL%==0 echo rustc 1.85.0"; + assert_eq!(probe("cmd", &["/d", "/c", script]).unwrap(), "1.85.0"); + } +} diff --git a/crates/cli/src/logging.rs b/crates/cli/src/logging.rs index 8c8a2a2..a29330c 100644 --- a/crates/cli/src/logging.rs +++ b/crates/cli/src/logging.rs @@ -14,8 +14,10 @@ pub(crate) fn init_tracing(verbose: u8) { .unwrap_or_else(|_| EnvFilter::new(format!("dependency_check_updates={level}"))); fmt() + .with_writer(std::io::stderr) .with_env_filter(filter) .with_target(false) .compact() - .init(); + .try_init() + .ok(); } diff --git a/crates/cli/src/maven_access.rs b/crates/cli/src/maven_access.rs new file mode 100644 index 0000000..6febde7 --- /dev/null +++ b/crates/cli/src/maven_access.rs @@ -0,0 +1,195 @@ +//! Explicit endpoint authorization. Never evaluate Gradle credential code. +use crate::project::error; +use dependency_check_updates_core::DcuError; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + #[serde(rename = "schemaVersion")] + version: u32, + repositories: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Repository { + url: String, + username_env: Option, + password_env: Option, + token_env: Option, +} + +pub(crate) fn normalize(url: &str) -> Result { + let parsed = + reqwest::Url::parse(url).map_err(|_| error("maven config", "invalid repository URL"))?; + let local = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if (parsed.scheme() != "https" && !(local && parsed.scheme() == "http")) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.host_str().is_none() + { + return Err(error( + "maven config", + "repository URLs require HTTPS (HTTP only for loopback), no embedded credentials, query or fragment", + )); + } + Ok(parsed.as_str().trim_end_matches('/').to_owned()) +} + +pub(crate) fn load(path: &Path) -> Result, DcuError> { + let metadata = std::fs::metadata(path).map_err(|source| DcuError::Io { + path: path.to_owned(), + source, + })?; + if metadata.len() > 1024 * 1024 { + return Err(error("maven config", "configuration exceeds size limit")); + } + let text = std::fs::read_to_string(path).map_err(|source| DcuError::Io { + path: path.to_owned(), + source, + })?; + parse(&text, |name| std::env::var(name).ok()) +} + +fn parse( + text: &str, + env: impl Fn(&str) -> Option, +) -> Result, DcuError> { + use base64::Engine; + let config: Config = serde_json::from_str(text).map_err(|_| error("maven config", "invalid configuration; credentials must use environment-variable names, not literal values"))?; + if config.version != 1 || config.repositories.len() > 128 { + return Err(error( + "maven config", + "unsupported schema or repository count", + )); + } + let secret = |name: &str| { + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return Err(error( + "maven config", + "invalid credential environment-variable name", + )); + } + env(name).filter(|s| !s.is_empty()).ok_or_else(|| { + error( + "maven config", + format!("missing credential environment variable {name}"), + ) + }) + }; + let mut result = HashMap::new(); + for repo in config.repositories { + let url = normalize(&repo.url)?; + let mut headers = HeaderMap::new(); + let auth = match (repo.token_env, repo.username_env, repo.password_env) { + (Some(token), None, None) => Some(format!("Bearer {}", secret(&token)?)), + (None, Some(user), Some(password)) => Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!( + "{}:{}", + secret(&user)?, + secret(&password)? + )) + )), + (None, None, None) => None, + _ => { + return Err(error( + "maven config", + "use tokenEnv OR both usernameEnv/passwordEnv", + )); + } + }; + if let Some(auth) = auth { + let mut value = HeaderValue::from_str(&auth) + .map_err(|_| error("maven config", "invalid credential header"))?; + value.set_sensitive(true); + headers.insert(AUTHORIZATION, value); + } + if result.insert(url, headers).is_some() { + return Err(error("maven config", "duplicate repository URL")); + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn basic_anonymous_duplicate_and_invalid_auth_configurations() { + let config = serde_json::json!({"schemaVersion":1,"repositories":[ + {"url":"https://repo.example/maven", "usernameEnv":"USER", "passwordEnv":"PASS"}, + {"url":"https://repo.example/public"} + ]}); + let text = config.to_string(); + let parsed = parse(&text, |name| { + Some(if name == "USER" { "user" } else { "pass" }.into()) + }) + .unwrap(); + assert_eq!( + parsed["https://repo.example/maven"][AUTHORIZATION], + "Basic dXNlcjpwYXNz" + ); + assert!(parsed["https://repo.example/public"].is_empty()); + for repositories in [ + serde_json::json!([{"url":"https://repo.example/","tokenEnv":"BAD-NAME"}]), + serde_json::json!([{"url":"https://repo.example/","usernameEnv":"USER"}]), + serde_json::json!([{"url":"https://repo.example/"},{"url":"https://repo.example/"}]), + ] { + assert!( + parse( + &serde_json::json!({"schemaVersion":1,"repositories":repositories}).to_string(), + |_| Some("value".into()) + ) + .is_err() + ); + } + assert!( + parse( + &text.replace("\"schemaVersion\":1", "\"schemaVersion\":2"), + |_| Some("value".into()) + ) + .is_err() + ); + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write(&path, vec![b' '; 1024 * 1024 + 1]).unwrap(); + assert!(load(&path).unwrap_err().to_string().contains("size limit")); + } + #[test] + fn explicit_auth_validation_never_prints_secrets() { + let config = r#"{"schemaVersion":1,"repositories":[{"url":"https://repo.example/maven/","tokenEnv":"TOKEN"}]}"#; + let result = parse(config, |_| Some("secret-token".into())).unwrap(); + assert!(result["https://repo.example/maven"][AUTHORIZATION].is_sensitive()); + assert_eq!( + result["https://repo.example/maven"][AUTHORIZATION], + "Bearer secret-token" + ); + assert!(parse(config, |_| None).is_err()); + for url in [ + "http://repo.example/maven", + "https://user:password@repo.example/maven", + "https://repo.example/maven?token=secret", + ] { + assert!( + !normalize(url) + .unwrap_err() + .to_string() + .contains("password@") + ); + } + assert!(normalize("http://127.0.0.1:1234/maven").is_ok()); + assert!( + parse(&config.replace("TOKEN", "bad\u{a}name"), |_| Some( + "secret".into() + )) + .is_err() + ); + } +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 2839e73..9a0e9ff 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -168,6 +168,7 @@ pub fn render_footer(path: &str, upgrading: bool, has_updates: bool, use_color: /// keys are unique — last-write-wins on `to`. For consumers that need full /// `(name, from, to)` triples, use the table format and parse line-by-line. #[must_use] +#[cfg(test)] pub fn render_json(updates: &[PlannedUpdate]) -> String { let mut map = serde_json::Map::with_capacity(updates.len()); for update in updates { diff --git a/crates/cli/src/pipeline.rs b/crates/cli/src/pipeline.rs index e0d2c06..d1c4586 100644 --- a/crates/cli/src/pipeline.rs +++ b/crates/cli/src/pipeline.rs @@ -65,7 +65,12 @@ fn strip_build_metadata(v: &str) -> &str { fn resolves_to_an_exact_ref(section: DependencySection) -> bool { matches!( section, - DependencySection::GitHubActions | DependencySection::DockerImage + DependencySection::GitHubActions + | DependencySection::DockerImage + | DependencySection::Maven + | DependencySection::GradlePlugin + | DependencySection::AndroidSdk + | DependencySection::Toolchain ) } @@ -156,16 +161,15 @@ 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)), - ) { - if sel_ver <= cur_ver { - trace!( - package = %dep.name, - current = %dep.current_req, - selected = %selected, - "skipping: selected version is not newer than current" - ); - continue; - } + ) && 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 diff --git a/crates/cli/src/project.rs b/crates/cli/src/project.rs new file mode 100644 index 0000000..60b11b1 --- /dev/null +++ b/crates/cli/src/project.rs @@ -0,0 +1,1513 @@ +//! Bounded static Gradle/tool parsing. No Gradle code is executed. +use std::collections::{BTreeMap, HashMap}; +use std::ops::Range; +use std::path::{Path, PathBuf}; + +use dependency_check_updates_core::{ + DcuError, DependencySection as Section, DependencySpec, ManifestHandler, ManifestKind, + ManifestRef, ParsedManifest, Patch, PlannedUpdate, apply_byte_patches, +}; +use regex::Regex; + +#[derive(Clone, Debug)] +pub(crate) struct Entry { + pub requested: bool, + pub dep: DependencySpec, + pub span: Option>, + pub reason: Option, + pub repositories: Vec, + pub integrity: Option<(Range, String)>, +} + +#[derive(Clone, Debug)] +struct Definition { + key: String, + value: String, + span: Range, +} + +#[derive(Clone, Debug)] +struct Reference { + name: String, + key: String, + section: Section, +} + +#[derive(Clone, Debug)] +struct CatalogAlias { + accessor: String, + name: String, + section: Section, + span: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct Document { + pub path: PathBuf, + pub text: String, + pub entries: Vec, + pub checksum: Option>, + pub distribution: Option, + pub applied_plugins: Vec, + pub context_issues: Vec, + pub resolved_uses: Vec<(PathBuf, DependencySpec)>, + property_bindings: Vec<(String, String)>, + definitions: Vec, + references: Vec, + repositories: Vec, + catalog_aliases: Vec, + catalog_uses: Vec<(String, Section, bool)>, +} + +fn rx(pattern: &str) -> Regex { + Regex::new(pattern).expect("static parser regex") +} + +/// Find the end of a quoted region without interpreting Gradle expressions. +/// Triple-quoted prose is opaque, including its embedded quotes and comments. +fn quoted_end(bytes: &[u8], start: usize) -> usize { + let quote = bytes[start]; + let triple = bytes.get(start..start + 3) == Some(&[quote; 3]); + let width = if triple { 3 } else { 1 }; + let mut i = start + width; + while i < bytes.len() { + if bytes + .get(i..i + width) + .is_some_and(|s| s.iter().all(|b| *b == quote)) + { + return i + width; + } + i = if !triple && bytes[i] == b'\\' { + (i + 2).min(bytes.len()) + } else { + i + 1 + }; + } + bytes.len() +} + +pub(crate) fn error(name: &str, detail: impl Into) -> DcuError { + DcuError::RegistryLookup { + package: name.to_owned(), + detail: detail.into(), + } +} + +/// Mask comments without changing byte positions or touching quoted strings. +fn uncomment(text: &str, slash: bool) -> String { + let mut b = text.as_bytes().to_vec(); + let mut i = 0; + while i < b.len() { + if matches!(b[i], b'\'' | b'"') { + i = quoted_end(&b, i); + } else if slash && i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'*' { + b[i] = b' '; + b[i + 1] = b' '; + i += 2; + let mut depth = 1; + while i < b.len() && depth > 0 { + if i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'*' { + depth += 1; + b[i] = b' '; + b[i + 1] = b' '; + i += 2; + } else if i + 1 < b.len() && b[i] == b'*' && b[i + 1] == b'/' { + depth -= 1; + b[i] = b' '; + b[i + 1] = b' '; + i += 2; + } else { + if !matches!(b[i], b'\r' | b'\n') { + b[i] = b' '; + } + i += 1; + } + } + } else if (!slash && matches!(b[i], b'#' | b'!')) + || (slash && i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'/') + { + while i < b.len() && b[i] != b'\n' { + if b[i] != b'\r' { + b[i] = b' '; + } + i += 1; + } + } else { + i += 1; + } + } + String::from_utf8(b).expect("comment masking preserves UTF-8") +} + +fn numeric(value: &str) -> bool { + let v = value.strip_prefix('v').unwrap_or(value); + semver::Version::parse(&dependency_check_updates_core::pad_to_three_segments(v)).is_ok() +} + +impl Document { + fn entry(&mut self, name: &str, value: &str, span: Option>, section: Section) { + let (value, span) = if name == "jdk" && value.starts_with("temurin-") { + let offset = "temurin-".len(); + (&value[offset..], span.map(|s| s.start + offset..s.end)) + } else { + (value, span) + }; + self.entries.push(Entry { + requested: true, + dep: DependencySpec { + name: name.to_owned(), + current_req: value.to_owned(), + section, + path_version: None, + }, + reason: (!numeric(value) || span.is_none()).then(|| { + if matches!( + value, + "stable" | "lts" | "lts/*" | "--lts" | "node" | "latest" | "beta" | "nightly" + ) || value.starts_with("lts/") + { + "channel preserved; moving channel is not a project pin".to_owned() + } else { + "unsupported dynamic expression or support range; preserved".to_owned() + } + }), + span, + repositories: Vec::new(), + integrity: None, + }); + } + + pub fn dependencies(&self) -> Vec { + self.entries + .iter() + .filter(|e| e.requested) + .map(|e| e.dep.clone()) + .collect() + } + + pub fn apply( + &self, + text: &str, + updates: &[PlannedUpdate], + extra: Vec, + ) -> Result { + let mut patches: BTreeMap<(usize, usize), String> = BTreeMap::new(); + for entry in &self.entries { + for u in updates.iter().filter(|u| { + u.name == entry.dep.name + && u.section == entry.dep.section + && u.from == entry.dep.current_req + }) { + if entry.reason.is_some() { + continue; + } + if let Some(span) = &entry.span { + let key = (span.start, span.end); + if let Some(previous) = patches.insert(key, u.to.clone()) + && previous != u.to + { + return Err(error( + &u.name, + "shared version reference has conflicting updates", + )); + } + } + } + } + for p in extra { + patches.insert((p.start, p.end), p.new_value); + } + let patches: Vec<_> = patches + .into_iter() + .map(|((start, end), new_value)| Patch { + start, + end, + new_value, + }) + .collect(); + apply_byte_patches(text, &patches).map_err(|e| error("patch", e.to_string())) + } +} + +pub(crate) struct ProjectHandler(pub Document); +impl ManifestHandler for ProjectHandler { + fn parse(&self, text: &str, path: &Path) -> Result { + let document = if self.0.text == text && self.0.path == path { + self.0.clone() + } else { + parse(text, path)? + }; + Ok(ParsedManifest { + manifest_ref: ManifestRef { + path: path.to_owned(), + kind: ManifestKind::from_path(path) + .ok_or_else(|| error("manifest", "unknown file"))?, + }, + dependencies: document.dependencies(), + }) + } + fn apply_updates(&self, text: &str, updates: &[PlannedUpdate]) -> Result { + self.0.apply(text, updates, Vec::new()) + } +} + +pub(crate) fn parse(text: &str, path: &Path) -> Result { + let mut doc = Document { + path: path.to_owned(), + text: text.to_owned(), + entries: Vec::new(), + definitions: Vec::new(), + references: Vec::new(), + repositories: Vec::new(), + checksum: None, + distribution: None, + applied_plugins: Vec::new(), + context_issues: Vec::new(), + resolved_uses: Vec::new(), + property_bindings: Vec::new(), + catalog_aliases: Vec::new(), + catalog_uses: Vec::new(), + }; + match ManifestKind::from_path(path) { + Some(ManifestKind::Gradle) => parse_gradle(&mut doc), + Some(ManifestKind::GradleCatalog) => parse_catalog(&mut doc)?, + Some(ManifestKind::GradleProperties) => parse_properties(&mut doc), + Some(ManifestKind::GradleWrapper) => parse_wrapper(&mut doc), + Some(ManifestKind::ToolVersions | ManifestKind::PackageJson) => parse_tools(&mut doc)?, + _ => {} + } + Ok(doc) +} + +fn outside_strings(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut outside = vec![true; bytes.len()]; + let mut i = 0; + while i < bytes.len() { + if matches!(bytes[i], b'\'' | b'"') { + let start = i; + i = quoted_end(bytes, i); + outside[start..i].fill(false); + } else { + i += 1; + } + } + outside +} + +fn json_depth(text: &str) -> Vec { + let outside = outside_strings(text); + let mut depth = 0usize; + text.bytes() + .enumerate() + .map(|(i, b)| { + let at = depth; + if outside[i] { + match b { + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), + _ => {} + } + } + at + }) + .collect() +} + +#[allow(clippy::too_many_lines)] +fn parse_gradle(doc: &mut Document) { + let clean = uncomment(&doc.text, true); + let outside = outside_strings(&clean); + if rx(r"\bincludeBuild\s*\(|\bapply\s*(?:\(\s*from\s*=|from\s*:)") + .find_iter(&clean) + .any(|m| outside[m.start()]) + { + let reason = "included builds and imported convention scripts are not statically resolved"; + doc.context_issues.push(reason.into()); + doc.entry("gradle.build-connection", reason, None, Section::Maven); + } + if rx(r"\b(?:exclusiveContent|includeGroup|excludeGroup|includeModule|excludeModule)\b") + .find_iter(&clean) + .any(|m| outside[m.start()]) + { + doc.repositories + .push("unsupported Gradle repository content filters".into()); + } + for c in rx(r"\bmaven\s*\{([^}]*)\}").captures_iter(&clean) { + if !outside[c.get(0).unwrap().start()] { + continue; + } + if !rx(r#"\burl\s*(?:=|\()?\s*(?:uri\s*\()?\s*["']([^"']+)["']"#).is_match(&c[1]) { + doc.repositories + .push("unsupported dynamic Gradle Maven repository URL".into()); + } + } + for c in rx(r"\bmaven\s*\(\s*([A-Za-z_]\w*)").captures_iter(&clean) { + if outside[c.get(0).unwrap().start()] { + doc.repositories + .push("unsupported dynamic Gradle Maven repository URL".into()); + } + } + for c in rx(r#"\b(?:id\s*(?:\(\s*)?|apply\s*\(\s*plugin\s*=\s*|apply\s+plugin\s*:\s*)["'](org\.jetbrains\.kotlin\.android|org\.jetbrains\.kotlin\.multiplatform|kotlin-android)["']([^\r\n]*)"#).captures_iter(&clean) { + if outside[c.get(0).unwrap().start()] && !c[2].contains("apply false") {doc.applied_plugins.push(c[1].to_owned());} + } + for (method, url) in [ + ("google", "https://dl.google.com/dl/android/maven2"), + ("mavenCentral", "https://repo.maven.apache.org/maven2"), + ("gradlePluginPortal", "https://plugins.gradle.org/m2"), + ] { + if rx(&format!(r"\b{method}\s*\(\s*\)")) + .find_iter(&clean) + .any(|m| outside[m.start()]) + { + doc.repositories.push(url.to_owned()); + } + } + for c in rx(r#"\b(?:url\s*(?:=|\()?\s*(?:uri\s*\()?|maven\s*\()\s*["']([^"']+)["']"#) + .captures_iter(&clean) + { + if !outside[c.get(0).unwrap().start()] { + continue; + } + doc.repositories.push(c[1].to_owned()); + } + for c in + rx(r#"(?m)^\s*(?:(?:val|var|def)\s+|ext\.)?([A-Za-z_]\w*)\s*(?::\s*String)?\s*=\s*["']([^"']+)["']\s*;?\s*$"#) + .captures_iter(&clean) + { + if !outside[c.get(0).unwrap().start()] { + continue; + } + let m = c.get(2).unwrap(); + doc.definitions.push(Definition { + key: c[1].to_owned(), + value: m.as_str().to_owned(), + span: m.range(), + }); + } + for c in rx(r#"(?m)^\s*val\s+([A-Za-z_]\w*)\s*(?::\s*String)?\s+by\s+extra\s*\(\s*["']([^"']+)["']\s*\)\s*;?\s*$"#).captures_iter(&clean) { + if outside[c.get(0).unwrap().start()] { + let m = c.get(2).unwrap(); + doc.definitions.push(Definition { key: c[1].to_owned(), value: m.as_str().to_owned(), span: m.range() }); + } + } + // Only literal project-property bindings. No environment access, function + // evaluation, defaults or provider transformations are interpreted. + for c in rx(r#"(?m)^\s*(?:val|def)\s+([A-Za-z_]\w*)\s*(?::\s*(?:String|Int))?\s*=\s*(?:providers\.gradleProperty\(\s*["']([^"']+)["']\s*\)\.get\(\)|(?:project\.)?(?:property|findProperty)\(\s*["']([^"']+)["']\s*\))\s*(?:\.toInt\(\)|as\s+String)?\s*;?\s*$"#).captures_iter(&clean) { + if outside[c.get(0).unwrap().start()] { + let key = c.get(2).or_else(|| c.get(3)).unwrap().as_str(); + doc.property_bindings.push((c[1].to_owned(), key.to_owned())); + } + } + for c in rx(r#"\b(?:extra|ext)\s*\[\s*["']([^"']+)["']\s*\]\s*=\s*["']([^"']+)["']"#) + .captures_iter(&clean) + { + if !outside[c.get(0).unwrap().start()] { + continue; + } + let m = c.get(2).unwrap(); + doc.definitions.push(Definition { + key: c[1].to_owned(), + value: m.as_str().to_owned(), + span: m.range(), + }); + } + for c in rx(r"(?m)^\s*(?:val|var|def)\s+([A-Za-z_]\w*)\s*(?::\s*Int)?\s*=\s*([0-9]+)\s*;?\s*$") + .captures_iter(&clean) + { + if !outside[c.get(0).unwrap().start()] { + continue; + } + let m = c.get(2).unwrap(); + doc.definitions.push(Definition { + key: c[1].to_owned(), + value: m.as_str().to_owned(), + span: m.range(), + }); + } + // Literal Maven notation in dependency calls, not arbitrary quoted prose. + let mut recognized = Vec::new(); + for c in rx(r"(?m)\b(?:classpath|\w*[Ii]mplementation|implementation|api|compileOnly|runtimeOnly|annotationProcessor|kapt|ksp)\s*(?:\(\s*)?libs\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*\)?\s*;?\s*$").captures_iter(&clean) { + if outside[c.get(0).unwrap().start()] { + recognized.push(c.get(0).unwrap().start()); + doc.catalog_uses.push((c[1].to_owned(), Section::Maven, false)); + } + } + for c in rx(r"\balias\s*\(\s*libs\.plugins\.([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*\)([^\r\n]*)") + .captures_iter(&clean) + { + if outside[c.get(0).unwrap().start()] { + doc.catalog_uses.push(( + c[1].to_owned(), + Section::GradlePlugin, + !c[2].contains("apply false"), + )); + } + } + for c in rx(r#"\b(?:classpath|\w*[Ii]mplementation|implementation|api|\w*[Cc]ompileOnly|compileOnly|\w*[Rr]untimeOnly|runtimeOnly|annotationProcessor|kapt|ksp)\s*(?:\(\s*)?["']([^"'\r\n]+)["']"#).captures_iter(&clean) { + if !outside[c.get(0).unwrap().start()] { continue; } + recognized.push(c.get(0).unwrap().start()); + let m=c.get(1).unwrap(); + let parts:Vec<_>=m.as_str().split(':').collect(); + if parts.len()!=3 { doc.entry(m.as_str(),m.as_str(),None,Section::Maven); continue; } + let name=format!("{}:{}",parts[0],parts[1]); let version=parts[2]; + let tail=clean[c.get(0).unwrap().end()..].trim_start(); + if tail.starts_with('+') || tail.starts_with(".to") { doc.entry(&name,"dynamic concatenation",None,Section::Maven); continue; } + if let Some(key)=reference_key(version) { doc.references.push(Reference{name,key,section:Section::Maven}); } + else { doc.entry(&name,version,Some(m.end()-version.len()..m.end()),Section::Maven); } + } + for c in rx(r"\b(classpath|\w*[Ii]mplementation|implementation|api|compileOnly|runtimeOnly|annotationProcessor|kapt|ksp)\s*\(\s*([^\r\n]+)").captures_iter(&clean) { + if !outside[c.get(0).unwrap().start()] || recognized.contains(&c.get(0).unwrap().start()) {continue;} + doc.entry(&format!("gradle.{}",&c[1]),c[2].trim(),None,Section::Maven); + } + for c in rx(r#"\bid\s*(?:\(\s*)?["']([^"']+)["']\s*\)?\s*version\s*(?:\(\s*)?(?:["']([^"']+)["']|([A-Za-z_]\w*))"#).captures_iter(&clean) { + if !outside[c.get(0).unwrap().start()] { continue; } + let tail=clean[c.get(0).unwrap().end()..].trim_start(); + if tail.starts_with('+') {doc.entry(&c[1],"dynamic plugin version",None,Section::GradlePlugin);continue;} + if let Some(m)=c.get(2) { + if let Some(key)=reference_key(m.as_str()) {doc.references.push(Reference{name:c[1].to_owned(),key,section:Section::GradlePlugin});} + else {doc.entry(&c[1],m.as_str(),Some(m.range()),Section::GradlePlugin);} + } else {doc.references.push(Reference{name:c[1].to_owned(),key:c[3].to_owned(),section:Section::GradlePlugin});} + } + for c in rx(r"\b(compileSdkVersion|targetSdkVersion|compileSdk|targetSdk)\b\s*(?:=|\()?\s*([A-Za-z_][\w.]*|[0-9]+)\b([^\r\n]*)").captures_iter(&clean) { + if !outside[c.get(0).unwrap().start()] { continue; } + let m=c.get(2).unwrap(); let name=if c[1].starts_with("compile") {"android.compileSdk"} else {"android.targetSdk"}; + if c[3].trim_start().starts_with(['+', '-', '*', '/','.']) {doc.entry(name,"dynamic SDK expression",None,Section::AndroidSdk);} + else if m.as_str().bytes().all(|b| b.is_ascii_digit()) {doc.entry(name,m.as_str(),Some(m.range()),Section::AndroidSdk);} + else {doc.references.push(Reference{name:name.to_owned(),key:m.as_str().to_owned(),section:Section::AndroidSdk});} + } + // Track a shared minSdk source as a protected consumer. It has no lookup + // or update target, so shared-source validation preserves the device range. + for c in + rx(r"\b(?:minSdkVersion|minSdk)\b\s*(?:=|\()?\s*([A-Za-z_]\w*)\b").captures_iter(&clean) + { + if outside[c.get(0).unwrap().start()] { + doc.references.push(Reference { + name: "android.minSdk".into(), + key: c[1].to_owned(), + section: Section::AndroidSdk, + }); + } + } +} + +fn reference_key(value: &str) -> Option { + let key = value + .strip_prefix("${") + .and_then(|v| v.strip_suffix('}')) + .or_else(|| value.strip_prefix('$'))?; + rx(r"^[A-Za-z_]\w*$").is_match(key).then(|| key.to_owned()) +} + +fn parse_properties(doc: &mut Document) { + let clean = uncomment(&doc.text, false); + for c in rx(r"(?m)^\s*([\w.]+)\s*[=:]\s*([^\s]+)\s*$").captures_iter(&clean) { + let m = c.get(2).unwrap(); + doc.definitions.push(Definition { + key: c[1].to_owned(), + value: m.as_str().to_owned(), + span: m.range(), + }); + } +} + +fn parse_wrapper(doc: &mut Document) { + let clean = uncomment(&doc.text, false); + if let Some(c)=rx(r"(?m)^\s*distributionUrl\s*=\s*(https(?:\\)?:\/\/(?:services|downloads)\.gradle\.org/distributions/gradle-([^\s/]+)-(bin|all)\.zip)\s*$").captures(&clean) { + let m=c.get(2).unwrap();doc.entry("gradle",m.as_str(),Some(m.range()),Section::Toolchain);doc.distribution=Some(c[3].to_owned()); + } else if clean.contains("distributionUrl") {doc.entry("gradle","custom distribution URL",None,Section::Toolchain);} + if let Some(c) = rx(r"(?m)^\s*distributionSha256Sum\s*=\s*([^\s]+)").captures(&clean) { + doc.checksum = Some(c.get(1).unwrap().range()); + } +} + +fn toml_string_span(text: &str, span: Range, value: &str) -> Option> { + let raw = text.get(span.clone())?; + let quotes = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { + 3 + } else { + 1 + }; + let content = span.start + quotes..span.end.checked_sub(quotes)?; + (text.get(content.clone())? == value).then_some(content) +} + +#[allow(clippy::too_many_lines)] +fn parse_catalog(doc: &mut Document) -> Result<(), DcuError> { + let parsed = toml_edit::Document::parse(doc.text.clone()) + .map_err(|e| error("version catalog", e.to_string()))?; + if let Some(versions) = parsed + .get("versions") + .and_then(toml_edit::Item::as_table_like) + { + for (key, item) in versions.iter() { + if let (Some(value), Some(span)) = (item.as_str(), item.span()) { + let Some(span) = toml_string_span(&doc.text, span, value) else { + continue; + }; + doc.definitions.push(Definition { + key: format!("catalog:{key}"), + value: value.to_owned(), + span, + }); + } + } + } + for (table, section) in [ + ("libraries", Section::Maven), + ("plugins", Section::GradlePlugin), + ] { + if let Some(items) = parsed.get(table).and_then(toml_edit::Item::as_table_like) { + for (alias, item) in items.iter() { + if let Some(literal) = item.as_str() { + if let Some((name, version)) = literal.rsplit_once(':') + && let Some(span) = item.span() + { + let version_span = toml_string_span(&doc.text, span, literal) + .map(|s| s.end - version.len()..s.end); + doc.catalog_aliases.push(CatalogAlias { + accessor: alias.replace(['-', '_'], "."), + name: name.to_owned(), + section, + span: version_span.clone(), + }); + doc.entry(name, version, version_span, section); + } + continue; + } + let name = if section == Section::GradlePlugin { + item.get("id") + .and_then(toml_edit::Item::as_str) + .map(str::to_owned) + } else { + item.get("module") + .and_then(toml_edit::Item::as_str) + .map(str::to_owned) + .or_else(|| { + Some(format!( + "{}:{}", + item.get("group")?.as_str()?, + item.get("name")?.as_str()? + )) + }) + }; + let Some(name) = name else { + doc.entry(alias, "unsupported catalog notation", None, section); + continue; + }; + let version_span = item.get("version").and_then(|version| { + if let Some(value) = version.as_str() { + toml_string_span(&doc.text, version.span()?, value) + } else { + let key = format!("catalog:{}", version.get("ref")?.as_str()?); + doc.definitions + .iter() + .find(|d| d.key == key) + .map(|d| d.span.clone()) + } + }); + doc.catalog_aliases.push(CatalogAlias { + accessor: alias.replace(['-', '_'], "."), + name: name.clone(), + section, + span: version_span, + }); + if let Some(version) = item.get("version") { + if let Some(value) = version.as_str() { + if let Some(span) = version.span() { + doc.entry( + &name, + value, + toml_string_span(&doc.text, span, value), + section, + ); + } + } else if let Some(key) = version.get("ref").and_then(toml_edit::Item::as_str) { + doc.references.push(Reference { + name, + key: format!("catalog:{key}"), + section, + }); + } else { + doc.entry(&name, "rich version constraint", None, section); + } + } else { + doc.entry(&name, "no static version", None, section); + } + } + } + } + Ok(()) +} + +fn tool_name(name: &str) -> Option<&'static str> { + match name { + "node" | "nodejs" => Some("node"), + "rust" => Some("rust"), + "npm" => Some("npm"), + "pnpm" => Some("pnpm"), + "yarn" => Some("yarn"), + "bun" => Some("bun"), + "java" | "jdk" => Some("jdk"), + _ => None, + } +} + +#[allow(clippy::too_many_lines)] +fn parse_tools(doc: &mut Document) -> Result<(), DcuError> { + let filename = doc.path.file_name().and_then(|s| s.to_str()).unwrap_or(""); + let clean = uncomment(&doc.text, false); + match filename { + "package.json" => { + let json: serde_json::Value = serde_json::from_str(&doc.text) + .map_err(|e| error("package.json", e.to_string()))?; + if let Some(manager) = json + .get("packageManager") + .and_then(serde_json::Value::as_str) + && let Some((name, value)) = manager.split_once('@') + { + if matches!(name, "npm" | "pnpm" | "yarn" | "bun") { + // Exact JSON key and value; never an unrelated string containing packageManager. + let depth = json_depth(&clean); + let pattern = rx(r#""packageManager"\s*:\s*"([^"\\]*)""#); + let captures: Vec<_> = pattern + .captures_iter(&clean) + .filter(|c| depth[c.get(0).unwrap().start()] == 1 && &c[1] == manager) + .collect(); + if captures.len() == 1 { + let c = &captures[0]; + let m = c.get(1).unwrap(); + let (version, hash) = value + .split_once('+') + .map_or((value, None), |(v, h)| (v, Some(h))); + let start = m.start() + name.len() + 1; + doc.entry( + name, + version, + Some(start..start + version.len()), + Section::Toolchain, + ); + if let Some(hash) = hash { + if let Some((algo, _)) = hash.split_once('.') { + doc.entries.last_mut().unwrap().integrity = + Some((start + version.len() + 1..m.end(), algo.to_owned())); + if name == "bun" { + doc.entries.last_mut().unwrap().reason=Some("unsupported Bun packageManager integrity semantics; preserved".to_owned()); + } + } else { + doc.entries.last_mut().unwrap().reason = + Some("unsupported packageManager integrity format".to_owned()); + } + } + } else { + doc.entry(name, value, None, Section::Toolchain); + doc.entries.last_mut().unwrap().reason = Some( + "escaped or ambiguous packageManager JSON declaration; preserved" + .to_owned(), + ); + } + } else { + doc.entry(name, value, None, Section::Toolchain); + } + } + } + ".nvmrc" | ".node-version" | "rust-toolchain" => { + if let Some(c) = rx(r"(?m)^\s*([^\s]+)\s*$").captures(&clean) { + let m = c.get(1).unwrap(); + doc.entry( + if filename == "rust-toolchain" { + "rust" + } else { + "node" + }, + m.as_str(), + Some(m.range()), + Section::Toolchain, + ); + } + } + ".tool-versions" => { + for c in rx(r"(?m)^\s*([\w-]+)\s+([^\r\n]+?)\s*$").captures_iter(&clean) { + if let Some(name) = tool_name(&c[1]) { + let m = c.get(2).unwrap(); + doc.entry(name, m.as_str(), Some(m.range()), Section::Toolchain); + } + } + } + "rust-toolchain.toml" | "mise.toml" | ".mise.toml" => { + let parsed = toml_edit::Document::parse(doc.text.clone()) + .map_err(|e| error(filename, e.to_string()))?; + let table = if filename == "rust-toolchain.toml" { + "toolchain" + } else { + "tools" + }; + if let Some(items) = parsed.get(table).and_then(toml_edit::Item::as_table_like) { + for (key, item) in items.iter() { + let name = if table == "toolchain" { + (key == "channel").then_some("rust") + } else { + tool_name(key) + }; + if let Some(name) = name { + if let (Some(value), Some(span)) = (item.as_str(), item.span()) { + doc.entry( + name, + value, + toml_string_span(&doc.text, span, value), + Section::Toolchain, + ); + } else { + doc.entry( + name, + "multiple versions or complex tool configuration", + None, + Section::Toolchain, + ); + } + } + } + } + } + _ => {} + } + Ok(()) +} + +/// Load Gradle context without expanding the update allowlist. Other consumers +/// participate in compatibility/shared-source checks, not registry requests. +#[cfg(test)] +pub(crate) fn load( + manifests: &[ManifestRef], + root: &Path, +) -> Result, DcuError> { + load_with_discovery(manifests, root, false) +} + +fn gradle_kind(path: &Path) -> bool { + matches!( + ManifestKind::from_path(path), + Some( + ManifestKind::Gradle + | ManifestKind::GradleCatalog + | ManifestKind::GradleProperties + | ManifestKind::GradleWrapper + ) + ) +} + +fn build_root(path: &Path, root: &Path) -> PathBuf { + path.ancestors() + .skip(1) + .take_while(|p| p.starts_with(root)) + .find(|p| { + p.join("settings.gradle").is_file() + || p.join("settings.gradle.kts").is_file() + || p.join("gradle/wrapper/gradle-wrapper.properties").is_file() + }) + .unwrap_or(root) + .to_owned() +} + +fn read_document(path: &Path, selected: &[PathBuf]) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| DcuError::Io { + path: path.to_owned(), + source, + })?; + let mut doc = parse(&text, path)?; + if !selected.iter().any(|p| p == path) { + for entry in &mut doc.entries { + entry.requested = false; + } + } + Ok(doc) +} + +fn reference_source( + documents: &HashMap, + path: &Path, + root: &Path, + key: &str, +) -> Option<(PathBuf, Definition)> { + let doc = documents.get(path)?; + let bindings: Vec<_> = doc + .property_bindings + .iter() + .filter(|(local, _)| local == key) + .collect(); + if !bindings.is_empty() { + let clean = uncomment(&doc.text, true); + let outside = outside_strings(&clean); + let assignments = rx(&format!( + r"\b{}\s*(?::\s*\w+)?\s*(?:=|by\b)", + regex::escape(key) + )) + .find_iter(&clean) + .filter(|m| outside[m.start()]) + .count(); + if bindings.len() != 1 || assignments != 1 { + return None; + } + // A Gradle property binding reads properties, never an unrelated script + // variable with the same name. The nearest declaration wins. + for parent in path.ancestors().skip(1).take_while(|p| p.starts_with(root)) { + let candidate = parent.join("gradle.properties"); + if let Some(properties) = documents.get(&candidate) { + let defs: Vec<_> = properties + .definitions + .iter() + .filter(|d| d.key == bindings[0].1) + .collect(); + if defs.len() == 1 { + return Some((candidate, defs[0].clone())); + } + if !defs.is_empty() { + return None; + } + } + } + return None; + } + let candidates = std::iter::once(path.to_owned()).chain( + path.ancestors() + .skip(1) + .take_while(|p| p.starts_with(root)) + .flat_map(|p| { + [ + p.join("gradle.properties"), + p.join("build.gradle.kts"), + p.join("build.gradle"), + ] + }), + ); + for candidate in candidates { + let Some(doc) = documents.get(&candidate) else { + continue; + }; + let defs: Vec<_> = doc.definitions.iter().filter(|d| d.key == key).collect(); + let clean = uncomment(&doc.text, true); + let outside = outside_strings(&clean); + let assignments = rx(&format!( + r"\b{}\s*(?::\s*\w+)?\s*(?:=|by\b)", + regex::escape(key) + )) + .find_iter(&clean) + .filter(|m| outside[m.start()]) + .count(); + if defs.len() == 1 && assignments <= 1 { + return Some((candidate, defs[0].clone())); + } + // A local dynamic/ambiguous assignment shadows an ancestor pin too. + if !defs.is_empty() || assignments != 0 { + return None; + } + } + None +} + +/// Reuse a complete -d discovery; other invocations walk only build scopes and +/// exact ancestor context paths. Expand shared-source owners only when needed. +#[allow(clippy::too_many_lines)] +pub(crate) fn load_with_discovery( + manifests: &[ManifestRef], + root: &Path, + reuse_deep: bool, +) -> Result, DcuError> { + let mut documents = HashMap::new(); + let mut paths: Vec<_> = manifests + .iter() + .filter(|m| { + matches!( + m.kind, + ManifestKind::Gradle + | ManifestKind::GradleCatalog + | ManifestKind::GradleProperties + | ManifestKind::GradleWrapper + | ManifestKind::ToolVersions + | ManifestKind::PackageJson + ) + }) + .map(|m| m.path.clone()) + .collect(); + let selected = paths.clone(); + let mut roots: Vec<_> = selected + .iter() + .filter(|p| gradle_kind(p)) + .map(|p| build_root(p, root)) + .collect(); + roots.sort(); + roots.dedup(); + let mut scopes = roots.clone(); + for path in selected.iter().filter(|p| gradle_kind(p)) { + for parent in path.ancestors().skip(1).take_while(|p| p.starts_with(root)) { + for name in [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + ".tool-versions", + "mise.toml", + ".mise.toml", + ] { + scopes.push(parent.join(name)); + } + } + } + scopes.sort(); + scopes.dedup(); + let mut allowed: std::collections::HashSet<_> = if roots.is_empty() { + std::collections::HashSet::new() + } else if reuse_deep { + manifests.iter().map(|m| m.path.clone()).collect() + } else { + dependency_check_updates_core::Scanner::scan_scoped_checked(root, &scopes)? + .into_iter() + .map(|m| m.path) + .collect() + }; + if !roots.is_empty() { + for path in &allowed { + // Catalogs are parsed only when selected, in the same build, or + // explicitly inherited by selected consumers. Other builds' bad + // catalogs cannot interrupt a targeted query. + let same_build = roots.contains(&build_root(path, root)); + let ancestor_probe = scopes.iter().any(|p| p == path); + if ((gradle_kind(path) + && (ManifestKind::from_path(path) != Some(ManifestKind::GradleCatalog) + || same_build + || ancestor_probe)) + || (ManifestKind::from_path(path) == Some(ManifestKind::ToolVersions) + && ancestor_probe)) + && !paths.contains(path) + { + paths.push(path.clone()); + } + } + } + paths.sort(); + paths.dedup(); + for path in paths { + let doc = read_document(&path, &selected)?; + documents.insert(path, doc); + } + let mut shared_roots = Vec::new(); + for path in &selected { + let doc = &documents[path]; + let own = build_root(path, root); + for reference in &doc.references { + if let Some((source, _)) = reference_source(&documents, path, root, &reference.key) { + let owner = source.parent().unwrap_or(root); + if !owner.starts_with(&own) { + shared_roots.push(owner.to_owned()); + } + } + } + if !doc.catalog_uses.is_empty() + && let Some(catalog) = path + .ancestors() + .skip(1) + .take_while(|p| p.starts_with(root)) + .map(|p| p.join("gradle/libs.versions.toml")) + .find(|p| allowed.contains(p)) + { + let owner = catalog.parent().and_then(Path::parent).unwrap_or(root); + if !owner.starts_with(&own) { + shared_roots.push(owner.to_owned()); + } + } + } + shared_roots.sort(); + shared_roots.dedup(); + if !shared_roots.is_empty() { + if !reuse_deep { + allowed.extend( + dependency_check_updates_core::Scanner::scan_scoped_checked(root, &shared_roots)? + .into_iter() + .map(|m| m.path), + ); + } + for path in &allowed { + if gradle_kind(path) + && ManifestKind::from_path(path) != Some(ManifestKind::GradleCatalog) + && !documents.contains_key(path) + { + documents.insert(path.clone(), read_document(path, &selected)?); + } + } + } + // Read a descendant catalog only when a script actually uses it, e.g. + // Kotlin aliases in a nested build affected by an ancestor wrapper. + let mut catalogs = Vec::new(); + for doc in documents.values().filter(|d| !d.catalog_uses.is_empty()) { + if let Some(path) = doc + .path + .ancestors() + .skip(1) + .take_while(|p| p.starts_with(root)) + .map(|p| p.join("gradle/libs.versions.toml")) + .find(|p| allowed.contains(p)) + && !documents.contains_key(&path) + { + catalogs.push(path); + } + } + catalogs.sort(); + catalogs.dedup(); + for path in catalogs { + documents.insert(path.clone(), read_document(&path, &selected)?); + } + let snapshot = documents.clone(); + let mut context_paths: Vec<_> = snapshot.keys().cloned().collect(); + context_paths.sort(); + for path in context_paths { + let original = &snapshot[&path]; + let mut repos = Vec::new(); + let boundary = build_root(&path, root); + for parent in path + .ancestors() + .skip(1) + .take_while(|p| p.starts_with(&boundary)) + { + for name in [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + ] { + if let Some(d) = snapshot.get(&parent.join(name)) { + for r in &d.repositories { + if !repos.contains(r) { + repos.push(r.clone()); + } + } + } + } + } + // References already added by another consumer retain that consumer's + // repository context, not the source property's own (possibly empty). + for e in documents + .get_mut(&path) + .unwrap() + .entries + .iter_mut() + .take(original.entries.len()) + { + e.repositories.clone_from(&repos); + } + documents + .get_mut(&path) + .unwrap() + .repositories + .clone_from(&repos); + for reference in &original.references { + let source = reference_source(&snapshot, &path, root, &reference.key); + if let Some((source_path, definition)) = source { + let d = documents.get_mut(&source_path).unwrap(); + d.entry( + &reference.name, + &definition.value, + Some(definition.span), + reference.section, + ); + d.entries.last_mut().unwrap().requested = + selected.contains(&path) || selected.contains(&source_path); + if reference.name == "android.minSdk" + || matches!(definition.key.as_str(), "minSdk" | "minSdkVersion") + { + d.entries.last_mut().unwrap().reason = + Some("minSdk source is a supported-device range; preserved".to_owned()); + } + d.entries + .last_mut() + .unwrap() + .repositories + .clone_from(&repos); + let dep = d.entries.last().unwrap().dep.clone(); + documents + .get_mut(&path) + .unwrap() + .resolved_uses + .push((source_path, dep)); + } else { + let d = documents.get_mut(&path).unwrap(); + d.entry(&reference.name, &reference.key, None, reference.section); + d.entries.last_mut().unwrap().reason = + Some(format!("unresolved version reference: {}", reference.key)); + d.entries.last_mut().unwrap().requested = selected.contains(&path); + } + } + } + // Default `libs` accessors resolve to the nearest ancestor's catalog. Keep + // inline/version.ref source identity so another alias of the same artifact + // does not accidentally enter an explicit --manifest update allowlist. + let snapshot = documents.clone(); + let mut alias_paths: Vec<_> = snapshot.keys().collect(); + alias_paths.sort(); + let mut activated = std::collections::HashSet::new(); + for path in alias_paths { + let original = &snapshot[path]; + for (accessor, section, applied) in &original.catalog_uses { + let catalog = path + .ancestors() + .skip(1) + .take_while(|p| p.starts_with(root)) + .find_map(|p| snapshot.get(&p.join("gradle/libs.versions.toml"))); + let alias = catalog.and_then(|d| { + d.catalog_aliases + .iter() + .find(|a| a.accessor == *accessor && a.section == *section) + }); + if let (Some(catalog), Some(alias)) = (catalog, alias) { + let source_selected = selected.contains(&catalog.path); + for (index, base) in catalog.entries.iter().enumerate().filter(|(_, e)| { + e.dep.name == alias.name + && e.dep.section == alias.section + && e.span == alias.span + }) { + documents + .get_mut(path) + .unwrap() + .resolved_uses + .push((catalog.path.clone(), base.dep.clone())); + let requested = selected.contains(path) || source_selected; + let doc = documents.get_mut(&catalog.path).unwrap(); + if requested && activated.insert((catalog.path.clone(), index)) { + doc.entries[index].requested = true; + doc.entries[index] + .repositories + .clone_from(&original.repositories); + } else { + // Keep every consumer, including non-selected uses of + // the same alias. Its build's repository context matters. + let mut consumer = base.clone(); + consumer.requested = requested; + consumer.repositories.clone_from(&original.repositories); + doc.entries.push(consumer); + } + } + if *applied + && matches!( + alias.name.as_str(), + "org.jetbrains.kotlin.android" | "org.jetbrains.kotlin.multiplatform" + ) + { + documents + .get_mut(path) + .unwrap() + .applied_plugins + .push(alias.name.clone()); + } + } else if selected.contains(path) { + let d = documents.get_mut(path).unwrap(); + d.entry( + &format!("libs.{accessor}"), + "unresolved catalog alias", + None, + *section, + ); + } + } + } + for path in &selected { + if ManifestKind::from_path(path) != Some(ManifestKind::GradleProperties) { + continue; + } + let doc = documents.get_mut(path).expect("selected properties"); + for definition in doc.definitions.clone() { + let key = definition.key.to_ascii_lowercase(); + if key.contains("version") + && !key.starts_with("minsdk") + && numeric(&definition.value) + && !doc + .entries + .iter() + .any(|e| e.span.as_ref() == Some(&definition.span)) + { + doc.entry( + &format!("gradle.property.{}", definition.key), + &definition.value, + None, + Section::Maven, + ); + doc.entries.last_mut().unwrap().reason = Some( + "version property has no statically identified artifact consumer; preserved" + .into(), + ); + } + } + } + Ok(documents) +} + +pub(crate) fn guard_shared_versions( + documents: &HashMap, + plans: &mut crate::compatibility::Plans, +) -> HashMap { + type Consumers = Vec<(String, Option)>; + let mut errors = HashMap::new(); + for (path, doc) in documents { + let Some(updates) = plans.get_mut(path) else { + continue; + }; + let mut spans: HashMap<(usize, usize), Consumers> = HashMap::new(); + for e in &doc.entries { + if let Some(span) = &e.span { + let targets: Vec<_> = updates + .iter() + .filter(|u| { + e.requested + && u.name == e.dep.name + && u.from == e.dep.current_req + && u.section == e.dep.section + }) + .map(|u| u.to.clone()) + .collect(); + let consumers = spans.entry((span.start, span.end)).or_default(); + if targets.is_empty() { + consumers.push((e.dep.name.clone(), None)); + } else { + consumers.extend(targets.into_iter().map(|to| (e.dep.name.clone(), Some(to)))); + } + } + } + for entries in spans.values() { + let targets: Vec<_> = entries.iter().filter_map(|(_, v)| v.as_ref()).collect(); + // A rejected/filtered consumer sharing the value must also be preserved. + if !targets.is_empty() + && (targets.iter().any(|v| *v != targets[0]) + || entries.iter().any(|(_, v)| v.is_none())) + { + updates.retain(|u| !entries.iter().any(|(name, _)| name == &u.name)); + errors.insert(path.clone(),"shared version source has conflicting targets or filtered consumers; preserved".to_owned()); + } + } + } + errors +} + +#[cfg(test)] +mod tests { + #[test] + fn quoted_repository_and_definition_text_is_not_executable_gradle() { + let text = "val documentation = \"\"\"\nmaven { url = uri(\"https://fake.example\") }\nval fakeVersion = \"1.0\"\n\"\"\"\n"; + let doc = parse(text, Path::new("build.gradle.kts")).unwrap(); + assert!(doc.repositories.is_empty()); + assert!(doc.definitions.iter().all(|d| d.key != "fakeVersion")); + let unclosed = + "val documentation = \"\"\"\nimplementation(\"g:fake:1.0\")\n// still inside text\n"; + assert!( + parse(unclosed, Path::new("build.gradle.kts")) + .unwrap() + .entries + .is_empty() + ); + assert_eq!(uncomment(unclosed, true), unclosed); + } + + #[test] + fn absent_and_ambiguous_gradle_property_sources_are_never_guessed() { + let root = Path::new("project"); + let path = root.join("app/build.gradle.kts"); + let doc = parse("val pin = providers.gradleProperty(\"sharedVersion\").get()\nimplementation(\"g:a:$pin\")\n", &path).unwrap(); + assert_eq!(doc.property_bindings.len(), 1); + let mut docs = HashMap::from([(path.clone(), doc)]); + assert!(reference_source(&docs, &path, root, "pin").is_none()); + assert!(reference_source(&docs, &path, root, "unknown").is_none()); + let props = root.join("app/gradle.properties"); + docs.insert( + props.clone(), + parse("sharedVersion=1.0\nsharedVersion=2.0\n", &props).unwrap(), + ); + assert!(reference_source(&docs, &path, root, "pin").is_none()); + } + + #[test] + fn shared_source_discovery_loads_a_consumed_catalog_in_another_nested_build() { + use dependency_check_updates_core::ManifestRef; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for (file, text) in [ + ("build.gradle.kts", "val sharedVersion = \"1.0\"\n"), + ("a/settings.gradle.kts", "rootProject.name = \"a\"\n"), + ( + "a/build.gradle.kts", + "implementation(\"g:a:$sharedVersion\")\n", + ), + ("b/settings.gradle.kts", "rootProject.name = \"b\"\n"), + ("b/build.gradle.kts", "implementation(libs.shared)\n"), + ( + "b/gradle/libs.versions.toml", + "[libraries]\nshared = { module = \"g:a\", version = \"1.0\" }\n", + ), + ] { + let p = root.join(file); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + let selected = ManifestRef { + path: root.join("a/build.gradle.kts"), + kind: ManifestKind::Gradle, + }; + let docs = load_with_discovery(&[selected], root, false).unwrap(); + assert!(docs.contains_key(&root.join("b/gradle/libs.versions.toml"))); + assert_eq!( + docs[&root.join("b/build.gradle.kts")].resolved_uses[0] + .1 + .name, + "g:a" + ); + assert!(!docs[&root.join("b/gradle/libs.versions.toml")].entries[0].requested); + } + use super::*; + + #[test] + fn nested_comments_and_quoted_fake_declarations_are_not_dependencies() { + let text = r#"/* outer /* implementation("fake:inside:1.0") */ end */ +val text = "escaped \" quote" +val fake = ''' +ext['ver'] = '1.0' +val sdk = 35 +id('fake.plugin') version '1.0' +compileSdk = 35 +maven { url = 'https://fake.invalid' } +''' +"#; + let doc = parse(text, Path::new("build.gradle.kts")).unwrap(); + assert!(doc.entries.is_empty()); + assert!(doc.definitions.is_empty()); + assert!(doc.repositories.is_empty()); + assert!( + parse("irrelevant", Path::new("unknown.txt")) + .unwrap() + .entries + .is_empty() + ); + let mut doc = parse("", Path::new("unknown.txt")).unwrap(); + parse_tools(&mut doc).unwrap(); + } + + #[test] + fn gradle_repository_and_plugin_forms_have_explicit_static_boundaries() { + let text = r#"repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { url = repositoryUrl } + maven(repositoryUrl) + exclusiveContent {} +} +extra['v'] = '1.0' +id('literal.plugin') version '1.0' + suffix +id('interpolated.plugin') version "$v" +id('variable.plugin') version v +implementation('bad:notation') +"#; + let doc = parse(text, Path::new("build.gradle.kts")).unwrap(); + assert_eq!(doc.references.len(), 2); + assert_eq!(doc.definitions.len(), 1); + assert_eq!(doc.entries.len(), 2); + assert!(doc.entries.iter().all(|e| e.reason.is_some())); + assert!( + doc.repositories + .iter() + .any(|r| r.contains("content filters")) + ); + assert_eq!( + doc.repositories + .iter() + .filter(|r| r.contains("dynamic")) + .count(), + 2 + ); + assert_eq!( + doc.repositories + .iter() + .filter(|r| r.starts_with("https://")) + .count(), + 3 + ); + } + + #[test] + fn catalog_literal_rich_missing_and_escaped_versions_keep_their_meaning() { + let text = r#"[versions] +escaped = "1.\u0030" +[libraries] +literal = "group:artifact:1.0" +rich = { module = "group:rich", version = { strictly = "1.0" } } +missing = { module = "group:missing" } +unknown = { unsupported = "1.0" } +"#; + let doc = parse(text, Path::new("gradle/libs.versions.toml")).unwrap(); + assert_eq!(doc.entries.len(), 4); + assert!(doc.definitions.is_empty()); + assert!(doc.entries[0].span.is_some()); + assert!(doc.entries[1..].iter().all(|e| e.reason.is_some())); + assert_eq!(toml_string_span("'''1.0'''", 0..9, "1.0"), Some(3..6)); + assert!(toml_string_span("x", 0..20, "x").is_none()); + } + + #[test] + fn package_manager_hashes_escaping_unknown_tools_and_complex_mise_are_preserved() { + for text in [ + r#"{"packageManager":"bun@1.0.0+sha256.hash"}"#, + r#"{"packageManager":"pnpm@1.0.0+invalidhash"}"#, + r#"{"packageManager":"pnpm@1.\u0030.0"}"#, + r#"{"packageManager":"unsupported@1.0.0"}"#, + ] { + let doc = parse(text, Path::new("package.json")).unwrap(); + assert_eq!(doc.entries.len(), 1); + assert!(doc.entries[0].reason.is_some()); + } + let doc = parse( + "[tools]\nnode = ['20','22']\nunsupported = '1.0'\n", + Path::new("mise.toml"), + ) + .unwrap(); + assert_eq!(doc.entries.len(), 1); + assert!(doc.entries[0].reason.is_some()); + let doc = parse( + "distributionUrl=https://mirror.example/custom.zip\n", + Path::new("gradle/wrapper/gradle-wrapper.properties"), + ) + .unwrap(); + assert!(doc.entries[0].reason.is_some()); + } + + #[test] + fn patcher_rejects_conflicting_source_updates_and_skips_unsupported_declarations() { + let path = Path::new("build.gradle.kts"); + let text = "implementation(\"group:artifact:1.0\")\n"; + let doc = parse(text, path).unwrap(); + let update = PlannedUpdate { + name: "group:artifact".into(), + from: "1.0".into(), + to: "2.0".into(), + section: Section::Maven, + }; + let handler = ProjectHandler(doc.clone()); + assert!( + handler + .apply_updates(text, std::slice::from_ref(&update)) + .unwrap() + .contains(":2.0") + ); + let other = PlannedUpdate { + to: "3.0".into(), + ..update.clone() + }; + assert!( + doc.apply(text, &[update.clone(), other], Vec::new()) + .is_err() + ); + let mut unsupported = doc; + unsupported.entries[0].reason = Some("dynamic".into()); + assert_eq!( + unsupported.apply(text, &[update], Vec::new()).unwrap(), + text + ); + } +} diff --git a/crates/cli/src/project_tests.rs b/crates/cli/src/project_tests.rs new file mode 100644 index 0000000..c4cf9be --- /dev/null +++ b/crates/cli/src/project_tests.rs @@ -0,0 +1,2157 @@ +//! Deterministic regression tests through the real scan/resolve/patch pipeline. +use crate::{ + Cli, project, + run::{ManifestJob, execute_at, report_rows, run_at}, + tool_registry::{Endpoints, ToolRegistry}, +}; +use clap::Parser; +use dependency_check_updates_core::{DependencySection, ManifestKind, Scanner, TargetLevel}; +use std::fmt::Write; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path, path_regex}, +}; + +const ANDROID: &str = "apps/app/src-tauri/gen/android"; +const FIXTURES: &[(&str, &str)] = &[ + ( + "package.json", + include_str!("../tests/fixtures/tauri/package.json"), + ), + (".nvmrc", include_str!("../tests/fixtures/tauri/.nvmrc")), + ( + ".node-version", + include_str!("../tests/fixtures/tauri/.node-version"), + ), + ( + ".tool-versions", + include_str!("../tests/fixtures/tauri/.tool-versions"), + ), + ( + ".mise.toml", + include_str!("../tests/fixtures/tauri/.mise.toml"), + ), + ( + "mise.toml", + include_str!("../tests/fixtures/tauri/mise.toml"), + ), + ( + "rust-toolchain.toml", + include_str!("../tests/fixtures/tauri/rust-toolchain.toml"), + ), + ( + "gradle/wrapper/gradle-wrapper.properties", + include_str!("../tests/fixtures/tauri/gradle/wrapper/gradle-wrapper.properties"), + ), + ( + "gradle/libs.versions.toml", + include_str!("../tests/fixtures/tauri/gradle/libs.versions.toml"), + ), + ( + "settings.gradle.kts", + include_str!("../tests/fixtures/tauri/settings.gradle.kts"), + ), + ( + "apps/app/src-tauri/gen/android/settings.gradle.kts", + include_str!("../tests/fixtures/tauri/apps/app/src-tauri/gen/android/settings.gradle.kts"), + ), + ( + "apps/app/src-tauri/gen/android/build.gradle.kts", + include_str!("../tests/fixtures/tauri/apps/app/src-tauri/gen/android/build.gradle.kts"), + ), + ( + "apps/app/src-tauri/gen/android/buildSrc/build.gradle.kts", + include_str!( + "../tests/fixtures/tauri/apps/app/src-tauri/gen/android/buildSrc/build.gradle.kts" + ), + ), + ( + "apps/app/src-tauri/gen/android/gradle.properties", + include_str!("../tests/fixtures/tauri/apps/app/src-tauri/gen/android/gradle.properties"), + ), + ( + "apps/app/src-tauri/gen/android/app/build.gradle.kts", + include_str!("../tests/fixtures/tauri/apps/app/src-tauri/gen/android/app/build.gradle.kts"), + ), + ( + "Cargo.toml", + include_str!("../tests/fixtures/tauri/Cargo.toml.fixture"), + ), + ( + "pyproject.toml", + include_str!("../tests/fixtures/tauri/pyproject.toml"), + ), + ( + "Dockerfile", + include_str!("../tests/fixtures/tauri/Dockerfile"), + ), + ( + ".github/workflows/CI.yml", + include_str!("../tests/fixtures/tauri/.github/workflows/CI.yml"), + ), +]; + +fn write(root: &Path, name: &str, text: &str) { + let p = root.join(name); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); +} +fn read(root: &Path, name: &str) -> String { + std::fs::read_to_string(root.join(name)).unwrap() +} +fn fixture(server: &MockServer) -> TempDir { + let tmp = TempDir::new().unwrap(); + for (name, text) in FIXTURES { + write( + tmp.path(), + name, + &text + .replace("google()", &format!("maven(\"{}\")", server.uri())) + .replace("mavenCentral()", "") + .replace("gradlePluginPortal()", "") + .replace("\r\n", "\n") + .replace('\n', "\r\n"), + ); + } + tmp +} +async fn metadata(server: &MockServer, p: &str, body: &str) { + Mock::given(method("GET")) + .and(path(p)) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} +async fn registry() -> (MockServer, ToolRegistry) { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + for (p, versions) in [ + ( + "/com/android/tools/build/gradle/maven-metadata.xml", + &["8.5.1", "8.5.2", "8.10.1"][..], + ), + ( + "/org/jetbrains/kotlin/kotlin-gradle-plugin/maven-metadata.xml", + &["1.9.25", "2.2.10"][..], + ), + ( + "/androidx/webkit/webkit/maven-metadata.xml", + &["1.6.1", "1.6.2", "1.12.1"][..], + ), + ( + "/androidx/appcompat/appcompat/maven-metadata.xml", + &["1.6.1", "1.7.0"][..], + ), + ( + "/com/google/android/material/material/maven-metadata.xml", + &["1.8.0", "1.12.0"][..], + ), + ("/junit/junit/maven-metadata.xml", &["4.13.2", "4.13.3"][..]), + ( + "/androidx/test/ext/junit/maven-metadata.xml", + &["1.1.4", "1.2.1"][..], + ), + ( + "/androidx/test/espresso/espresso-core/maven-metadata.xml", + &["3.5.0", "3.6.1"][..], + ), + ] { + let body = format!( + "{}", + versions.iter().fold(String::new(), |mut s, v| { + write!(s, "{v}").unwrap(); + s + }) + ); + metadata(&server, p, &body).await; + } + metadata(&server,"/gradle",r#"[{"version":"8.9","snapshot":false,"buildTime":"20240101"},{"version":"8.13","snapshot":false,"buildTime":"20250101"}]"#).await; + metadata( + &server, + "/distributions/gradle-8.13-bin.zip.sha256", + &"b".repeat(64), + ) + .await; + metadata( + &server, + "/distributions/gradle-8.13-all.zip.sha256", + &"c".repeat(64), + ) + .await; + metadata(&server,"/node",r#"[{"version":"v20.1.0","date":"2023-01-01","lts":false},{"version":"v22.15.1","date":"2025-01-01","lts":"Jod"}]"#).await; + metadata( + &server, + "/rust", + "[pkg.rust]\nversion = \"1.86.0 (hash date)\"\n", + ) + .await; + metadata(&server, "/jdk", r#"{"versions":[{"semver":"17.0.0+1"}]}"#).await; + metadata( + &server, + "/repos/oven-sh/bun/releases", + r#"[{"tag_name":"bun-v1.2.7","draft":false,"published_at":"2025-01-01"}]"#, + ) + .await; + metadata(&server,"/android",r#"111"#).await; + for (name, current, latest) in [ + ("pnpm", "10.12.1", "10.13.1"), + ("yarn", "1.22.20", "1.22.22"), + ("npm", "10.0.0", "11.0.0"), + ] { + metadata(&server,&format!("/{name}"),&format!(r#"{{"dist-tags":{{"latest":"{latest}"}},"versions":{{"{current}":{{}},"{latest}":{{}}}}}}"#)).await; + } + let uri = server.uri(); + let registry = ToolRegistry::with_endpoints(Endpoints { + gradle: format!("{uri}/gradle"), + distributions: format!("{uri}/distributions"), + node: format!("{uri}/node"), + rust: format!("{uri}/rust"), + github: uri.clone(), + android: format!("{uri}/android"), + jdk: format!("{uri}/jdk"), + npm: uri, + yarn: format!("{}/yarn-tags", server.uri()), + yarn_downloads: server.uri(), + ..Endpoints::default() + }); + (server, registry) +} + +#[tokio::test] +async fn integrity_failure_blocks_only_its_own_declaration_and_strict_mode_aborts() { + let (server, registry) = registry().await; + Mock::given(path("/pnpm/10.13.1")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + let original = r#"{"packageManager":"pnpm@10.12.1+sha512.old","dependencies":{"npm":"^10.0.0","pnpm":"10.12.1","yarn":"1.22.22"}}"#; + for strict in [false, true] { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "package.json", original); + let mut cli = Cli::parse_from(["dcu", "-u"]); + cli.fail_on_incomplete = strict; + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + let rows: Vec<_> = report + .items + .iter() + .filter_map(|r| { + if let crate::report::Item::Project(r) = r { + Some(r) + } else { + None + } + }) + .collect(); + let tool = rows + .iter() + .find(|r| r.name == "pnpm" && r.section == "toolchain") + .unwrap(); + assert_eq!(tool.status, crate::report::Status::Blocked); + assert!(tool.latest.is_some()); + assert_eq!( + rows.iter().find(|r| r.name == "yarn").unwrap().status, + crate::report::Status::Current + ); + assert_eq!( + rows.iter() + .find(|r| r.name == "pnpm" && r.section == "dependencies") + .unwrap() + .status, + crate::report::Status::Update + ); + let library = rows.iter().find(|r| r.name == "npm").unwrap(); + assert_eq!(library.status, crate::report::Status::Update); + assert_eq!(library.updated, !strict); + let updated = read(tmp.path(), "package.json"); + assert!(updated.contains("pnpm@10.12.1+sha512.old")); + if strict { + assert_eq!(updated, original); + assert_eq!(report.exit_code(&cli), 2); + } else { + assert!(updated.contains("^11.0.0")); + assert_eq!(report.exit_code(&cli), 0); + } + } +} + +#[tokio::test] +async fn compatible_suggestions_preserve_latest_and_apply_only_when_selected() { + let (server, registry) = registry().await; + // Latest AGP 9 requires an explicit plugin migration; the supported 8.10.0 + // alternative works with Kotlin 2.2.10 and Gradle 8.13. + Mock::given(path("/com/android/tools/build/gradle/maven-metadata.xml")).respond_with(ResponseTemplate::new(200).set_body_string("8.5.18.10.09.0.0")).with_priority(1).mount(&server).await; + let tmp = TempDir::new().unwrap(); + let build = format!( + "repositories {{ maven {{ url = uri(\"{}\") }} }}\nplugins {{ id(\"com.android.application\") version \"8.5.1\"; id(\"org.jetbrains.kotlin.android\") version \"1.9.25\" }}\n", + server.uri() + ); + write(tmp.path(), "build.gradle.kts", &build); + write( + tmp.path(), + "settings.gradle.kts", + "rootProject.name = \"compatible\"\n", + ); + write(tmp.path(), ".tool-versions", "java 17\n"); + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\ndistributionSha256Sum=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + ); + let query = execute_at( + &Cli::parse_from(["dcu", "-d"]), + tmp.path(), + ®istry, + false, + ) + .await + .unwrap(); + assert_eq!(read(tmp.path(), "build.gradle.kts"), build); + let json = query.json(crate::cli::OutputFormat::JsonReport).unwrap(); + let agp = json["items"] + .as_array() + .unwrap() + .iter() + .find(|r| r["name"] == "com.android.application") + .unwrap(); + assert_eq!(agp["latest"], "9.0.0"); + assert_eq!(agp["selected"], "9.0.0"); + assert_eq!(agp["compatible"], "8.10.0"); + assert!(agp["to"].is_null()); + let cli = Cli::parse_from(["dcu", "-d", "--compatible", "-u", "--fail-on-incomplete"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!( + report.exit_code(&cli), + 0, + "{}", + report.json(crate::cli::OutputFormat::JsonReport).unwrap() + ); + let applied = read(tmp.path(), "build.gradle.kts"); + assert!(applied.contains("8.10.0")); + assert!(applied.contains("2.2.10")); + let wrapper = read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"); + assert!(wrapper.contains("gradle-8.13-bin.zip")); + assert!(wrapper.contains(&"b".repeat(64))); + assert_eq!(read(tmp.path(), ".tool-versions"), "java 17\n"); +} + +#[tokio::test] +async fn compatible_tools_keep_short_pins_filters_targets_and_unknown_states() { + let (server, registry) = registry().await; + Mock::given(path("/gradle")).respond_with(ResponseTemplate::new(200).set_body_string(r#"[{"version":"8.9","snapshot":false},{"version":"8.13","snapshot":false},{"version":"9.8","snapshot":false}]"#)).with_priority(1).mount(&server).await; + Mock::given(path("/jdk")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({"versions":[{"semver":"17.0.15+6"},{"semver":"27.0.1+4"}]}), + )) + .with_priority(1) + .mount(&server) + .await; + for args in [ + vec!["dcu", "-d", "-u", "--compatible"], + vec!["dcu", "-d", "-u", "--compatible", "--target", "minor"], + vec!["dcu", "-d", "-u", "--compatible", "--reject", "jdk"], + vec!["dcu", "-d", "-u", "--compatible", "--target", "patch"], + ] { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + "rootProject.name = \"plain\"\n", + ); + write( + tmp.path(), + ".tool-versions", + "java 17 # keep line precision\r\n", + ); + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\r\n", + ); + let cli = Cli::parse_from(args.clone()); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + let expected = + if args.contains(&"minor") || args.contains(&"patch") || args.contains(&"--reject") { + "17" + } else { + "27" + }; + assert_eq!( + read(tmp.path(), ".tool-versions"), + format!("java {expected} # keep line precision\r\n") + ); + let gradle = if args.contains(&"patch") { + "8.9" + } else if args.contains(&"minor") { + "8.13" + } else { + "9.8" + }; + assert!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties") + .contains(&format!("gradle-{gradle}-bin.zip")), + "{}", + report.json(crate::cli::OutputFormat::JsonReport).unwrap() + ); + } + let tmp = TempDir::new().unwrap(); + let wrapper = + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n"; + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + wrapper, + ); + let report = execute_at( + &Cli::parse_from(["dcu", "-d", "-u", "--compatible"]), + tmp.path(), + ®istry, + false, + ) + .await + .unwrap(); + assert_eq!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"), + wrapper + ); + let row = report + .items + .iter() + .find_map(|r| { + if let crate::report::Item::Project(r) = r { + Some(r) + } else { + None + } + }) + .unwrap(); + assert!(row.compatible.is_none()); + assert_eq!(row.status, crate::report::Status::Unverified); + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + ".tool-versions", + "java 17\nnode lts/*\nrust stable\n", + ); + let cli = Cli::parse_from(["dcu", "-u", "--compatible", "--fail-on-incomplete"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert_eq!( + read(tmp.path(), ".tool-versions"), + "java 27\nnode lts/*\nrust stable\n" + ); + assert!(report.compatibility_rules.is_empty()); +} + +#[tokio::test] +async fn compatible_selection_isolates_unconnected_builds() { + let (_server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + for build in ["a", "b"] { + write( + tmp.path(), + &format!("apps/{build}/settings.gradle.kts"), + "rootProject.name = \"separate\"\n", + ); + write( + tmp.path(), + &format!("apps/{build}/gradle/wrapper/gradle-wrapper.properties"), + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n", + ); + } + write(tmp.path(), "apps/a/.tool-versions", "java 17\n"); + let cli = Cli::parse_from(["dcu", "-d", "-u", "--compatible"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert!( + read( + tmp.path(), + "apps/a/gradle/wrapper/gradle-wrapper.properties" + ) + .contains("gradle-8.13-bin.zip") + ); + assert!( + read( + tmp.path(), + "apps/b/gradle/wrapper/gradle-wrapper.properties" + ) + .contains("gradle-8.9-bin.zip") + ); + assert!(report.incomplete()); +} + +#[tokio::test] +async fn stale_and_future_rule_sources_are_reported_and_cannot_approve_updates() { + let (_server, registry) = registry().await; + for date in ["1900-01-01", "9999-12-31"] { + let tmp = TempDir::new().unwrap(); + let wrapper = + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n"; + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + wrapper, + ); + write(tmp.path(), ".tool-versions", "java 17\n"); + write(tmp.path(), "rules.json", &serde_json::json!({"schemaVersion":1,"verifiedAt":date,"sources":["https://docs.gradle.org/current/userguide/compatibility.html"]}).to_string()); + let cli = Cli::parse_from([ + "dcu", + "-d", + "-u", + "--compatible", + "--strict-compatibility", + "--fail-on-incomplete", + "--compatibility-file", + "rules.json", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 2); + assert_eq!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"), + wrapper + ); + let json = report.json(crate::cli::OutputFormat::JsonReport).unwrap(); + let provenance = &json["compatibilityRules"][1]; + assert_eq!(provenance["verifiedAt"], date); + assert_eq!(provenance["userSupplied"], true); + assert_eq!(provenance["stale"], date == "1900-01-01"); + assert_eq!(provenance["future"], date == "9999-12-31"); + assert!( + report + .diagnostics + .iter() + .any(|d| d.code == "compatibility-rules-date") + ); + } +} + +#[tokio::test] +async fn tauri_scan_check_and_upgrade_all_sources() { + let (server, registry) = registry().await; + let tmp = fixture(&server); + let manifests = Scanner::scan_deep(tmp.path()); + assert_eq!(manifests.len(), FIXTURES.len()); + for name in [".nvmrc", ".node-version", ".tool-versions", ".mise.toml"] { + assert!(manifests.iter().any(|m| m.path == tmp.path().join(name))); + } + let before: Vec<_> = FIXTURES + .iter() + .map(|(p, _)| (*p, read(tmp.path(), p))) + .collect(); + assert!( + run_at( + &Cli::parse_from(["dcu", "-d"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + for (path, text) in &before { + assert_eq!(&read(tmp.path(), path), text, "read-only changed {path}"); + } + assert!( + run_at( + &Cli::parse_from(["dcu", "-d", "-u", "--format", "json"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + for file in [ + format!("{ANDROID}/build.gradle.kts"), + format!("{ANDROID}/buildSrc/build.gradle.kts"), + ] { + let text = read(tmp.path(), &file); + assert!( + text.contains("com.android.tools.build:gradle:8.10.1"), + "{text}" + ); + assert!(!text.contains("com.android.tools.build:gradle:8.5.1")); + } + let app = read(tmp.path(), &format!("{ANDROID}/app/build.gradle.kts")); + assert!(app.contains("androidx.webkit:webkit:1.12.1")); + assert!(app.contains("$webkitVersion")); + assert!(app.contains("compileSdk = 36")); + assert!(app.contains("targetSdk = 36")); + assert!(app.contains("minSdk = 24")); + assert!(app.contains("${lookupVersion()}")); + assert!( + read(tmp.path(), &format!("{ANDROID}/gradle.properties")) + .contains("webkitVersion = 1.12.1") + ); + let catalog = read(tmp.path(), "gradle/libs.versions.toml"); + assert!(catalog.contains("webkit = \"1.12.1\" # Shared version source")); + assert!(catalog.contains("agp = \"8.10.1\"")); + assert!(catalog.contains("kotlin = \"2.2.10\"")); + let wrapper = read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"); + assert!(wrapper.contains("https\\://services.gradle.org/distributions/gradle-8.13-bin.zip")); + assert!(wrapper.contains(&format!("distributionSha256Sum={}", "b".repeat(64)))); + assert_eq!(read(tmp.path(), ".nvmrc"), "v22.15.1\r\n"); + assert!(read(tmp.path(), "package.json").contains("pnpm@10.13.1")); + assert!(read(tmp.path(), "package.json").contains("\">=20\"")); + assert!(read(tmp.path(), "mise.toml").contains("rust = \"stable\"")); + for (path, text) in &before { + let new = read(tmp.path(), path); + assert_eq!(new.matches("\r\n").count(), text.matches("\r\n").count()); + } + let build = read(tmp.path(), &format!("{ANDROID}/build.gradle.kts")); + assert!(build.contains("fake.comment:dependency:1.0.0")); + assert!(build.contains("fake.block:dependency:1.0.0")); +} + +#[tokio::test] +async fn manifest_filter_patch_reject_and_reference_source() { + let (server, registry) = registry().await; + let tmp = fixture(&server); + let app = format!("{ANDROID}/app/build.gradle.kts"); + let before = read(tmp.path(), &app); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--manifest", + &app, + "webkit", + "--target", + "patch", + ]); + assert!(run_at(&cli, tmp.path(), ®istry, false).await.unwrap()); + assert!(read(tmp.path(), &app).contains("androidx.webkit:webkit:1.6.2")); + assert!( + read(tmp.path(), &format!("{ANDROID}/gradle.properties")).contains("webkitVersion = 1.6.2") + ); + assert!(read(tmp.path(), &app).contains("appcompat:1.6.1")); + assert_eq!( + read(tmp.path(), &app).matches("minSdk = 24").count(), + before.matches("minSdk = 24").count() + ); + let unchanged = read(tmp.path(), &app); + assert!( + !run_at( + &Cli::parse_from([ + "dcu", + "-u", + "--manifest", + &app, + "webkit", + "--reject", + "webkit" + ]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), &app), unchanged); +} + +#[tokio::test] +async fn wrapper_all_and_failed_checksum_preserve_files() { + let (server, registry) = registry().await; + let wrapper = "gradle/wrapper/gradle-wrapper.properties"; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + wrapper, + include_str!("../tests/fixtures/tauri/gradle/wrapper/gradle-wrapper.properties"), + ); + write( + tmp.path(), + wrapper, + &read(tmp.path(), wrapper).replace("-bin.zip", "-all.zip"), + ); + assert!( + run_at( + &Cli::parse_from(["dcu", "-u", "--manifest", wrapper]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert!(read(tmp.path(), wrapper).contains("-8.13-all.zip")); + assert!(read(tmp.path(), wrapper).contains(&"c".repeat(64))); + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + wrapper, + include_str!("../tests/fixtures/tauri/gradle/wrapper/gradle-wrapper.properties"), + ); + let before = read(tmp.path(), wrapper); + Mock::given(path("/distributions/gradle-8.13-bin.zip.sha256")) + .respond_with(ResponseTemplate::new(503)) + .with_priority(1) + .mount(&server) + .await; + assert!( + !run_at( + &Cli::parse_from(["dcu", "-u", "--manifest", wrapper]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), wrapper), before); +} + +#[tokio::test] +async fn conflicts_and_repository_failures_keep_original() { + let (server, registry) = registry().await; + let tmp = fixture(&server); + metadata( + &server, + "/gradle-conflict", + r#"[{"version":"8.9","snapshot":false}]"#, + ) + .await; + let mut registry = registry; + registry.endpoints.gradle = format!("{}/gradle-conflict", server.uri()); + let build = format!("{ANDROID}/build.gradle.kts"); + let before = read(tmp.path(), &build); + run_at( + &Cli::parse_from(["dcu", "-d", "-u"]), + tmp.path(), + ®istry, + false, + ) + .await + .unwrap(); + assert_eq!(read(tmp.path(), &build), before); + let tmp = fixture(&server); + let app = format!("{ANDROID}/app/build.gradle.kts"); + let before = read(tmp.path(), &app); + Mock::given(path_regex("/androidx/webkit/.*")) + .respond_with(ResponseTemplate::new(503)) + .with_priority(1) + .mount(&server) + .await; + assert!( + !run_at( + &Cli::parse_from(["dcu", "-u", "--manifest", &app, "webkit"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), &app), before); + let entry = project::Entry { + requested: true, + dep: dependency_check_updates_core::DependencySpec { + name: "example:private".into(), + current_req: "1.0.0".into(), + section: DependencySection::Maven, + path_version: None, + }, + span: None, + reason: None, + repositories: vec!["https://private.example/maven".into()], + integrity: None, + }; + assert!( + registry + .resolve(&entry, TargetLevel::Latest) + .await + .unwrap_err() + .to_string() + .contains("private or unsupported repository") + ); +} + +#[test] +fn parser_handles_comments_channels_and_dynamic_expressions() { + let text = "// implementation(\"bad:fake:1.0\")\n/* implementation(\"bad:block:1.0\") */\nval prose = \"implementation('bad:string:1.0')\"\nval version = \"1.0.0\"\nimplementation(\"good:artifact:$version\")\nimplementation(\"good:artifact:1.0.0\" + suffix)\nimplementation(computeDependency())\nid(\"example.plugin\") version \"1.0.0\"\ncompileSdk = 35 + offset\nminSdk = 24\n"; + let doc = project::parse(text, Path::new("build.gradle.kts")).unwrap(); + assert!(!doc.entries.iter().any(|e| e.dep.name.starts_with("bad"))); + assert!(!doc.entries.iter().any(|e| e.dep.name.contains("minSdk"))); + assert!(doc.entries.iter().filter(|e| e.reason.is_some()).count() >= 3); + for filename in [".nvmrc", "rust-toolchain"] { + let doc = project::parse("stable\r\n", Path::new(filename)).unwrap(); + assert!( + doc.entries[0] + .reason + .as_ref() + .unwrap() + .starts_with("channel") + ); + assert_eq!(doc.apply("stable\r\n", &[], vec![]).unwrap(), "stable\r\n"); + } +} + +#[test] +fn json_reports_failed_and_unsupported_without_claiming_current() { + let document = project::parse( + "implementation(\"g:a:${getVersion()}\")\n", + Path::new("build.gradle.kts"), + ) + .unwrap(); + let job = ManifestJob { + manifest_ref: dependency_check_updates_core::ManifestRef { + path: PathBuf::from("build.gradle.kts"), + kind: ManifestKind::Gradle, + }, + display_path: "build.gradle.kts".into(), + text: document.text.clone(), + handler: Box::new(project::ProjectHandler(document.clone())), + deps: document.dependencies(), + document: Some(document), + }; + let rows = report_rows( + &job, + &[(0, Err(project::error("g:a", "dynamic expression")))], + &[], + None, + None, + false, + ); + let json = serde_json::to_string(&rows).unwrap(); + let decoded: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded[0]["status"], "unsupported"); + assert!(!decoded[0]["updated"].as_bool().unwrap()); + assert!(decoded[0]["latest"].is_null()); +} + +#[tokio::test] +async fn package_manager_hashes_use_the_correct_release_bytes() { + let (server, registry) = registry().await; + metadata( + &server, + "/yarn-tags", + r#"{"tags":["4.0.0","4.1.0"],"aliases":{"stable":"4.1.0"}}"#, + ) + .await; + metadata(&server, "/4.1.0/packages/yarnpkg-cli/bin/yarn.js", "abc").await; + for (name, latest) in [("pnpm", "10.13.1"), ("npm", "11.0.0"), ("yarn", "1.22.22")] { + metadata( + &server, + &format!("/{name}/{latest}"), + &format!( + r#"{{"dist":{{"tarball":"{}/tarballs/{name}"}}}}"#, + server.uri() + ), + ) + .await; + metadata(&server, &format!("/tarballs/{name}"), "abc").await; + } + for (manager, old, latest, algo, digest) in [ + ( + "pnpm", + "10.12.1", + "10.13.1", + "sha256", + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + "npm", + "10.0.0", + "11.0.0", + "sha1", + "a9993e364706816aba3e25717850c26c9cd0d89d", + ), + ( + "yarn", + "1.22.20", + "1.22.22", + "sha512", + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + ), + ( + "yarn", + "4.0.0", + "4.1.0", + "sha224", + "23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7", + ), + ] { + let tmp = TempDir::new().unwrap(); + let original = format!( + "{{\r\n \"packageManager\": \"{manager}@{old}+{algo}.oldhash\",\r\n \"engines\": {{\"node\":\">=20\"}}\r\n}}\r\n" + ); + write(tmp.path(), "package.json", &original); + let cli = Cli::parse_from(["dcu", "--manifest", "package.json"]); + assert!(run_at(&cli, tmp.path(), ®istry, false).await.unwrap()); + assert_eq!(read(tmp.path(), "package.json"), original); + let cli = Cli::parse_from(["dcu", "-u", "--manifest", "package.json"]); + assert!(run_at(&cli, tmp.path(), ®istry, false).await.unwrap()); + assert_eq!( + read(tmp.path(), "package.json"), + original.replace( + &format!("{old}+{algo}.oldhash"), + &format!("{latest}+{algo}.{digest}") + ) + ); + } + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"packageManager":"bun@1.1.0"}"#, + ); + assert!( + run_at( + &Cli::parse_from(["dcu", "-u"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert!(read(tmp.path(), "package.json").contains("bun@1.2.7")); +} + +#[tokio::test] +async fn plugin_markers_groovy_and_sdk_variable_sources() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + metadata( + &server, + "/example/plugin/example.plugin.gradle.plugin/maven-metadata.xml", + "1.0.01.1.0", + ) + .await; + write( + tmp.path(), + "build.gradle", + &format!( + "repositories {{ maven {{ url '{}' }} }}\r\nplugins {{ id 'example.plugin' version '1.0.0' }}\r\nval sdkVersion = 35\r\nandroid {{ compileSdk = sdkVersion\r\n targetSdkVersion 35\r\n minSdkVersion 24\r\n}}\r\n", + server.uri() + ), + ); + assert!( + run_at( + &Cli::parse_from(["dcu", "-u"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + let text = read(tmp.path(), "build.gradle"); + assert!(text.contains("version '1.1.0'")); + assert!(text.contains("val sdkVersion = 36")); + assert!(text.contains("compileSdk = sdkVersion")); + assert!(text.contains("targetSdkVersion 36")); + assert!(text.contains("minSdkVersion 24")); +} + +#[tokio::test] +async fn shared_catalog_versions_do_not_modify_filtered_consumers() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}", server.uri()), + ); + let catalog = "[versions]\nshared = '1.6.1' # shared source\n[libraries]\nwebkit = { module = 'androidx.webkit:webkit', version.ref = 'shared' }\nappcompat = { module = 'androidx.appcompat:appcompat', version.ref = 'shared' }\n"; + write(tmp.path(), "gradle/libs.versions.toml", catalog); + assert!( + !run_at( + &Cli::parse_from(["dcu", "-u", "webkit"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), "gradle/libs.versions.toml"), catalog); + assert!( + !run_at( + &Cli::parse_from(["dcu", "-u"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), "gradle/libs.versions.toml"), catalog); +} + +#[tokio::test] +async fn explicit_build_manifest_updates_catalog_alias_source_only() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + &format!( + "dependencyResolutionManagement {{ repositories {{ maven(\"{}\") }} }}\n", + server.uri() + ), + ); + let catalog = "[versions]\nwebkit = '1.6.1' # original source\n[libraries]\nandroid-webkit = { module = 'androidx.webkit:webkit', version.ref = 'webkit' }\nother = { module = 'androidx.appcompat:appcompat', version = '1.6.1' }\n[plugins]\nkotlin-android = { id = 'org.jetbrains.kotlin.android', version = '1.9.25' }\n"; + write(tmp.path(), "gradle/libs.versions.toml", catalog); + let build = "plugins {\n alias(libs.plugins.kotlin.android)\n}\ndependencies {\n implementation(libs.android.webkit)\n // implementation(libs.other)\n}\n"; + write(tmp.path(), "app/build.gradle.kts", build); + let manifests = [dependency_check_updates_core::ManifestRef { + path: tmp.path().join("app/build.gradle.kts"), + kind: ManifestKind::Gradle, + }]; + let docs = project::load(&manifests, tmp.path()).unwrap(); + assert!( + docs[&tmp.path().join("app/build.gradle.kts")] + .applied_plugins + .contains(&"org.jetbrains.kotlin.android".to_owned()) + ); + let catalog_doc = &docs[&tmp.path().join("gradle/libs.versions.toml")]; + assert!( + catalog_doc + .dependencies() + .iter() + .any(|d| d.name == "androidx.webkit:webkit") + ); + assert!( + !catalog_doc + .dependencies() + .iter() + .any(|d| d.name == "androidx.appcompat:appcompat") + ); + run_at( + &Cli::parse_from([ + "dcu", + "-u", + "--manifest", + "app/build.gradle.kts", + "androidx.webkit:webkit", + ]), + tmp.path(), + ®istry, + false, + ) + .await + .unwrap(); + assert_eq!(read(tmp.path(), "app/build.gradle.kts"), build); + assert_eq!( + read(tmp.path(), "gradle/libs.versions.toml"), + catalog.replace("webkit = '1.6.1'", "webkit = '1.12.1'") + ); + + write( + tmp.path(), + "app/build.gradle.kts", + "dependencies {\n implementation(libs.missing)\n implementation(libs.other.get())\n}\n", + ); + let docs = project::load(&manifests, tmp.path()).unwrap(); + let entries = &docs[&tmp.path().join("app/build.gradle.kts")].entries; + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|e| e.reason.is_some())); +} + +#[tokio::test] +async fn strict_incomplete_aborts_entire_batch_and_cleanup_but_strict_compatibility_is_scoped() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "package.json", "{}"); + write(tmp.path(), "package-lock.json", "keep lock"); + write(tmp.path(), "node_modules/keep", "keep install"); + write(tmp.path(), ".nvmrc", "20.1.0\r\n"); + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + let build = "classpath(\"com.android.tools.build:gradle:8.5.1\")\nimplementation(\"androidx.webkit:webkit:1.6.1\")\n"; + let wrapper = + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n"; + write(tmp.path(), "build.gradle.kts", build); + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + wrapper, + ); + let cli = Cli::parse_from([ + "dcu", + "-d", + "-u", + "--rm", + "--fail-on-incomplete", + "--format", + "json-report", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 2); + assert!(matches!( + report.outcome, + crate::report::ApplyOutcome::Aborted + )); + assert_eq!(report.summary().updated, 0); + assert_eq!(read(tmp.path(), "build.gradle.kts"), build); + assert_eq!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"), + wrapper + ); + assert_eq!(read(tmp.path(), ".nvmrc"), "20.1.0\r\n"); + assert_eq!(read(tmp.path(), "package-lock.json"), "keep lock"); + assert!(tmp.path().join("node_modules/keep").exists()); + let cli = Cli::parse_from([ + "dcu", + "-d", + "-u", + "--strict-compatibility", + "--format", + "json-report", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert!(matches!( + report.outcome, + crate::report::ApplyOutcome::Committed + )); + assert_eq!( + read(tmp.path(), "build.gradle.kts"), + build.replace("webkit:1.6.1", "webkit:1.12.1") + ); + assert_eq!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"), + wrapper + ); + assert_eq!(read(tmp.path(), ".nvmrc"), "22.15.1\r\n"); + let rows = report.json(crate::OutputFormat::JsonReport).unwrap(); + let agp = rows["items"] + .as_array() + .unwrap() + .iter() + .find(|r| r["name"] == "com.android.tools.build:gradle") + .unwrap(); + assert_eq!(agp["status"], "unverified"); + assert_eq!(agp["updated"], false); + assert!( + agp["reason"] + .as_str() + .unwrap() + .contains("strict compatibility") + ); +} + +#[tokio::test] +async fn explicit_gradle_manifest_uses_ancestor_jdk_without_updating_the_pin() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write(tmp.path(), ".tool-versions", "java 17\n"); + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\n", + ); + write( + tmp.path(), + "android/settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + let build = "classpath(\"com.android.tools.build:gradle:8.5.1\")\n"; + write(tmp.path(), "android/app/build.gradle.kts", build); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--strict-compatibility", + "--manifest", + "android/app/build.gradle.kts", + "--format", + "json-report", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 1); + assert_eq!(report.summary().incomplete, 0); + assert_eq!(read(tmp.path(), ".tool-versions"), "java 17\n"); + assert_eq!( + read(tmp.path(), "android/app/build.gradle.kts"), + build.replace("8.5.1", "8.10.1") + ); +} + +#[tokio::test] +async fn targeted_context_does_not_read_unrelated_catalogs_or_inherit_their_repositories() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + "repositories { maven(\"https://private.invalid/root\") }\n", + ); + write( + tmp.path(), + "apps/a/settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + write( + tmp.path(), + "apps/a/app/build.gradle.kts", + "implementation(\"androidx.webkit:webkit:1.6.1\")\n", + ); + write( + tmp.path(), + "apps/b/settings.gradle.kts", + "repositories { maven(\"https://private.invalid/b\") }\n", + ); + write( + tmp.path(), + "apps/b/gradle/libs.versions.toml", + "[not valid TOML", + ); + write( + tmp.path(), + "apps/b/build.gradle.kts", + "classpath(\"com.android.tools.build:gradle:99.0.0\")\n", + ); + let manifest = Scanner::from_path(&tmp.path().join("apps/a/app/build.gradle.kts")).unwrap(); + let docs = project::load(&[manifest], tmp.path()).unwrap(); + assert!( + docs.keys() + .all(|p| !p.starts_with(tmp.path().join("apps/b"))) + ); + let entry = &docs[&tmp.path().join("apps/a/app/build.gradle.kts")].entries[0]; + assert_eq!(entry.repositories, vec![server.uri()]); + let cli = Cli::parse_from(["dcu", "-u", "--manifest", "apps/a/app/build.gradle.kts"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 1); + assert_eq!( + read(tmp.path(), "apps/b/gradle/libs.versions.toml"), + "[not valid TOML" + ); + write(tmp.path(), "package.json", "{}"); + let docs = project::load( + &[Scanner::from_path(&tmp.path().join("package.json")).unwrap()], + tmp.path(), + ) + .unwrap(); + assert_eq!(docs.len(), 1); // No Gradle context traversal for a Node-only lookup. +} + +#[tokio::test] +async fn shared_ancestor_source_expands_consumers_without_parsing_unrelated_catalogs() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + let properties = "sharedVersion=1.6.1\n"; + write(tmp.path(), "gradle.properties", properties); + for name in ["a", "b"] { + write( + tmp.path(), + &format!("apps/{name}/settings.gradle.kts"), + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + } + write( + tmp.path(), + "apps/a/build.gradle.kts", + "implementation(\"androidx.webkit:webkit:$sharedVersion\")\n", + ); + write( + tmp.path(), + "apps/b/build.gradle.kts", + "implementation(\"androidx.appcompat:appcompat:$sharedVersion\")\n", + ); + write( + tmp.path(), + "apps/b/gradle/libs.versions.toml", + "[broken catalog", + ); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--manifest", + "apps/a/build.gradle.kts", + "webkit", + "--format", + "json-report", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 0); + assert_eq!(read(tmp.path(), "gradle.properties"), properties); + assert_eq!(report.items.len(), 1); + assert_eq!(report.items[0].status(), crate::report::Status::Blocked); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--manifest", + "apps/a/build.gradle.kts", + "webkit", + "--format", + "json-legacy", + ]); + assert!( + execute_at(&cli, tmp.path(), ®istry, false) + .await + .is_err() + ); + assert_eq!(read(tmp.path(), "gradle.properties"), properties); +} + +#[tokio::test] +async fn duplicate_metadata_is_shared_but_targets_and_executions_are_independent() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + write( + tmp.path(), + "build.gradle.kts", + "classpath(\"com.android.tools.build:gradle:8.5.1\")\nclasspath(\"com.android.tools.build:gradle:8.5.2\")\n", + ); + write( + tmp.path(), + "buildSrc/build.gradle.kts", + "implementation(\"com.android.tools.build:gradle:8.5.1\")\n", + ); + write( + tmp.path(), + "package.json", + r#"{"packageManager":"pnpm@10.12.1","dependencies":{"pnpm":"10.12.1"}}"#, + ); + write(tmp.path(), ".nvmrc", "20.1.0\n"); + write(tmp.path(), ".node-version", "20.1.0\n"); + let cli = Cli::parse_from(["dcu", "-d", "-t", "patch", "--format", "json-report"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + let json = report.json(crate::OutputFormat::Json).unwrap(); + let rows = json.as_array().unwrap(); + assert_eq!( + rows.iter() + .filter(|r| r["name"] == "com.android.tools.build:gradle") + .count(), + 3 + ); + assert!( + rows.iter() + .filter(|r| r["name"] == "com.android.tools.build:gradle") + .all(|r| r["selected"] == "8.5.2") + ); + let requests = server.received_requests().await.unwrap(); + for path in [ + "/com/android/tools/build/gradle/maven-metadata.xml", + "/pnpm", + "/node", + ] { + assert_eq!( + requests.iter().filter(|r| r.url.path() == path).count(), + 1, + "{path}" + ); + } + let cli = Cli::parse_from(["dcu", "-d", "--format", "json-report"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + let json = report.json(crate::OutputFormat::Json).unwrap(); + assert!( + json.as_array() + .unwrap() + .iter() + .filter(|r| r["name"] == "com.android.tools.build:gradle") + .all(|r| r["selected"] == "8.10.1") + ); + let requests = server.received_requests().await.unwrap(); + assert_eq!( + requests + .iter() + .filter(|r| r.url.path() == "/com/android/tools/build/gradle/maven-metadata.xml") + .count(), + 2 + ); +} + +#[test] +fn dynamic_local_assignment_shadows_ancestor_pin_and_imported_builds_are_unverified() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "gradle.properties", "webkitVersion=1.6.1\n"); + write( + tmp.path(), + "build.gradle.kts", + "val webkitVersion = lookupVersion()\nimplementation(\"androidx.webkit:webkit:$webkitVersion\")\n", + ); + let docs = project::load( + &[Scanner::from_path(&tmp.path().join("build.gradle.kts")).unwrap()], + tmp.path(), + ) + .unwrap(); + let doc = &docs[&tmp.path().join("build.gradle.kts")]; + assert!( + doc.entries[0] + .reason + .as_ref() + .unwrap() + .contains("unresolved") + ); + assert!( + docs[&tmp.path().join("gradle.properties")] + .entries + .is_empty() + ); + let doc = project::parse( + "includeBuild(projectPath())\n", + Path::new("settings.gradle.kts"), + ) + .unwrap(); + assert!(!doc.context_issues.is_empty()); + assert!(doc.entries[0].reason.is_some()); +} + +#[tokio::test] +async fn same_artifact_in_nonselected_shared_property_consumers_is_preserved() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "gradle.properties", "webkitVersion=1.6.1\n"); + for name in ["a", "b"] { + write( + tmp.path(), + &format!("apps/{name}/settings.gradle.kts"), + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + write( + tmp.path(), + &format!("apps/{name}/build.gradle.kts"), + "implementation(\"androidx.webkit:webkit:$webkitVersion\")\n", + ); + } + let cli = Cli::parse_from([ + "dcu", + "-u", + "--manifest", + "apps/a/build.gradle.kts", + "--format", + "json-report", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 0); + assert_eq!(report.items[0].status(), crate::report::Status::Blocked); + assert_eq!( + read(tmp.path(), "gradle.properties"), + "webkitVersion=1.6.1\n" + ); +} + +#[tokio::test] +async fn catalog_alias_uses_consumer_repositories_and_keeps_nonselected_uses() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + "repositories { maven(\"https://private.invalid/root\") }\n", + ); + write( + tmp.path(), + "android/settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + let catalog = "[versions]\nwebkit = '1.6.1'\n[libraries]\nwebkit = { module = 'androidx.webkit:webkit', version.ref = 'webkit' }\n"; + write(tmp.path(), "gradle/libs.versions.toml", catalog); + write( + tmp.path(), + "android/a/build.gradle.kts", + "implementation(libs.webkit)\n", + ); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--manifest", + "android/a/build.gradle.kts", + "--format", + "json-report", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 1); + assert_eq!( + read(tmp.path(), "gradle/libs.versions.toml"), + catalog.replace("1.6.1", "1.12.1") + ); + write(tmp.path(), "gradle/libs.versions.toml", catalog); + write( + tmp.path(), + "android/b/build.gradle.kts", + "implementation(libs.webkit)\n", + ); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 0); + assert_eq!(report.items[0].status(), crate::report::Status::Blocked); + assert_eq!(read(tmp.path(), "gradle/libs.versions.toml"), catalog); +} + +#[tokio::test] +async fn shared_alias_lookup_failure_blocks_successful_consumers_of_the_same_source() { + let (server, registry) = registry().await; + let failed = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(503)) + .mount(&failed) + .await; + let tmp = TempDir::new().unwrap(); + let catalog = + "[libraries]\nwebkit = { module = 'androidx.webkit:webkit', version = '1.6.1' }\n"; + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + write(tmp.path(), "gradle/libs.versions.toml", catalog); + for (name, url) in [("a", server.uri()), ("b", failed.uri())] { + write( + tmp.path(), + &format!("apps/{name}/settings.gradle.kts"), + &format!("repositories {{ maven(\"{url}\") }}\n"), + ); + write( + tmp.path(), + &format!("apps/{name}/build.gradle.kts"), + "implementation(libs.webkit)\n", + ); + } + let cli = Cli::parse_from(["dcu", "-d", "-u", "--format", "json-report"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.summary().updated, 0); + assert_eq!(read(tmp.path(), "gradle/libs.versions.toml"), catalog); + assert!( + report + .items + .iter() + .any(|r| r.status() == crate::report::Status::Blocked) + ); + let rows = report.json(crate::OutputFormat::Json).unwrap(); + assert!( + rows.as_array() + .unwrap() + .iter() + .any(|r| r["reason"].as_str().is_some_and(|s| s.contains("503"))) + ); +} + +#[test] +fn ordinary_npm_tool_names_do_not_gain_tool_compatibility() { + use dependency_check_updates_core::{ManifestHandler, ResolvedVersion}; + let handler = dependency_check_updates_node::NodeHandler; + let text = r#"{"dependencies":{"jdk":"1.0.0","gradle":"1.0.0"}}"#; + let path = PathBuf::from("package.json"); + let deps = handler.parse(text, &path).unwrap().dependencies; + let job = ManifestJob { + manifest_ref: dependency_check_updates_core::ManifestRef { + path, + kind: ManifestKind::PackageJson, + }, + display_path: "package.json".into(), + text: text.into(), + handler: Box::new(handler), + deps, + document: None, + }; + let resolved = vec![ + ( + 0, + Ok(ResolvedVersion { + latest: Some("1.0.0".into()), + selected: Some("1.0.0".into()), + }), + ), + ( + 1, + Ok(ResolvedVersion { + latest: Some("1.0.0".into()), + selected: Some("1.0.0".into()), + }), + ), + ]; + let rows = report_rows( + &job, + &resolved, + &[], + Some("unverified: missing JDK"), + None, + false, + ); + assert!( + rows.iter() + .all(|r| r.status == crate::report::Status::Current + && r.compatibility.is_none() + && r.selection_policy.is_none()) + ); +} + +#[tokio::test] +async fn failed_checksum_revalidates_agp_before_any_write() { + let (server, registry) = registry().await; + let tmp = fixture(&server); + Mock::given(path("/distributions/gradle-8.13-bin.zip.sha256")) + .respond_with(ResponseTemplate::new(503)) + .with_priority(1) + .mount(&server) + .await; + let build = format!("{ANDROID}/build.gradle.kts"); + let before = read(tmp.path(), &build); + run_at( + &Cli::parse_from(["dcu", "-d", "-u"]), + tmp.path(), + ®istry, + false, + ) + .await + .unwrap(); + assert_eq!(read(tmp.path(), &build), before); + assert!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties").contains("gradle-8.9-bin.zip") + ); +} + +#[tokio::test] +async fn agp9_requires_explicit_kotlin_android_migration() { + let (server, registry) = registry().await; + let tmp = fixture(&server); + Mock::given(path("/com/android/tools/build/gradle/maven-metadata.xml")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("9.0.1"), + ) + .with_priority(1) + .mount(&server) + .await; + metadata( + &server, + "/gradle9", + r#"[{"version":"9.1.0","snapshot":false}]"#, + ) + .await; + let mut registry = registry; + registry.endpoints.gradle = format!("{}/gradle9", server.uri()); + let app = format!("{ANDROID}/app/build.gradle.kts"); + write( + tmp.path(), + &app, + &format!( + "plugins {{ id(\"org.jetbrains.kotlin.android\") }}\r\n{}", + read(tmp.path(), &app) + ), + ); + let build = format!("{ANDROID}/build.gradle.kts"); + let before = read(tmp.path(), &build); + run_at( + &Cli::parse_from(["dcu", "-d", "-u"]), + tmp.path(), + ®istry, + false, + ) + .await + .unwrap(); + assert_eq!(read(tmp.path(), &build), before); +} + +#[test] +fn scanner_preserves_ignore_and_build_directory_rules() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join(".git")).unwrap(); + write( + tmp.path(), + ".gitignore", + "ignored/\nignored-project/.nvmrc\n", + ); + for p in [ + ".nvmrc", + "apps/app/.node-version", + "apps/app/.mise.toml", + "ignored/build.gradle.kts", + "ignored-project/.nvmrc", + "node_modules/tool/.nvmrc", + "target/build.gradle.kts", + "build/build.gradle.kts", + ".secret/.nvmrc", + ] { + write(tmp.path(), p, "20\n"); + } + let found = Scanner::scan_deep(tmp.path()); + assert_eq!( + found.len(), + 3, + "{:?}", + found.iter().map(|m| &m.path).collect::>() + ); +} + +#[test] +fn tool_syntax_preserves_channels_ranges_and_vendor_prefixes() { + let text = "[tools]\nnode = 'lts/jod'\nrust = 'stable'\npnpm = '>=10 <11'\njava = 'temurin-17.0.1+9'\n"; + let doc = project::parse(text, Path::new("mise.toml")).unwrap(); + assert_eq!(doc.entries.len(), 4); + assert_eq!(doc.entries[0].dep.current_req, "lts/jod"); + assert!(doc.entries[2].reason.is_some()); + let update = dependency_check_updates_core::PlannedUpdate { + name: "jdk".into(), + section: DependencySection::Toolchain, + from: "17.0.1+9".into(), + to: "21.0.2+13".into(), + }; + assert_eq!( + doc.apply(text, &[update], vec![]).unwrap(), + text.replace("temurin-17.0.1+9", "temurin-21.0.2+13") + ); + let package = r#"{"nested":{"packageManager":"pnpm@10.12.1"},"packageManager":"pnpm@10.12.1"}"#; + let doc = project::parse(package, Path::new("package.json")).unwrap(); + let update = dependency_check_updates_core::PlannedUpdate { + name: "pnpm".into(), + section: DependencySection::Toolchain, + from: "10.12.1".into(), + to: "10.13.1".into(), + }; + assert_eq!( + doc.apply(package, &[update], vec![]).unwrap(), + r#"{"nested":{"packageManager":"pnpm@10.12.1"},"packageManager":"pnpm@10.13.1"}"# + ); +} + +#[tokio::test] +async fn shared_sdk_source_cannot_raise_min_sdk_and_reassignments_are_unsupported() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + let script = + "val sdk = 24\r\nandroid {\r\ncompileSdk = sdk\r\ntargetSdk = sdk\r\nminSdk = sdk\r\n}\r\n"; + write(tmp.path(), "build.gradle.kts", script); + assert!( + !run_at( + &Cli::parse_from(["dcu", "-u"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), "build.gradle.kts"), script); + let script = format!( + "repositories {{ maven(\"{}\") }}\nvar version = \"1.6.1\"\nversion = calculateVersion()\nimplementation(\"androidx.webkit:webkit:$version\")\n", + server.uri() + ); + write(tmp.path(), "build.gradle.kts", &script); + assert!( + !run_at( + &Cli::parse_from(["dcu", "-u"]), + tmp.path(), + ®istry, + false + ) + .await + .unwrap() + ); + assert_eq!(read(tmp.path(), "build.gradle.kts"), script); +} + +#[test] +fn toml_literal_quotes_and_escaped_values_remain_safe() { + let text = + "[tools]\nnode = '''20.1.0''' # Keep three literal quotes\nrust = \"1\\u002e85.0\"\n"; + let doc = project::parse(text, Path::new("mise.toml")).unwrap(); + assert!(doc.entries[1].reason.is_some()); + let update = dependency_check_updates_core::PlannedUpdate { + name: "node".into(), + section: DependencySection::Toolchain, + from: "20.1.0".into(), + to: "22.1.0".into(), + }; + assert_eq!( + doc.apply(text, &[update], vec![]).unwrap(), + text.replace("'''20.1.0'''", "'''22.1.0'''") + ); +} + +#[test] +fn properties_without_artifact_context_are_reported_as_unsupported() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "gradle.properties", + "webkitVersion=1.6.1\nminSdkVersion=24\norg.gradle.jvmargs=-Xmx2g\n", + ); + let manifests = Scanner::scan_dir(tmp.path()); + let docs = project::load(&manifests, tmp.path()).unwrap(); + let doc = &docs[&tmp.path().join("gradle.properties")]; + assert_eq!(doc.dependencies().len(), 1); + assert!( + doc.entries[0] + .reason + .as_ref() + .unwrap() + .contains("no statically identified artifact") + ); +} + +#[tokio::test] +async fn nested_sdk_conflict_blocks_ancestor_version_sources_and_tool_pins() { + let (server, registry) = registry().await; + for catalog in [false, true] { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), ".tool-versions", "java 16\n"); + let wrapper = + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n"; + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + wrapper, + ); + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + write( + tmp.path(), + "android/settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + let (source_path, source, declaration, name) = if catalog { + ( + "gradle/libs.versions.toml", + "[versions]\nagp = '8.5.1'\n[plugins]\nandroid = { id = 'com.android.application', version.ref = 'agp' }\n", + "plugins {\n alias(libs.plugins.android)\n}\n", + "com.android.application", + ) + } else { + ( + "gradle.properties", + "agpVersion=8.5.1 # original\n", + "classpath(\"com.android.tools.build:gradle:$agpVersion\")\n", + "com.android.tools.build:gradle", + ) + }; + write(tmp.path(), source_path, source); + let build = format!("{declaration}android {{\n compileSdk = 37\n}}\n"); + write(tmp.path(), "android/app/build.gradle.kts", &build); + let cli = Cli::parse_from(["dcu", "-d", "-u", "--format", "json-report"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(read(tmp.path(), source_path), source); + assert_eq!(read(tmp.path(), "android/app/build.gradle.kts"), build); + assert_eq!(read(tmp.path(), ".tool-versions"), "java 16\n"); + assert_eq!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties"), + wrapper + ); + let json = report.json(crate::OutputFormat::JsonReport).unwrap(); + for dependency in [name, "gradle", "jdk"] { + let row = json["items"] + .as_array() + .unwrap() + .iter() + .find(|row| row["name"] == dependency) + .unwrap(); + assert_eq!(row["status"], "blocked", "{row}"); + assert_eq!(row["to"], serde_json::Value::Null); + assert_eq!(row["updated"], false); + assert!(row["reason"].as_str().unwrap().contains("conflict:")); + } + assert_eq!(report.summary().updated, 0); + } +} + +#[tokio::test] +async fn standalone_jdk_and_plain_gradle_do_not_require_android_pins() { + let (_server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write(tmp.path(), ".tool-versions", "java 16\n"); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--fail-on-incomplete", + "--strict-compatibility", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert_eq!(report.summary().updated, 1); + assert_eq!(report.summary().incomplete, 0); + assert_eq!(read(tmp.path(), ".tool-versions"), "java 17\n"); + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n", + ); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert_eq!(report.summary().updated, 1); + assert_eq!(report.summary().incomplete, 0); + assert!( + read(tmp.path(), "gradle/wrapper/gradle-wrapper.properties") + .contains("gradle-8.13-bin.zip") + ); +} + +#[tokio::test] +async fn unsupported_build_connections_remain_unverified_with_known_tool_pins() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write(tmp.path(), ".tool-versions", "java 17\n"); + let wrapper = + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\n"; + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + wrapper, + ); + write( + tmp.path(), + "settings.gradle.kts", + &format!( + "includeBuild(\"../conventions\")\nrepositories {{ maven(\"{}\") }}\n", + server.uri() + ), + ); + let build = "classpath(\"com.android.tools.build:gradle:8.5.1\")\nimplementation(\"androidx.webkit:webkit:1.6.1\")\n"; + write(tmp.path(), "build.gradle.kts", build); + let cli = Cli::parse_from(["dcu", "-u", "--strict-compatibility"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert!(report.summary().incomplete > 0); + assert_eq!( + read(tmp.path(), "build.gradle.kts"), + build.replace("webkit:1.6.1", "webkit:1.12.1") + ); + let json = report.json(crate::OutputFormat::JsonReport).unwrap(); + let agp = json["items"] + .as_array() + .unwrap() + .iter() + .find(|row| row["name"] == "com.android.tools.build:gradle") + .unwrap(); + assert!( + agp["reason"] + .as_str() + .unwrap() + .contains("strict compatibility blocks") + ); +} + +#[tokio::test] +async fn deep_catalog_lookup_uses_consumer_repository_not_an_unused_source_context() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + "// no repositories at the catalog source\n", + ); + write( + tmp.path(), + "gradle/libs.versions.toml", + "[libraries]\nwebkit = { module = 'androidx.webkit:webkit', version = '1.6.1' }\n", + ); + write( + tmp.path(), + "android/settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + let build = "dependencies {\n implementation(libs.webkit)\n}\n"; + write(tmp.path(), "android/app/build.gradle.kts", build); + let cli = Cli::parse_from(["dcu", "-d", "-u", "--fail-on-incomplete"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert_eq!(report.summary().updated, 1); + assert_eq!(report.summary().incomplete, 0); + assert!(read(tmp.path(), "gradle/libs.versions.toml").contains("version = '1.12.1'")); + assert_eq!(read(tmp.path(), "android/app/build.gradle.kts"), build); +} + +#[tokio::test] +async fn typed_extra_and_literal_provider_bindings_patch_only_source_spans() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\r\n", server.uri()), + ); + let properties = "webkitVersion=1.6.1 # source\r\n"; + write(tmp.path(), "gradle.properties", properties); + let build = "val materialVersion: String = \"1.8.0\" // typed\r\nval appcompatVersion: String by extra(\"1.6.1\")\r\nval webkit: String = providers.gradleProperty(\"webkitVersion\").get()\r\nimplementation(\"com.google.android.material:material:$materialVersion\")\r\nimplementation(\"androidx.appcompat:appcompat:$appcompatVersion\")\r\nimplementation(\"androidx.webkit:webkit:$webkit\")\r\n// val fake by extra(\"1.0\")\r\n"; + write(tmp.path(), "app/build.gradle.kts", build); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--manifest", + "app/build.gradle.kts", + "--fail-on-incomplete", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert_eq!( + read(tmp.path(), "gradle.properties"), + properties.replace("1.6.1", "1.12.1") + ); + assert_eq!( + read(tmp.path(), "app/build.gradle.kts"), + build + .replace( + "materialVersion: String = \"1.8.0\"", + "materialVersion: String = \"1.12.0\"" + ) + .replace( + "appcompatVersion: String by extra(\"1.6.1\")", + "appcompatVersion: String by extra(\"1.7.0\")" + ) + ); + for binding in [ + "val webkit: String = providers.gradleProperty(\"webkitVersion\").map { transform(it) }.get()\n", + "val webkit: String = providers.gradleProperty(\"webkitVersion\").get()\nwebkit = calculateVersion()\n", + ] { + let build = format!("{binding}implementation(\"androidx.webkit:webkit:$webkit\")\n"); + write(tmp.path(), "app/build.gradle.kts", &build); + write(tmp.path(), "gradle.properties", properties); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 2); + assert_eq!(read(tmp.path(), "app/build.gradle.kts"), build); + assert_eq!(read(tmp.path(), "gradle.properties"), properties); + } +} + +#[tokio::test] +async fn explicit_maven_access_authenticates_and_rejects_redirects_without_secret_leaks() { + use reqwest::header::{AUTHORIZATION, HeaderMap}; + use wiremock::matchers::header; + let (server, mut registry) = registry().await; + let destination = MockServer::start().await; + let repo = format!("{}/private", server.uri()); + Mock::given(method("GET")).and(path("/private/org/example/library/maven-metadata.xml")) + .and(header("authorization", "Bearer secret-test")) + .respond_with(ResponseTemplate::new(200).set_body_string("1.0.01.1.0")) + .expect(1).mount(&server).await; + let mut headers = HeaderMap::new(); + let mut value = "Bearer secret-test" + .parse::() + .unwrap(); + value.set_sensitive(true); + headers.insert(AUTHORIZATION, value); + registry.private_repositories.insert(repo.clone(), headers); + registry.private_cache = + Some(dependency_check_updates_core::MetadataCache::without_redirects()); + let build = format!( + "repositories {{ maven(\"{repo}\") }}\nimplementation(\"org.example:library:1.0.0\")\n" + ); + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "build.gradle.kts", &build); + let cli = Cli::parse_from(["dcu", "-u", "--fail-on-incomplete"]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert!(read(tmp.path(), "build.gradle.kts").contains("library:1.1.0")); + server.reset().await; + Mock::given(method("GET")) + .and(path("/private/org/example/library/maven-metadata.xml")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("Location", format!("{}/leak", destination.uri())), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/leak")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&destination) + .await; + write(tmp.path(), "build.gradle.kts", &build); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 2); + let json = report + .json(crate::OutputFormat::JsonReport) + .unwrap() + .to_string(); + assert!(!json.contains("secret-test")); + assert!(json.contains("HTTP 302")); + assert_eq!(read(tmp.path(), "build.gradle.kts"), build); + destination.verify().await; +} + +#[tokio::test] +async fn compatibility_extensions_verify_new_combinations_without_replacing_builtins() { + let (server, registry) = registry().await; + let tmp = TempDir::new().unwrap(); + write(tmp.path(), ".tool-versions", "java 17\n"); + write( + tmp.path(), + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\n", + ); + write( + tmp.path(), + "settings.gradle.kts", + &format!("repositories {{ maven(\"{}\") }}\n", server.uri()), + ); + let build = "classpath(\"com.android.tools.build:gradle:99.0.0\")\n"; + write(tmp.path(), "build.gradle.kts", build); + Mock::given(method("GET")).and(path("/com/android/tools/build/gradle/maven-metadata.xml")) + .respond_with(ResponseTemplate::new(200).set_body_string("99.0.1")) + .with_priority(1).mount(&server).await; + write( + tmp.path(), + "rules.json", + r#"{"schemaVersion":1,"verifiedAt":"2026-10-01","sources":["https://developer.android.com/build/releases/about-agp"],"agp":{"99.0":{"minGradle":"8.13","minJdk":17}}}"#, + ); + let cli = Cli::parse_from([ + "dcu", + "-u", + "--strict-compatibility", + "--fail-on-incomplete", + "--compatibility-file", + "rules.json", + ]); + let report = execute_at(&cli, tmp.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.exit_code(&cli), 0); + assert_eq!( + read(tmp.path(), "build.gradle.kts"), + build.replace("99.0.0", "99.0.1") + ); +} diff --git a/crates/cli/src/report.rs b/crates/cli/src/report.rs new file mode 100644 index 0000000..f611628 --- /dev/null +++ b/crates/cli/src/report.rs @@ -0,0 +1,497 @@ +//! Stable reporting contracts and shared exit policy for binaries and bridges. +use crate::cli::{Cli, OutputFormat}; +use dependency_check_updates_core::DcuError; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Status { + Update, + Current, + Channel, + Unsupported, + Failed, + Blocked, + Unverified, + Missing, +} + +impl Status { + pub fn incomplete(self) -> bool { + matches!( + self, + Self::Unsupported | Self::Failed | Self::Blocked | Self::Unverified | Self::Missing + ) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectRow { + pub manifest: String, + pub name: String, + pub section: String, + pub from: String, + pub to: Option, + pub latest: Option, + pub selected: Option, + #[serde(default)] + pub compatible: Option, + pub status: Status, + pub reason: Option, + pub compatibility: Option, + pub selection_policy: Option, + pub updated: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalRow { + pub name: String, + pub scope: String, + pub installed: Option, + pub latest: Option, + pub selected: Option, + pub status: Status, + pub reason: Option, + pub update_command: String, + pub updated: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum Item { + Project(ProjectRow), + Local(LocalRow), +} + +impl Item { + pub fn status(&self) -> Status { + match self { + Self::Project(r) => r.status, + Self::Local(r) => r.status, + } + } + pub fn updated(&self) -> bool { + match self { + Self::Project(r) => r.updated, + Self::Local(r) => r.updated, + } + } + pub fn incomplete(&self) -> bool { + self.status().incomplete() + || matches!(self, Self::Project(r) if r.compatibility.as_deref().is_some_and(|s| s.starts_with("unverified:"))) + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct Diagnostic { + pub code: String, + pub message: String, + pub path: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Summary { + pub manifests: usize, + pub checked: usize, + pub updates: usize, + pub updated: usize, + pub incomplete: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ApplyOutcome { + #[default] + NotRequested, + NoChanges, + Committed, + Aborted, + RolledBack, + RecoveryRequired, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct RunReport { + pub items: Vec, + pub diagnostics: Vec, + pub outcome: ApplyOutcome, + pub execution_failed: bool, + pub manifest_count: usize, + pub compatibility_rules: Vec, +} + +impl RunReport { + pub fn summary(&self) -> Summary { + Summary { + manifests: self.manifest_count, + checked: self.items.len(), + updates: self + .items + .iter() + .filter(|r| r.status() == Status::Update) + .count(), + updated: self.items.iter().filter(|r| r.updated()).count(), + incomplete: self.items.iter().filter(|r| r.incomplete()).count() + + self.diagnostics.len(), + } + } + pub fn has_updates(&self) -> bool { + self.summary().updates != 0 + } + pub fn incomplete(&self) -> bool { + self.summary().incomplete != 0 + } + pub fn exit_code(&self, cli: &Cli) -> u8 { + if self.execution_failed { + 1 + } else if cli.fail_on_incomplete && self.incomplete() { + 2 + } else { + u8::from(cli.error_level >= 2 && self.has_updates()) + } + } + + pub fn json(&self, format: OutputFormat) -> Result { + match format { + OutputFormat::Json => Ok(serde_json::to_value(&self.items).expect("report items")), + OutputFormat::JsonReport => Ok(serde_json::json!({ + "schemaVersion": 2, + "summary": self.summary(), + "items": self.items, + "diagnostics": self.diagnostics, + "applyOutcome": self.outcome, + "compatibilityRules": self.compatibility_rules, + })), + OutputFormat::JsonLegacy => { + self.validate_legacy()?; + let mut updates = serde_json::Map::new(); + for item in &self.items { + if let Item::Project(r) = item + && let Some(to) = &r.to + { + if updates.get(&r.name).is_some_and(|v| v != to) { + return Err(crate::project::error( + "json-legacy", + "conflicting versions cannot be represented; use --format json-report", + )); + } + updates.insert(r.name.clone(), to.clone().into()); + } + } + Ok(Value::Object(updates)) + } + OutputFormat::Table => unreachable!("table is not JSON"), + } + } + + pub fn validate_legacy(&self) -> Result<(), DcuError> { + let manifests: BTreeSet<_> = self + .items + .iter() + .filter_map(|r| match r { + Item::Project(r) => Some(&r.manifest), + Item::Local(_) => None, + }) + .collect(); + if self.manifest_count > 1 + || manifests.len() > 1 + || self.items.iter().any(|r| matches!(r, Item::Local(_))) + { + return Err(crate::project::error( + "json-legacy", + "requires one effective project manifest; use --format json-report", + )); + } + Ok(()) + } + + pub fn print_json(&self, format: OutputFormat) -> Result<(), DcuError> { + println!( + "{}", + serde_json::to_string_pretty(&self.json(format)?).expect("JSON report") + ); + if format != OutputFormat::JsonReport { + for d in &self.diagnostics { + eprintln!("{}: {}", d.code, d.message); + } + } + if format == OutputFormat::JsonLegacy { + for item in &self.items { + if let Item::Project(r) = item + && r.status.incomplete() + { + eprintln!( + "{} [{}]: {}", + r.name, + r.manifest, + r.reason.as_deref().unwrap_or("incomplete check") + ); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn exit_policy_preserves_defaults_and_prioritizes_incomplete() { + let mut report = RunReport::default(); + report.items.push(Item::Local(LocalRow { + name: "node".into(), + scope: "local".into(), + installed: None, + latest: None, + selected: None, + status: Status::Missing, + reason: Some("not installed".into()), + update_command: "installer".into(), + updated: false, + })); + assert_eq!(report.exit_code(&Cli::parse_from(["dcu", "-e", "2"])), 0); + assert_eq!( + report.exit_code(&Cli::parse_from(["dcu", "--fail-on-incomplete", "-e", "2"])), + 2 + ); + report.execution_failed = true; + assert_eq!( + report.exit_code(&Cli::parse_from(["dcu", "--fail-on-incomplete"])), + 1 + ); + } + + #[test] + fn empty_output_contract_and_legacy_scope() { + let mut report = RunReport::default(); + assert_eq!( + report.json(OutputFormat::Json).unwrap(), + serde_json::json!([]) + ); + assert_eq!( + report.json(OutputFormat::JsonLegacy).unwrap(), + serde_json::json!({}) + ); + let json = report.json(OutputFormat::JsonReport).unwrap(); + assert_eq!(json["schemaVersion"], 2); + assert_eq!(json["applyOutcome"], "not-requested"); + report.manifest_count = 2; + assert!(report.json(OutputFormat::JsonLegacy).is_err()); + } + + #[test] + fn local_rows_are_not_representable_in_legacy_output_and_diagnostics_survive_json() { + let report = RunReport { + items: vec![Item::Local(LocalRow { + name: "node".into(), + scope: "local".into(), + installed: None, + latest: None, + selected: None, + status: Status::Missing, + reason: Some("not installed".into()), + update_command: "installer".into(), + updated: false, + })], + diagnostics: vec![Diagnostic { + code: "probe".into(), + message: "failed".into(), + path: None, + }], + ..RunReport::default() + }; + assert!(report.validate_legacy().is_err()); + assert!(report.json(OutputFormat::JsonLegacy).is_err()); + report.print_json(OutputFormat::Json).unwrap(); + } + + fn contract_fields(schema: &Value, definition: &Value, value: &Value) { + let expected: BTreeSet<_> = definition["required"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + let actual: BTreeSet<_> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(actual, expected); + for (name, rule) in definition["properties"].as_object().unwrap() { + let rule = if let Some(reference) = rule["$ref"].as_str() { + schema.pointer(reference.trim_start_matches('#')).unwrap() + } else { + rule + }; + let field = &value[name]; + if let Some(required) = rule.get("const") { + assert_eq!(field, required); + } + if let Some(choices) = rule["enum"].as_array() { + assert!(choices.contains(field)); + } + match rule["type"].as_str() { + Some("string") => assert!(field.is_string()), + Some("integer") => assert!(field.as_i64().is_some() || field.as_u64().is_some()), + Some("boolean") => assert!(field.is_boolean()), + Some("array") => assert!(field.is_array()), + Some("object") => assert!(field.is_object()), + _ if rule["type"].is_array() => assert!(field.is_null() || field.is_string()), + _ => {} + } + } + } + + #[test] + fn serialized_report_contract_matches_schema_fields_types_statuses_and_outcomes() { + let schema: Value = + serde_json::from_str(include_str!("../schemas/report-v2.schema.json")).unwrap(); + let mut report = RunReport { + compatibility_rules: crate::compatibility_rules::Rules::builtin().provenance, + ..RunReport::default() + }; + for status in [ + Status::Update, + Status::Current, + Status::Channel, + Status::Unsupported, + Status::Failed, + Status::Blocked, + Status::Unverified, + Status::Missing, + ] { + report.items.push(Item::Project(ProjectRow { + manifest: "app/build.gradle.kts".into(), + name: "g:a".into(), + section: "maven".into(), + from: "1.0".into(), + to: None, + latest: None, + selected: None, + status, + compatible: None, + reason: None, + compatibility: None, + selection_policy: None, + updated: false, + })); + report.items.push(Item::Local(LocalRow { + name: "node".into(), + scope: "local".into(), + installed: None, + latest: None, + selected: None, + status, + reason: None, + update_command: "installer".into(), + updated: false, + })); + } + report.diagnostics.push(Diagnostic { + code: "failure".into(), + message: "details".into(), + path: None, + }); + for outcome in [ + ApplyOutcome::NotRequested, + ApplyOutcome::NoChanges, + ApplyOutcome::Committed, + ApplyOutcome::Aborted, + ApplyOutcome::RolledBack, + ApplyOutcome::RecoveryRequired, + ] { + report.outcome = outcome; + let json = report.json(OutputFormat::JsonReport).unwrap(); + contract_fields(&schema, &schema, &json); + contract_fields(&schema, &schema["$defs"]["summary"], &json["summary"]); + for p in json["compatibilityRules"].as_array().unwrap() { + contract_fields(&schema, &schema["$defs"]["provenance"], p); + } + for item in json["items"].as_array().unwrap() { + let definition = if item.get("manifest").is_some() { + "project" + } else { + "local" + }; + contract_fields(&schema, &schema["$defs"][definition], item); + let decoded: Item = serde_json::from_value(item.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), *item); + } + contract_fields( + &schema, + &schema["$defs"]["diagnostic"], + &json["diagnostics"][0], + ); + } + } + + #[test] + fn legacy_detects_conflicting_targets_and_collapses_identical_ones() { + let row = ProjectRow { + manifest: "package.json".into(), + name: "pnpm".into(), + section: "dependencies".into(), + from: "10.1.0".into(), + to: Some("10.2.0".into()), + latest: Some("10.2.0".into()), + selected: Some("10.2.0".into()), + compatible: None, + status: Status::Update, + reason: None, + compatibility: None, + selection_policy: None, + updated: false, + }; + let mut report = RunReport { + manifest_count: 1, + items: vec![Item::Project(row.clone()), Item::Project(row)], + ..RunReport::default() + }; + assert_eq!( + report.json(OutputFormat::JsonLegacy).unwrap(), + serde_json::json!({"pnpm":"10.2.0"}) + ); + let Item::Project(row) = &mut report.items[1] else { + unreachable!() + }; + row.to = Some("11.0.0".into()); + assert!(report.json(OutputFormat::JsonLegacy).is_err()); + } + + #[test] + fn unverified_updates_count_incomplete_even_with_an_available_target() { + let mut report = RunReport::default(); + let row = ProjectRow { + manifest: "build.gradle.kts".into(), + name: "gradle".into(), + section: "toolchain".into(), + from: "8.9".into(), + to: Some("8.13".into()), + latest: Some("8.13".into()), + selected: Some("8.13".into()), + compatible: None, + status: Status::Update, + reason: None, + compatibility: Some("unverified: missing JDK pin".into()), + selection_policy: None, + updated: false, + }; + report.items.push(Item::Project(row)); + assert_eq!(report.summary().incomplete, 1); + assert_eq!( + report.exit_code(&Cli::parse_from(["dcu", "--fail-on-incomplete", "-e", "2"])), + 2 + ); + assert_eq!(report.exit_code(&Cli::parse_from(["dcu", "-e", "2"])), 1); + } +} diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 3488624..38e7e40 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -15,26 +15,18 @@ use dependency_check_updates_rust::{CratesIoRegistry, RustHandler}; use crate::cleanup_progress::{cleanup_with_progress, targets_for_job}; use crate::cli::{Cli, OutputFormat}; +use crate::compatibility; 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; +use crate::project::{self, Document, ProjectHandler}; +use crate::report::{ApplyOutcome, Diagnostic, Item, ProjectRow, RunReport, Status}; +use crate::tool_registry::ToolRegistry; +use crate::transaction; /// Resolved version batch from a registry, indexed into the dependency slice /// the registry was handed. -type ResolvedBatch = Vec<(usize, Result)>; +pub(crate) type ResolvedBatch = Vec<(usize, Result)>; /// Entry point for bridge crates (napi, maturin). /// @@ -47,11 +39,13 @@ type ResolvedBatch = Vec<(usize, Result)>; pub async fn main(args: &[String]) -> Result<(), DcuError> { use clap::Parser; let cli = Cli::parse_from(args); - let error_level = cli.error_level; - let has_updates = run(&cli).await?; - - if error_level >= 2 && has_updates { - std::process::exit(1); + let report = execute(&cli).await?; + if report.execution_failed { + return Err(report_error(&report)); + } + let code = report.exit_code(&cli); + if code != 0 { + std::process::exit(i32::from(code)); } Ok(()) @@ -69,17 +63,8 @@ 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 - } - } + match execute(&cli).await { + Ok(report) => ExitCode::from(report.exit_code(&cli)), Err(e) => { eprintln!("Error: {e}"); ExitCode::FAILURE @@ -217,6 +202,57 @@ where } } +fn docker_registry(endpoint: Option<&str>) -> DockerRegistry { + endpoint.map_or_else(DockerRegistry::new, DockerRegistry::with_base_url) +} + +async fn resolve_individual_job( + job: &ManifestJob, + npm: Option<&NpmRegistry>, + crates_io: Option<&CratesIoRegistry>, + pypi: Option<&PyPiRegistry>, + registry: &ToolRegistry, + target: TargetLevel, +) -> ResolvedBatch { + match job.manifest_ref.kind { + ManifestKind::PackageJson => { + let ordinary: Vec<_> = job + .deps + .iter() + .enumerate() + .filter(|(_, d)| d.section != DependencySection::Toolchain) + .collect(); + let specs: Vec<_> = ordinary.iter().map(|(_, d)| (*d).clone()).collect(); + let mut batch = resolve_with(npm, &specs, |r, deps| r.resolve_batch(deps, target)) + .await + .into_iter() + .map(|(i, result)| (ordinary[i].0, result)) + .collect::>(); + batch.extend(resolve_project(job, registry, target).await); + batch.sort_by_key(|(i, _)| *i); + batch + } + ManifestKind::CargoToml => { + resolve_with(crates_io, &job.deps, |r, deps| { + r.resolve_batch(deps, target) + }) + .await + } + ManifestKind::PyProjectToml => { + resolve_with(pypi, &job.deps, |r, deps| r.resolve_batch(deps, target)).await + } + // These jobs are aggregated by remote_specs, never queried twice. + ManifestKind::GitHubWorkflow | ManifestKind::Dockerfile | ManifestKind::DockerCompose => { + Vec::new() + } + ManifestKind::Gradle + | ManifestKind::GradleCatalog + | ManifestKind::GradleProperties + | ManifestKind::GradleWrapper + | ManifestKind::ToolVersions => resolve_project(job, registry, target).await, + } +} + /// Run the dependency-check-updates CLI with the given configuration. /// /// # Errors @@ -225,6 +261,27 @@ where #[allow(clippy::too_many_lines)] #[cfg(not(tarpaulin_include))] pub async fn run(cli: &Cli) -> Result { + let report = execute(cli).await?; + if report.execution_failed { + return Err(report_error(&report)); + } + Ok(report.has_updates()) +} + +fn report_error(report: &RunReport) -> DcuError { + DcuError::PatchFailed { + path: PathBuf::from("."), + detail: report + .diagnostics + .iter() + .map(|d| d.message.as_str()) + .collect::>() + .join("; "), + } +} + +#[cfg(not(tarpaulin_include))] +async fn execute(cli: &Cli) -> Result { // Install rustls crypto provider (reqwest is built with rustls-no-provider). // Idempotent: subsequent calls are no-ops. let _ = rustls::crypto::ring::default_provider().install_default(); @@ -238,6 +295,119 @@ pub async fn run(cli: &Cli) -> Result { })?; debug!(root = %root.display(), "working directory"); + let tool_registry = ToolRegistry::new(); + let result = if cli.local_tools { + crate::local_tools::run(cli, &tool_registry).await + } else { + execute_at(cli, &root, &tool_registry, use_color).await + }; + match result { + Err(e) if cli.format == OutputFormat::JsonReport => { + let report = RunReport { + execution_failed: true, + outcome: if cli.recover.is_some() { + ApplyOutcome::RecoveryRequired + } else if cli.upgrade { + ApplyOutcome::Aborted + } else { + ApplyOutcome::NotRequested + }, + diagnostics: vec![Diagnostic { + code: "execution-failed".into(), + message: diagnostic_message(&e), + path: None, + }], + ..RunReport::default() + }; + report.print_json(cli.format)?; + Ok(report) + } + result => result, + } +} + +fn diagnostic_message(error: &DcuError) -> String { + match error { + DcuError::Io { source, .. } => format!("{error}: {source}"), + DcuError::ManifestParse { detail, .. } | DcuError::PatchFailed { detail, .. } => { + format!("{error}: {detail}") + } + _ => error.to_string(), + } +} + +#[cfg(test)] +pub(crate) async fn run_at( + cli: &Cli, + root: &std::path::Path, + registry: &ToolRegistry, + color: bool, +) -> Result { + let report = execute_at(cli, root, registry, color).await?; + if report.execution_failed { + return Err(report_error(&report)); + } + Ok(report.has_updates()) +} + +pub(crate) async fn execute_at( + cli: &Cli, + root: &std::path::Path, + tool_registry: &ToolRegistry, + use_color: bool, +) -> Result { + execute_at_with_commit(cli, root, tool_registry, use_color, transaction::commit).await +} + +#[allow(clippy::too_many_lines)] +async fn execute_at_with_commit( + cli: &Cli, + root: &std::path::Path, + tool_registry: &ToolRegistry, + use_color: bool, + commit: F, +) -> Result +where + F: FnOnce(&std::path::Path, Vec) -> Result<(), transaction::Failure>, +{ + if let Some(mode) = cli.recover { + let changed = transaction::recover(root, mode == crate::cli::RecoveryMode::Finish) + .map_err(|e| project::error("recovery", e))?; + let report = RunReport { + outcome: if !changed { + ApplyOutcome::NoChanges + } else if mode == crate::cli::RecoveryMode::Finish { + ApplyOutcome::Committed + } else { + ApplyOutcome::RolledBack + }, + ..RunReport::default() + }; + if cli.format.is_json() { + report.print_json(cli.format)?; + } else { + println!("Recovery outcome: {:?}", report.outcome); + } + return Ok(report); + } + let mut registry = tool_registry.fresh_execution(); + if let Some(path) = &cli.maven_config { + registry.private_repositories = crate::maven_access::load(&root.join(path))?; + registry.private_cache = + Some(dependency_check_updates_core::MetadataCache::without_redirects()); + } + let tool_registry = ®istry; + let rule_path = cli.compatibility_file.as_ref().map(|p| root.join(p)); + let rules = crate::compatibility_rules::Rules::load(rule_path.as_deref())?; + if cli.upgrade { + let receipts = transaction::pending(root).map_err(|source| DcuError::Io { + path: root.to_owned(), + source, + })?; + if !receipts.is_empty() { + return pending_report(cli, receipts); + } + } debug!(target = %cli.target, upgrade = cli.upgrade, deep = cli.deep, "options"); if !cli.filter.is_empty() { @@ -248,7 +418,20 @@ pub async fn run(cli: &Cli) -> Result { } // 1. Discover manifests - let manifests = Scanner::discover(&root, cli.manifest.as_deref(), cli.deep)?; + let mut manifests = Scanner::discover(root, cli.manifest.as_deref(), cli.deep)?; + let documents = + project::load_with_discovery(&manifests, root, cli.deep && cli.manifest.is_none())?; + for document in documents.values() { + if document.entries.iter().any(|e| e.requested) + && !manifests.iter().any(|m| m.path == document.path) + { + manifests.push(dependency_check_updates_core::ManifestRef { + path: document.path.clone(), + kind: ManifestKind::from_path(&document.path).expect("recognized context"), + }); + } + } + manifests.sort_by(|a, b| a.path.cmp(&b.path)); info!(count = manifests.len(), "discovered manifests"); for m in &manifests { debug!(path = %m.path.display(), kind = %m.kind, "found manifest"); @@ -264,23 +447,36 @@ pub async fn run(cli: &Cli) -> Result { })?; let display_path = manifest_ref .path - .strip_prefix(&root) + .strip_prefix(root) .unwrap_or(&manifest_ref.path) .display() .to_string(); info!(path = %display_path, kind = %manifest_ref.kind, "processing manifest"); - 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 document = documents.get(&manifest_ref.path).cloned(); + 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), + ManifestKind::Dockerfile => Box::new(DockerfileHandler), + ManifestKind::DockerCompose => Box::new(ComposeHandler), + ManifestKind::Gradle + | ManifestKind::GradleCatalog + | ManifestKind::GradleProperties + | ManifestKind::GradleWrapper + | ManifestKind::ToolVersions => Box::new(ProjectHandler( + document.as_ref().expect("project document").clone(), + )), }; - let parsed = handler.parse(&text, &manifest_ref.path)?; + let mut parsed = handler.parse(&text, &manifest_ref.path)?; + if manifest_ref.kind == ManifestKind::PackageJson + && let Some(document) = &document + { + parsed.dependencies.extend(document.dependencies()); + } let total_deps = parsed.dependencies.len(); debug!(total_deps, "parsed dependencies"); for dep in &parsed.dependencies { @@ -302,9 +498,17 @@ pub async fn run(cli: &Cli) -> Result { text, handler, deps, + document, }); } + let target_receipts = + transaction::pending_for(manifest_jobs.iter().map(|j| j.manifest_ref.path.as_path())) + .map_err(|e| project::error("recovery marker", e))?; + if cli.upgrade && !target_receipts.is_empty() { + return pending_report(cli, target_receipts); + } + // 3. Resolve ALL versions concurrently across all manifests (Promise.all pattern) // Create registries once and share across all manifests of the same kind. let total_deps: usize = manifest_jobs.iter().map(|j| j.deps.len()).sum(); @@ -317,72 +521,64 @@ pub async fn run(cli: &Cli) -> Result { // 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, - ); + let npm_registry = registry_for(&manifest_jobs, ManifestKind::PackageJson, || { + NpmRegistry::with_cache(&tool_registry.endpoints.npm, tool_registry.cache.clone()) + }); + let crates_registry = registry_for(&manifest_jobs, ManifestKind::CargoToml, || { + CratesIoRegistry::with_cache( + &tool_registry.endpoints.crates_io, + tool_registry.cache.clone(), + ) + }); + let pypi_registry = registry_for(&manifest_jobs, ManifestKind::PyProjectToml, || { + PyPiRegistry::with_cache(&tool_registry.endpoints.pypi, tool_registry.cache.clone()) + }); // 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 github_registry = + registry_for_section(&manifest_jobs, DependencySection::GitHubActions, || { + GitHubActionsRegistry::with_base_url(&tool_registry.endpoints.github) + }); + let docker_registry = + registry_for_section(&manifest_jobs, DependencySection::DockerImage, || { + docker_registry(tool_registry.endpoints.docker.as_deref()) + }); let mut resolve_futures = Vec::with_capacity(manifest_jobs.len()); + // Keep the existing GitHub/OCI repository/auth-aware batch deduplication, + // but batch across files rather than caching authenticated HTTP by URL. + let (remote_indices, remote_specs) = remote_specs(&manifest_jobs); for (job_idx, job) in manifest_jobs.iter().enumerate() { - if !job.deps.is_empty() { + if !job.deps.is_empty() + && !matches!( + job.manifest_ref.kind, + ManifestKind::GitHubWorkflow + | ManifestKind::Dockerfile + | ManifestKind::DockerCompose + ) + { 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 => 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 => { - 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(), - }, - }; + let resolved = + resolve_individual_job(job, npm, crates_io, pypi, tool_registry, cli.target) + .await; (job_idx, resolved) }); } } - let resolved_results: Vec<_> = futures::future::join_all(resolve_futures).await; + let (resolved_results, remote_results) = futures::join!( + futures::future::join_all(resolve_futures), + resolve_workflow( + &remote_specs, + github_registry.as_ref(), + docker_registry.as_ref(), + cli.target + ) + ); // Build a vec: job_idx -> resolved versions (dense indices, no HashMap needed) let mut resolved_map: Vec> = @@ -390,77 +586,629 @@ pub async fn run(cli: &Cli) -> Result { for (job_idx, resolved) in resolved_results { resolved_map[job_idx] = Some(resolved); } + for (i, result) in remote_results { + let (job, dep) = remote_indices[i]; + resolved_map[job] + .get_or_insert_with(Vec::new) + .push((dep, result)); + } + for batch in resolved_map.iter_mut().flatten() { + batch.sort_by_key(|(i, _)| *i); + } // 4. Print results and apply updates (sequential — needs ordered output) - let mut any_updates = false; + let mut plans: compatibility::Plans = manifest_jobs + .iter() + .enumerate() + .map(|(i, j)| { + ( + j.manifest_ref.path.clone(), + compute_updates(&j.deps, resolved_map[i].as_deref().unwrap_or(&[])), + ) + }) + .collect(); + let suggestions = crate::compatible::suggest( + &manifest_jobs, + &resolved_map, + &documents, + tool_registry, + cli.target, + &rules, + ) + .await; + if cli.compatible { + for (job_idx, job) in manifest_jobs.iter().enumerate() { + plans + .get_mut(&job.manifest_ref.path) + .expect("job plan") + .retain(|u| { + !job.deps.iter().enumerate().any(|(i, d)| { + suggestions.coupled.contains(&(job_idx, i)) + && u.name == d.name + && u.from == d.current_req + && u.section == d.section + }) + }); + } + for (path, updates) in &suggestions.updates { + plans + .entry(path.clone()) + .or_default() + .extend(updates.iter().cloned()); + } + } + let mut compatibility = std::collections::HashMap::new(); + let mut preparation_errors = std::collections::HashMap::new(); + block_failed_shared_queries( + &manifest_jobs, + &resolved_map, + &documents, + &mut plans, + &mut preparation_errors, + ); + validate_plans( + &documents, + &mut plans, + &mut compatibility, + &mut preparation_errors, + cli.strict_compatibility, + &rules, + ); + let mut sidecars = std::collections::HashMap::new(); + let mut sidecar_errors = std::collections::HashMap::new(); + for job in &manifest_jobs { + let updates = plans.get_mut(&job.manifest_ref.path).expect("job plan"); + if let Some(document) = &job.document { + let mut retained = Vec::new(); + let mut extra = Vec::new(); + let mut errors = Vec::new(); + for update in updates.drain(..) { + match tool_registry + .sidecars(document, std::slice::from_ref(&update)) + .await + { + Ok(patches) => { + extra.extend(patches); + retained.push(update); + } + Err(e) => errors.push((update, e.to_string())), + } + } + *updates = retained; + sidecars.insert(job.manifest_ref.path.clone(), extra); + sidecar_errors.insert(job.manifest_ref.path.clone(), errors); + } + } + // Check the actual retained combination after failed checksum/hash lookups. + validate_plans( + &documents, + &mut plans, + &mut compatibility, + &mut preparation_errors, + cli.strict_compatibility, + &rules, + ); + let mut prepared = std::collections::HashMap::new(); + for job in &manifest_jobs { + let updates = &plans[&job.manifest_ref.path]; + if updates.is_empty() { + continue; + } + let text = if let Some(document) = &job.document { + let extra = sidecars + .remove(&job.manifest_ref.path) + .unwrap_or_default() + .into_iter() + .filter(|p| { + (document + .checksum + .as_ref() + .is_some_and(|span| span.start == p.start && span.end == p.end) + && updates.iter().any(|u| u.name == "gradle")) + || document.entries.iter().any(|e| { + e.integrity + .as_ref() + .is_some_and(|(span, _)| span.start == p.start && span.end == p.end) + && updates + .iter() + .any(|u| u.name == e.dep.name && u.from == e.dep.current_req) + }) + }) + .collect(); + let project_updates: Vec<_> = updates + .iter() + .filter(|u| project_section(u.section)) + .cloned() + .collect(); + let text = document.apply(&job.text, &project_updates, extra)?; + let ordinary: Vec<_> = updates + .iter() + .filter(|u| !project_section(u.section)) + .cloned() + .collect(); + if ordinary.is_empty() { + text + } else { + job.handler.apply_updates(&text, &ordinary)? + } + } else { + job.handler.apply_updates(&job.text, updates)? + }; + prepared.insert(job.manifest_ref.path.clone(), text); + } + let mut report = RunReport { + manifest_count: manifest_jobs.len(), + compatibility_rules: if compatibility.is_empty() { + Vec::new() + } else { + rules.provenance.clone() + }, + ..RunReport::default() + }; + report.diagnostics.extend(suggestions.diagnostics); + if !compatibility.is_empty() { + for provenance in &rules.provenance { + if provenance.stale || provenance.future { + report.diagnostics.push(Diagnostic { code: "compatibility-rules-date".into(), message: format!("compatibility rules verified {} are stale (>180 days) or future-dated; compatibility remains unverified", provenance.verified_at), path: None }); + } + } + } + for (i, job) in manifest_jobs.iter().enumerate() { + let mut rows = report_rows( + job, + resolved_map[i].as_deref().unwrap_or(&[]), + &plans[&job.manifest_ref.path], + compatibility + .get(&job.manifest_ref.path) + .map(String::as_str), + preparation_errors + .get(&job.manifest_ref.path) + .map(String::as_str), + false, + ); + if let Some(errors) = sidecar_errors.get(&job.manifest_ref.path) { + for row in &mut rows { + if let Some((_, error)) = errors.iter().find(|(u, _)| { + u.name == row.name && u.from == row.from && u.section.label() == row.section + }) { + row.status = Status::Blocked; + row.reason = Some(format!("integrity preparation failed: {error}")); + } + } + } + for (dep_idx, row) in rows.iter_mut().enumerate() { + row.compatible = suggestions.choices.get(&(i, dep_idx)).cloned(); + if cli.compatible && suggestions.coupled.contains(&(i, dep_idx)) { + row.selection_policy = Some("bounded verified combination: AGP, Kotlin, Gradle, JDK, SDK descending; no downgrades or channel conversion".into()); + if row.compatible.is_none() && row.status == Status::Current { + row.status = Status::Unverified; + row.reason = Some("no verified combination found within published candidates and documented rules".into()); + } + } + } + report.items.extend(rows.into_iter().map(Item::Project)); + } + let mut pending_receipts = transaction::pending(root).map_err(|source| DcuError::Io { + path: root.to_owned(), + source, + })?; + pending_receipts.extend(target_receipts); + pending_receipts.sort(); + pending_receipts.dedup(); + for path in pending_receipts { + report.diagnostics.push(Diagnostic {code:"pending-recovery".into(),message:format!("unfinished update receipt {}; inspect backups; read-only queries never recover files automatically",path.display()),path:Some(path.display().to_string())}); + } + // Validate output representability before any writes, including conflicting + // versions of a repeated name in legacy's flat update-only object. + if cli.format == OutputFormat::JsonLegacy { + report.json(cli.format)?; + } + if cli.upgrade { + if report + .diagnostics + .iter() + .any(|d| d.code == "pending-recovery") + { + report.outcome = ApplyOutcome::Aborted; + report.execution_failed = true; + } else if cli.fail_on_incomplete && report.incomplete() { + report.outcome = ApplyOutcome::Aborted; + } else if prepared.is_empty() { + report.outcome = ApplyOutcome::NoChanges; + } else { + let changes = manifest_jobs + .iter() + .filter_map(|job| { + prepared + .get(&job.manifest_ref.path) + .map(|text| transaction::Change { + path: job.manifest_ref.path.clone(), + original: job.text.as_bytes().to_vec(), + replacement: text.as_bytes().to_vec(), + }) + }) + .collect(); + match commit(root, changes) { + Ok(()) => { + report.outcome = ApplyOutcome::Committed; + for item in &mut report.items { + if let Item::Project(r) = item { + r.updated = r.to.is_some(); + } + } + } + Err(failure) => { + report.outcome = if failure.recovery_required { + ApplyOutcome::RecoveryRequired + } else if failure.committed { + ApplyOutcome::Committed + } else if failure.rolled_back { + ApplyOutcome::RolledBack + } else { + ApplyOutcome::Aborted + }; + if failure.committed { + for item in &mut report.items { + if let Item::Project(r) = item { + r.updated = r.to.is_some(); + } + } + } + report.execution_failed = true; + report.diagnostics.push(Diagnostic { + code: "apply-failed".into(), + message: failure.detail, + path: None, + }); + } + } + } + } 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)); + let aborted = report.execution_failed || (cli.fail_on_incomplete && report.incomplete()); + for job in &manifest_jobs { + if !aborted { + cleanup_targets.extend(targets_for_job(job, remove_lockfile, remove_installed)); + } + if cli.format == OutputFormat::Table { + print!("{}", output::render_header(&job.display_path, cli.upgrade)); + } if job.deps.is_empty() { - print!( - "{}", - output::render_footer(&job.display_path, cli.upgrade, false, use_color) - ); + if cli.format == OutputFormat::Table { + println!("No matching dependency declarations.\n"); + } continue; } - let resolved = resolved_map[job_idx].as_deref().unwrap_or(&[]); - - let success_count = resolved.iter().filter(|(_, r)| r.is_ok()).count(); - let fail_count = resolved.len() - success_count; - debug!( - resolved = success_count, - failed = fail_count, - "registry resolution complete" - ); - - let updates = compute_updates(&job.deps, resolved); + let updates = &plans[&job.manifest_ref.path]; + let rows: Vec<_> = report + .items + .iter() + .filter_map(|item| match item { + Item::Project(r) if r.manifest == job.display_path => Some(r), + _ => None, + }) + .collect(); + let incomplete = rows.iter().any(|r| r.status.incomplete()); + if cli.format == OutputFormat::Table { + for row in &rows { + if !matches!(row.status, Status::Update | Status::Current) { + println!( + " {} [{:?}] current={} latest={} {}", + row.name, + row.status, + row.from, + row.latest.as_deref().unwrap_or("unknown"), + row.reason.as_deref().unwrap_or("") + ); + } + if let Some(c) = &row.compatibility { + println!(" {}: {c}", row.name); + } + if let Some(candidate) = &row.compatible { + println!( + " {}: verified combination candidate={candidate} (apply with --compatible -u)", + row.name + ); + } + } + } debug!(updates = updates.len(), "computed planned updates"); - for update in &updates { + for update in updates { debug!(name = %update.name, from = %update.from, to = %update.to, "update available"); } if updates.is_empty() { - info!(path = %job.display_path, "all dependencies up to date"); - print!( - "{}", - output::render_footer(&job.display_path, cli.upgrade, false, use_color) - ); + if cli.format == OutputFormat::Table && incomplete { + println!("Some entries could not be checked or safely updated.\n"); + } else if cli.format == OutputFormat::Table { + print!( + "{}", + output::render_footer(&job.display_path, cli.upgrade, false, use_color) + ); + } continue; } - any_updates = true; + if cli.format == OutputFormat::Table { + print!("{}", output::render_table(updates, use_color)); + if cli.upgrade && !matches!(report.outcome, ApplyOutcome::Committed) { + println!("Project changes were not committed.\n"); + } else { + print!( + "{}", + output::render_footer(&job.display_path, cli.upgrade, true, use_color) + ); + } + } + } - match cli.format { - OutputFormat::Table => print!("{}", output::render_table(&updates, use_color)), - OutputFormat::Json => println!("{}", output::render_json(&updates)), + let cleanup = cleanup_with_progress(cleanup_targets).await; + if !cleanup.diagnostics.is_empty() { + report.execution_failed = true; + report.diagnostics.extend(cleanup.diagnostics); + } + if cli.format.is_json() { + report.print_json(cli.format)?; + if !cleanup.summary.is_empty() { + eprint!("{}", cleanup.summary); + } + } else { + print!("{}", cleanup.summary); + for d in &report.diagnostics { + eprintln!("{}: {}", d.code, d.message); } + } + + Ok(report) +} - if cli.upgrade { - info!(path = %job.display_path, count = updates.len(), "applying updates"); - let new_text = job.handler.apply_updates(&job.text, &updates)?; - std::fs::write(&job.manifest_ref.path, new_text).map_err(|e| DcuError::Io { - path: job.manifest_ref.path.clone(), - source: e, - })?; - info!(path = %job.display_path, "manifest updated successfully"); +fn pending_report(cli: &Cli, receipts: Vec) -> Result { + let report = RunReport { execution_failed: true, outcome: ApplyOutcome::Aborted, + diagnostics: receipts.into_iter().map(|path| Diagnostic { code: "pending-recovery".into(), message: format!("unfinished update receipt {}; run --recover from its directory before another update", path.display()), path: Some(path.display().to_string()) }).collect(), ..RunReport::default() }; + if cli.format.is_json() { + report.print_json(cli.format)?; + } else { + for d in &report.diagnostics { + eprintln!("{}: {}", d.code, d.message); } + } + Ok(report) +} - print!( - "{}", - output::render_footer(&job.display_path, cli.upgrade, true, use_color) - ); +fn project_section(section: DependencySection) -> bool { + matches!( + section, + DependencySection::Maven + | DependencySection::GradlePlugin + | DependencySection::AndroidSdk + | DependencySection::Toolchain + ) +} + +fn remote_specs(jobs: &[ManifestJob]) -> (Vec<(usize, usize)>, Vec) { + let indices: Vec<_> = jobs + .iter() + .enumerate() + .flat_map(|(job_idx, job)| { + job.deps + .iter() + .enumerate() + .filter(|(_, dep)| { + matches!( + dep.section, + DependencySection::GitHubActions | DependencySection::DockerImage + ) + }) + .map(move |(dep_idx, _)| (job_idx, dep_idx)) + }) + .collect(); + let specs = indices + .iter() + .map(|&(job, dep)| jobs[job].deps[dep].clone()) + .collect(); + (indices, specs) +} + +fn block_failed_shared_queries( + jobs: &[ManifestJob], + results: &[Option], + documents: &std::collections::HashMap, + plans: &mut compatibility::Plans, + errors: &mut std::collections::HashMap, +) { + for (job_idx, job) in jobs.iter().enumerate() { + for (dep_idx, result) in results[job_idx].as_deref().unwrap_or(&[]) { + let Err(error) = result else { + continue; + }; + let Some(entry) = source_entry(job, *dep_idx) else { + continue; + }; + let Some(span) = &entry.span else { + continue; + }; + let doc = &documents[&job.manifest_ref.path]; + let consumers: Vec<_> = doc + .entries + .iter() + .filter(|e| e.span.as_ref() == Some(span)) + .collect(); + let updates = plans.get_mut(&job.manifest_ref.path).unwrap(); + let before = updates.len(); + updates.retain(|u| { + !consumers.iter().any(|e| { + e.dep.name == u.name + && e.dep.current_req == u.from + && e.dep.section == u.section + }) + }); + if updates.len() != before { + errors.insert( + job.manifest_ref.path.clone(), + format!("shared-source consumer could not be checked; preserved: {error}"), + ); + } + } + } +} + +fn validate_plans( + documents: &std::collections::HashMap, + plans: &mut compatibility::Plans, + statuses: &mut std::collections::HashMap, + errors: &mut std::collections::HashMap, + strict: bool, + rules: &crate::compatibility_rules::Rules, +) { + loop { + let before: usize = plans.values().map(Vec::len).sum(); + for (path, status) in compatibility::guard(documents, plans, strict, rules) { + if !statuses.get(&path).is_some_and(|s| { + s.starts_with("conflict:") || s.contains("strict compatibility blocks") + }) { + statuses.insert(path, status); + } + } + errors.extend(project::guard_shared_versions(documents, plans)); + if plans.values().map(Vec::len).sum::() == before { + break; + } } +} - print!("{}", cleanup_with_progress(cleanup_targets).await); +async fn resolve_project( + job: &ManifestJob, + registry: &ToolRegistry, + target: TargetLevel, +) -> ResolvedBatch { + let futures = job + .deps + .iter() + .enumerate() + .filter(|(_, d)| project_section(d.section)) + .map(|(i, dep)| async move { + let result = match source_entry(job, i) { + Some(entry) => registry.resolve(entry, target).await, + None => Err(project::error(&dep.name, "source declaration not found")), + }; + (i, result) + }); + futures::future::join_all(futures).await +} - Ok(any_updates) +pub(crate) fn source_entry(job: &ManifestJob, index: usize) -> Option<&project::Entry> { + let dep = &job.deps[index]; + let same = |d: &DependencySpec| { + d.name == dep.name && d.current_req == dep.current_req && d.section == dep.section + }; + let ordinal = job.deps[..index].iter().filter(|d| same(d)).count(); + job.document + .as_ref()? + .entries + .iter() + .filter(|e| e.requested && same(&e.dep)) + .nth(ordinal) +} + +pub(crate) fn report_rows( + job: &ManifestJob, + resolved: &[(usize, Result)], + updates: &[dependency_check_updates_core::PlannedUpdate], + compatibility: Option<&str>, + preparation_error: Option<&str>, + upgrade: bool, +) -> Vec { + job.deps + .iter() + .enumerate() + .map(|(i, dep)| { + let result = resolved.iter().find(|(idx, _)| *idx == i).map(|(_, r)| r); + let update = updates.iter().find(|u| { + u.name == dep.name && u.from == dep.current_req && u.section == dep.section + }); + let mut status = if update.is_some() { + Status::Update + } else { + Status::Current + }; + let mut reason = None; + let (latest, selected) = match result { + Some(Ok(r)) => (r.latest.clone(), r.selected.clone()), + _ => (None, None), + }; + if let Some(e) = source_entry(job, i).and_then(|e| e.reason.clone()) { + status = if e.starts_with("channel preserved") { + Status::Channel + } else { + Status::Unsupported + }; + reason = Some(e); + } + if let Some(Err(e)) = result { + if status != Status::Unsupported { + status = Status::Failed; + } + reason = Some(e.to_string()); + } + if result.is_none() { + status = Status::Failed; + reason = Some("no registry result".into()); + } + if result.is_some_and(|r| r.as_ref().is_ok_and(|r| r.selected.is_none())) + && status == Status::Current + { + status = Status::Unverified; + reason = Some("no candidate for requested target".into()); + } + let compatibility = compatibility.filter(|_| { + project_section(dep.section) && crate::compatibility::related(&dep.name) + }); + if let Some(c) = compatibility { + if c.starts_with("conflict:") { + status = Status::Blocked; + reason = Some(c.to_owned()); + } else if c.starts_with("unverified:") && status == Status::Current { + status = Status::Unverified; + reason = Some(c.to_owned()); + } + } + if let Some(e) = preparation_error.filter(|_| update.is_none()) { + status = Status::Blocked; + reason = Some(e.to_owned()); + } + let selection_policy = (matches!( + dep.section, + DependencySection::Maven + | DependencySection::GradlePlugin + | DependencySection::AndroidSdk + ) || (dep.section == DependencySection::Toolchain + && dep.name == "jdk")) + .then(|| { + "newest falls back to greatest: metadata has no per-version publication date" + .to_owned() + }); + ProjectRow { + manifest: job.display_path.clone(), + name: dep.name.clone(), + section: dep.section.label().into(), + from: dep.current_req.clone(), + to: update.map(|u| u.to.clone()), + latest, + selected, + compatible: None, + status, + reason, + compatibility: compatibility.map(str::to_owned), + selection_policy, + updated: upgrade && update.is_some(), + } + }) + .collect() } /// Intermediate state for processing a single manifest. @@ -468,8 +1216,9 @@ 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: &'static (dyn ManifestHandler + Send + Sync), + pub(crate) handler: Box, pub(crate) deps: Vec, + pub(crate) document: Option, } #[cfg(test)] @@ -479,6 +1228,422 @@ mod tests { use rstest::rstest; use std::path::PathBuf; + #[tokio::test] + async fn recovery_notices_receipts_from_parent_transactions_and_late_concurrent_writes() { + use crate::tool_registry::Endpoints; + use clap::Parser; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let child = dir.path().join("child"); + std::fs::create_dir(&child).unwrap(); + let node = child.join(".nvmrc"); + std::fs::write(&node, "20\n").unwrap(); + let registry = ToolRegistry::with_endpoints(Endpoints { + node: format!("{}/node", server.uri()), + ..Endpoints::default() + }); + let failure = transaction::commit_with( + dir.path(), + vec![transaction::Change { + path: node.clone(), + original: b"20\n".to_vec(), + replacement: b"22\n".to_vec(), + }], + |step, _, _| { + if step == transaction::Step::Finalize { + Err("interrupted finalize".into()) + } else { + Ok(()) + } + }, + ) + .unwrap_err(); + assert!(failure.recovery_required); + let cli = Cli::parse_from(["dcu", "-u", "--format", "json-report"]); + let report = execute_at(&cli, &child, ®istry, false).await.unwrap(); + assert_eq!(report.outcome, ApplyOutcome::Aborted); + assert!( + report + .diagnostics + .iter() + .any(|d| d.code == "pending-recovery") + ); + transaction::recover(dir.path(), false).unwrap(); + let late_receipt = child.join(".dcu-transaction-concurrent.json"); + Mock::given(path("/node")) + .respond_with(move |_: &wiremock::Request| { + std::fs::write(&late_receipt, "another process owns this receipt").unwrap(); + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{"version":"v22.0.0","lts":true}])) + }) + .mount(&server) + .await; + let report = execute_at(&cli, &child, ®istry, false).await.unwrap(); + assert_eq!(report.outcome, ApplyOutcome::Aborted); + assert!(report.execution_failed); + assert_eq!(std::fs::read_to_string(node).unwrap(), "20\n"); + } + + #[tokio::test] + async fn table_recovery_and_json_cleanup_report_the_actual_outcome() { + use crate::tool_registry::Endpoints; + use clap::Parser; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let cli = Cli::parse_from(["dcu", "--recover", "rollback"]); + let registry = ToolRegistry::with_endpoints(Endpoints { + npm: server.uri(), + ..Endpoints::default() + }); + assert_eq!( + execute_at(&cli, dir.path(), ®istry, false) + .await + .unwrap() + .outcome, + ApplyOutcome::NoChanges + ); + Mock::given(path("/react")).respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"dist-tags":{"latest":"2.0.0"},"versions":{"1.0.0":{},"2.0.0":{}}}))).mount(&server).await; + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"react":"1.0.0"}}"#, + ) + .unwrap(); + std::fs::write(dir.path().join("bun.lock"), "test lock").unwrap(); + let cli = Cli::parse_from(["dcu", "-u", "--rm", "--format", "json-report"]); + assert_eq!( + execute_at(&cli, dir.path(), ®istry, false) + .await + .unwrap() + .outcome, + ApplyOutcome::Committed + ); + assert!(!dir.path().join("bun.lock").exists()); + } + + #[tokio::test] + async fn compatible_mode_does_not_claim_unverified_current_pins_are_current() { + use crate::tool_registry::Endpoints; + use clap::Parser; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/gradle")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([{"version":"8.9"}])), + ) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("gradle/wrapper")).unwrap(); + std::fs::write( + dir.path().join("gradle/wrapper/gradle-wrapper.properties"), + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("build.gradle.kts"), + "includeBuild(dynamicPath)\n", + ) + .unwrap(); + let cli = Cli::parse_from(["dcu", "-d", "--compatible", "--format", "json-report"]); + let registry = ToolRegistry::with_endpoints(Endpoints { + gradle: format!("{}/gradle", server.uri()), + ..Endpoints::default() + }); + let report = execute_at(&cli, dir.path(), ®istry, false) + .await + .unwrap(); + let row = report + .items + .iter() + .find_map(|i| match i { + Item::Project(r) if r.name == "gradle" => Some(r), + _ => None, + }) + .unwrap(); + assert_eq!(row.status, Status::Unverified); + assert!(row.reason.as_ref().unwrap().contains("unverified")); + std::fs::write(dir.path().join("build.gradle.kts"), format!("repositories {{ maven(\"{}\") }}\nclasspath(\"org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25\")\nplugins {{ id(\"org.jetbrains.kotlin.jvm\") version \"2.0.0\" }}\n", server.uri())).unwrap(); + std::fs::write( + dir.path().join("gradle/wrapper/gradle-wrapper.properties"), + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.0-bin.zip\n", + ) + .unwrap(); + std::fs::write(dir.path().join(".tool-versions"), "java 17\n").unwrap(); + Mock::given(path("/jdk")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"versions":[{"semver":"17.0.0"}]})), + ) + .mount(&server) + .await; + Mock::given(path("/gradle-two")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([{"version":"8.0"}])), + ) + .mount(&server) + .await; + let registry = ToolRegistry::with_endpoints(Endpoints { + gradle: format!("{}/gradle-two", server.uri()), + jdk: format!("{}/jdk", server.uri()), + ..Endpoints::default() + }); + Mock::given(path("/org/jetbrains/kotlin/kotlin-gradle-plugin/maven-metadata.xml")).respond_with(ResponseTemplate::new(200).set_body_string("1.9.252.0.0")).mount(&server).await; + let cli = Cli::parse_from([ + "dcu", + "-d", + "--compatible", + "--target", + "patch", + "--format", + "json-report", + ]); + let report = execute_at(&cli, dir.path(), ®istry, false) + .await + .unwrap(); + assert!(report.items.iter().any(|i| matches!(i, Item::Project(row) if row.status == Status::Unverified && row.reason.as_ref().is_some_and(|r| r.contains("no verified combination"))))); + } + + #[tokio::test] + async fn update_failure_reports_match_actual_transaction_outcomes() { + use crate::tool_registry::Endpoints; + use clap::Parser; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/node")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{ "version":"v22.0.0", "lts":"Fixed" }])), + ) + .mount(&server) + .await; + let registry = ToolRegistry::with_endpoints(Endpoints { + node: format!("{}/node", server.uri()), + ..Endpoints::default() + }); + for (step, expected, committed) in [ + (transaction::Step::Stage, ApplyOutcome::Aborted, false), + (transaction::Step::Commit, ApplyOutcome::RolledBack, false), + ( + transaction::Step::Finalize, + ApplyOutcome::RecoveryRequired, + true, + ), + ( + transaction::Step::AfterReplace, + ApplyOutcome::Committed, + true, + ), + ] { + let dir = tempfile::TempDir::new().unwrap(); + for name in [".nvmrc", ".node-version"] { + std::fs::write(dir.path().join(name), "20.0.0\n").unwrap(); + } + let cli = Cli::parse_from(["dcu", "-d", "-u", "--format", "json-report"]); + let report = + execute_at_with_commit(&cli, dir.path(), ®istry, false, |root, changes| { + if step == transaction::Step::AfterReplace { + transaction::commit(root, changes)?; + return Err(transaction::Failure { + detail: "post-commit cleanup failed".into(), + committed: true, + recovery_required: false, + rolled_back: false, + }); + } + transaction::commit_with(root, changes, |at, i, _| { + if at == step && (step != transaction::Step::Commit || i == 1) { + Err("injected failure".into()) + } else { + Ok(()) + } + }) + }) + .await + .unwrap(); + assert_eq!(report.outcome, expected); + assert!(report.execution_failed); + assert_eq!(report.exit_code(&cli), 1); + assert!( + report + .items + .iter() + .all(|item| matches!(item, Item::Project(row) if row.updated == committed)) + ); + for name in [".nvmrc", ".node-version"] { + assert_eq!( + std::fs::read_to_string(dir.path().join(name)).unwrap(), + if committed { "22.0.0\n" } else { "20.0.0\n" } + ); + } + } + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join(".dcu-transaction-blocked.json"), "{}").unwrap(); + let cli = Cli::parse_from(["dcu", "-u"]); + assert!( + run_at(&cli, dir.path(), ®istry, false) + .await + .unwrap_err() + .to_string() + .contains("patch") + ); + for error in [ + DcuError::PatchFailed { + path: PathBuf::from("file"), + detail: "specific detail".into(), + }, + DcuError::ManifestParse { + path: PathBuf::from("file"), + detail: "specific detail".into(), + }, + ] { + assert!(diagnostic_message(&error).contains("specific detail")); + } + } + + #[tokio::test] + async fn fixed_metadata_updates_all_existing_ecosystems_through_the_pipeline() { + use crate::tool_registry::Endpoints; + use clap::Parser; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + for (url, body) in [ + ( + "/npm/react", + serde_json::json!({"dist-tags":{"latest":"2.0.0"},"versions":{"1.0.0":{},"2.0.0":{}}}), + ), + ( + "/crates/serde/versions", + serde_json::json!({"versions":[{"num":"1.0.0","yanked":false},{"num":"2.0.0","yanked":false}]}), + ), + ( + "/pytest/json", + serde_json::json!({"info":{"version":"2.0.0"},"releases":{"1.0.0":[{}],"2.0.0":[{}]}}), + ), + ( + "/repos/actions/checkout/tags", + serde_json::json!([{"name":"v4"},{"name":"v5"}]), + ), + ( + "/v2/library/node/tags/list", + serde_json::json!({"name":"library/node","tags":["20","22"]}), + ), + ] { + Mock::given(path(url)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + } + let registry = ToolRegistry::with_endpoints(Endpoints { + npm: format!("{}/npm", server.uri()), + crates_io: server.uri(), + pypi: server.uri(), + github: server.uri(), + docker: Some(server.uri()), + ..Endpoints::default() + }); + let dir = tempfile::TempDir::new().unwrap(); + for (name, text) in [ + ("package.json", r#"{"dependencies":{"react":"^1.0.0"}}"#), + ( + "Cargo.toml", + "[package]\nname='fixture'\nversion='0.1.0'\n[dependencies]\nserde='1.0.0'\n", + ), + ( + "pyproject.toml", + "[project]\nname='fixture'\ndependencies=['pytest>=1.0.0']\n", + ), + ("Dockerfile", "FROM node:20\n"), + ("compose.yml", "services:\n app:\n image: node:20\n"), + ( + ".github/workflows/test.yml", + "jobs:\n test:\n runs-on: ubuntu-latest\n container:\n image: node:20\n steps:\n - uses: actions/checkout@v4\n", + ), + ] { + let path = dir.path().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + let cli = Cli::parse_from(["dcu", "-d", "-u", "--format", "json-report"]); + let report = execute_at(&cli, dir.path(), ®istry, false) + .await + .unwrap(); + assert_eq!(report.outcome, ApplyOutcome::Committed); + assert_eq!(report.items.len(), 7); + assert!(!report.incomplete()); + assert!( + report + .items + .iter() + .all(|item| matches!(item, Item::Project(row) if row.updated)) + ); + assert!( + std::fs::read_to_string(dir.path().join("Cargo.toml")) + .unwrap() + .contains("2.0.0") + ); + assert!( + std::fs::read_to_string(dir.path().join("pyproject.toml")) + .unwrap() + .contains("2.0.0") + ); + let _ = docker_registry(None); // Client construction never sends a request. + let remote = job( + ManifestKind::GitHubWorkflow, + vec![dep("actions/checkout", DependencySection::GitHubActions)], + ); + assert!( + resolve_individual_job(&remote, None, None, None, ®istry, TargetLevel::Latest) + .await + .is_empty() + ); + } + + #[tokio::test] + async fn missing_sources_and_missing_candidates_are_incomplete_not_current() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let job = job( + ManifestKind::Gradle, + vec![dep("group:artifact", DependencySection::Maven)], + ); + let rows = report_rows(&job, &[], &[], None, None, false); + assert_eq!(rows[0].status, Status::Failed); + let batch = vec![( + 0, + Ok(ResolvedVersion { + latest: None, + selected: None, + }), + )]; + assert_eq!( + report_rows(&job, &batch, &[], None, None, false)[0].status, + Status::Unverified + ); + let registry = ToolRegistry::new(); + let result = resolve_project(&job, ®istry, TargetLevel::Latest).await; + assert!( + result[0] + .1 + .as_ref() + .unwrap_err() + .to_string() + .contains("source declaration") + ); + block_failed_shared_queries( + &[job], + &[Some(result)], + &std::collections::HashMap::new(), + &mut compatibility::Plans::new(), + &mut std::collections::HashMap::new(), + ); + } + fn dep(name: &str, section: DependencySection) -> DependencySpec { DependencySpec { name: name.to_owned(), @@ -496,8 +1661,9 @@ mod tests { }, display_path: "manifest".to_owned(), text: String::new(), - handler: &NODE_HANDLER, + handler: Box::new(NodeHandler), deps, + document: None, } } @@ -511,6 +1677,76 @@ mod tests { ) } + #[tokio::test] + async fn remote_batch_shares_requests_across_manifests_and_preserves_indices() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/actions/checkout/tags")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + {"name":"v4"}, {"name":"v5"} + ]))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v2/library/node/tags/list")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name":"library/node", "tags":["20", "22", "20-alpine", "22-alpine"] + }))) + .expect(1) + .mount(&server) + .await; + let spec = |name: &str, version: &str, section| { + let mut d = dep(name, section); + d.current_req = version.to_owned(); + d + }; + let jobs = vec![ + job( + ManifestKind::PackageJson, + vec![dep("react", DependencySection::Dependencies)], + ), + job( + ManifestKind::GitHubWorkflow, + vec![ + spec("actions/checkout", "v4", DependencySection::GitHubActions), + spec("node", "20-alpine", DependencySection::DockerImage), + ], + ), + job( + ManifestKind::Dockerfile, + vec![spec("node", "20", DependencySection::DockerImage)], + ), + job( + ManifestKind::GitHubWorkflow, + vec![spec( + "actions/checkout", + "v4", + DependencySection::GitHubActions, + )], + ), + ]; + let (indices, specs) = remote_specs(&jobs); + assert_eq!(indices, vec![(1, 0), (1, 1), (2, 0), (3, 0)]); + let github = GitHubActionsRegistry::with_base_url(&server.uri()); + let docker = DockerRegistry::with_base_url(&server.uri()); + let results = + resolve_workflow(&specs, Some(&github), Some(&docker), TargetLevel::Latest).await; + let mapped: std::collections::HashMap<_, _> = results + .into_iter() + .map(|(i, r)| (indices[i], r.unwrap().selected.unwrap())) + .collect(); + assert_eq!(mapped[&(1, 0)], "5"); + assert_eq!(mapped[&(1, 1)], "22-alpine"); + assert_eq!(mapped[&(2, 0)], "22"); + assert_eq!(mapped[&(3, 0)], "5"); + } + /// 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. diff --git a/crates/cli/src/tool_registry.rs b/crates/cli/src/tool_registry.rs new file mode 100644 index 0000000..0d78447 --- /dev/null +++ b/crates/cli/src/tool_registry.rs @@ -0,0 +1,917 @@ +//! Official metadata clients for Maven, Gradle distributions and project tools. +use crate::project::{Document, Entry, error}; +use dependency_check_updates_core::{ + DcuError, DependencySection as Section, MetadataCache, Patch, PlannedUpdate, ResolvedVersion, + TargetLevel, highest_stable, pad_to_three_segments, select_version, +}; +use serde_json::Value; +use sha1::Sha1; +use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; +use std::fmt::Write; +use std::sync::Arc; + +pub(crate) struct ToolRegistry { + pub cache: MetadataCache, + pub endpoints: Endpoints, + npm: dependency_check_updates_node::NpmRegistry, + pub private_repositories: std::collections::HashMap, + pub private_cache: Option, +} + +#[derive(Clone)] +pub(crate) struct Endpoints { + pub gradle: String, + pub distributions: String, + pub node: String, + pub rust: String, + pub github: String, + pub android: String, + pub jdk: String, + pub npm: String, + pub crates_io: String, + pub pypi: String, + pub docker: Option, + pub yarn: String, + pub yarn_downloads: String, +} + +impl Default for Endpoints { + fn default() -> Self { + Self { + gradle: "https://services.gradle.org/versions/all".into(), + distributions: "https://services.gradle.org/distributions".into(), + node: "https://nodejs.org/dist/index.json".into(), + rust: "https://static.rust-lang.org/dist/channel-rust-stable.toml".into(), + github: "https://api.github.com".into(), + android: "https://dl.google.com/android/repository/repository2-1.xml".into(), + jdk: "https://api.adoptium.net/v3/info/release_versions?image_type=jdk&release_type=ga&page_size=50&sort_method=DATE&sort_order=DESC".into(), + npm: "https://registry.npmjs.org".into(), + crates_io: "https://crates.io/api/v1".into(), + pypi: "https://pypi.org/pypi".into(), + docker: None, + yarn: "https://repo.yarnpkg.com/tags".into(), + yarn_downloads: "https://repo.yarnpkg.com".into(), + } + } +} + +impl ToolRegistry { + pub fn fresh_execution(&self) -> Self { + let mut result = Self::with_endpoints(self.endpoints.clone()); + result + .private_repositories + .clone_from(&self.private_repositories); + result.private_cache = self + .private_cache + .as_ref() + .map(|_| MetadataCache::without_redirects()); + result + } + pub fn new() -> Self { + Self::with_endpoints(Endpoints::default()) + } + pub fn with_endpoints(endpoints: Endpoints) -> Self { + let cache = MetadataCache::new(); + Self { + npm: dependency_check_updates_node::NpmRegistry::with_cache( + &endpoints.npm, + cache.clone(), + ), + cache, + endpoints, + private_repositories: std::collections::HashMap::new(), + private_cache: None, + } + } + + async fn text(&self, url: &str, name: &str) -> Result { + let bytes = self.bytes(url, name, MetadataCache::METADATA_LIMIT).await?; + String::from_utf8(bytes.to_vec()).map_err(|e| error(name, e.to_string())) + } + async fn bytes(&self, url: &str, name: &str, limit: usize) -> Result, DcuError> { + self.cache + .get(url, reqwest::header::HeaderMap::new(), limit, name) + .await + } + async fn json(&self, url: &str, name: &str) -> Result { + serde_json::from_str(&self.text(url, name).await?).map_err(|e| error(name, e.to_string())) + } + + #[allow(clippy::too_many_lines)] + pub async fn resolve( + &self, + entry: &Entry, + target: TargetLevel, + ) -> Result { + let dep = &entry.dep; + if entry + .reason + .as_ref() + .is_some_and(|r| !r.starts_with("channel preserved")) + { + return Err(error(&dep.name, entry.reason.clone().unwrap())); + } + if matches!(dep.section, Section::Maven | Section::GradlePlugin) { + return self.maven(entry, target).await; + } + if dep.name == "yarn" + && dep + .current_req + .trim_start_matches('v') + .split('.') + .next() + .and_then(|v| v.parse::().ok()) + .is_some_and(|v| v >= 2) + { + let json = self.json(&self.endpoints.yarn, "yarn").await?; + let tags = &json["tags"]; + let versions = if let Some(tags) = tags.as_array() { + tags.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect::>() + } else if let Some(tags) = tags.as_object() { + tags.keys().cloned().collect() + } else { + return Err(error("yarn", "invalid official Yarn tags")); + }; + return choose(&versions, &dep.current_req, target, None); + } + let (versions, newest) = match dep.name.as_str() { + "npm" | "pnpm" | "yarn" => { + let spec = dep.clone(); + let result = self.npm.resolve_batch(&[spec], target).await; + return result + .into_iter() + .next() + .ok_or_else(|| error(&dep.name, "empty npm response"))? + .1; + } + "gradle" => { + let json = self.json(&self.endpoints.gradle, "gradle").await?; + let rows = json + .as_array() + .ok_or_else(|| error("gradle", "invalid distribution metadata"))?; + let all: Vec<_> = rows + .iter() + .filter(|r| r.get("snapshot").and_then(Value::as_bool) != Some(true)) + .filter_map(|r| r.get("version").and_then(Value::as_str).map(str::to_owned)) + .collect(); + let newest = rows + .iter() + .filter(|r| r.get("snapshot").and_then(Value::as_bool) != Some(true)) + .max_by_key(|r| r.get("buildTime").and_then(Value::as_str).unwrap_or("")) + .and_then(|r| r.get("version")) + .and_then(Value::as_str) + .map(str::to_owned); + (all, newest) + } + "node" => { + let json = self.json(&self.endpoints.node, "node").await?; + let rows = json + .as_array() + .ok_or_else(|| error("node", "invalid release index"))?; + let lts = dep.current_req.starts_with("lts") || dep.current_req == "--lts"; + let all = rows + .iter() + .filter(|r| !lts || r.get("lts").is_some_and(|v| v != &Value::Bool(false))) + .filter(|r| { + dep.current_req + .strip_prefix("lts/") + .filter(|s| *s != "*") + .is_none_or(|channel| { + r.get("lts") + .and_then(Value::as_str) + .is_some_and(|name| name.eq_ignore_ascii_case(channel)) + }) + }) + .filter_map(|r| { + r.get("version") + .and_then(Value::as_str) + .map(|v| v.trim_start_matches('v').to_owned()) + }) + .collect(); + let newest = rows + .iter() + .max_by_key(|r| r.get("date").and_then(Value::as_str).unwrap_or("")) + .and_then(|r| r.get("version")) + .and_then(Value::as_str) + .map(|v| v.trim_start_matches('v').to_owned()); + (all, newest) + } + "rust" => { + let channel = if matches!(dep.current_req.as_str(), "beta" | "nightly") { + dep.current_req.as_str() + } else { + "stable" + }; + let url = self + .endpoints + .rust + .replace("channel-rust-stable", &format!("channel-rust-{channel}")); + let text = self.text(&url, "rust").await?; + let doc = text + .parse::() + .map_err(|e| error("rust", e.to_string()))?; + let version = doc["pkg"]["rust"]["version"] + .as_str() + .and_then(|v| v.split_whitespace().next()) + .ok_or_else(|| error("rust", "missing stable Rust version"))?; + if entry.reason.is_some() { + return Ok(ResolvedVersion { + latest: Some(version.to_owned()), + selected: None, + }); + } + if matches!(target, TargetLevel::Minor | TargetLevel::Patch) { + let json = self + .json( + &format!( + "{}/repos/rust-lang/rust/releases?per_page=100", + self.endpoints.github + ), + "rust", + ) + .await?; + (release_tags(&json, ""), None) + } else { + (vec![version.to_owned()], Some(version.to_owned())) + } + } + "bun" => { + let json = self + .json( + &format!( + "{}/repos/oven-sh/bun/releases?per_page=100", + self.endpoints.github + ), + "bun", + ) + .await?; + let newest = json + .as_array() + .and_then(|rows| { + rows.iter() + .filter(|r| r.get("draft") != Some(&Value::Bool(true))) + .max_by_key(|r| { + r.get("published_at").and_then(Value::as_str).unwrap_or("") + }) + }) + .and_then(|r| r.get("tag_name")) + .and_then(Value::as_str) + .map(|v| v.trim_start_matches("bun-v").to_owned()); + (release_tags(&json, "bun-v"), newest) + } + "android.compileSdk" | "android.targetSdk" => { + let text = self.text(&self.endpoints.android, "android-sdk").await?; + validate_xml(&text, "android-sdk")?; + // SDK repository platform packages; preview packages carry a suffix and do not match. + let regex=regex::Regex::new(r#"]*path="platforms;android-([0-9]+)"[^>]*>([\s\S]*?)"#).unwrap(); + let all = regex + .captures_iter(&text) + .filter(|c| { + !c[2].contains("") && !c[2].contains("true") + }) + .map(|c| c[1].to_owned()) + .collect(); + (all, None) + } + "jdk" => { + let json = self.json(&self.endpoints.jdk, "jdk").await?; + let all = json + .get("versions") + .and_then(Value::as_array) + .ok_or_else(|| error("jdk", "missing release index"))? + .iter() + .filter_map(|v| v.get("semver").and_then(Value::as_str).map(str::to_owned)) + .collect(); + (all, None) + } + name => return Err(error(name, "unsupported development tool")), + }; + let mut result = choose(&versions, &dep.current_req, target, newest.as_deref())?; + if entry.reason.is_some() { + result.selected = None; + } + // Short tool pins intentionally track a release line. Keep their precision. + if dep.section == Section::Toolchain && dep.name != "gradle" { + let precision = dep.current_req.trim_start_matches('v').split('.').count(); + if let Some(selected) = &mut result.selected { + *selected = selected + .split('.') + .take(precision) + .collect::>() + .join("."); + } + } + Ok(result) + } + + async fn maven(&self, entry: &Entry, target: TargetLevel) -> Result { + choose( + &self.maven_versions(entry).await?, + &entry.dep.current_req, + target, + None, + ) + } + + async fn maven_versions(&self, entry: &Entry) -> Result, DcuError> { + let name = &entry.dep.name; + let coordinate = if entry.dep.section == Section::GradlePlugin { + match name.as_str() { + "com.android.application" | "com.android.library" => { + "com.android.tools.build:gradle".to_owned() + } + "org.jetbrains.kotlin.android" + | "org.jetbrains.kotlin.jvm" + | "org.jetbrains.kotlin.multiplatform" => { + "org.jetbrains.kotlin:kotlin-gradle-plugin".to_owned() + } + _ => format!("{name}:{name}.gradle.plugin"), + } + } else { + name.clone() + }; + let (group, artifact) = coordinate + .split_once(':') + .ok_or_else(|| error(name, "invalid Maven coordinate"))?; + if !group + .bytes() + .chain(artifact.bytes()) + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err(error(name, "unsupported Maven coordinate")); + } + if entry.repositories.is_empty() { + return Err(error(name, "no statically declared supported repository")); + } + let mut failures = Vec::new(); + let mut not_found = Vec::new(); + let mut versions = Vec::new(); + for repo in &entry.repositories { + let normalized = crate::maven_access::normalize(repo).ok(); + let configured = normalized + .as_ref() + .and_then(|r| self.private_repositories.get(r)); + if !public_repository(repo) && configured.is_none() { + failures.push("private or unsupported repository: authorize a literal endpoint with --maven-config".into()); + continue; + } + let url = format!( + "{}/{}/{}/maven-metadata.xml", + repo.trim_end_matches('/'), + group.replace('.', "/"), + artifact + ); + let text = if let Some(headers) = configured { + let bytes = self + .private_cache + .as_ref() + .expect("configured private cache") + .get(&url, headers.clone(), MetadataCache::METADATA_LIMIT, name) + .await; + bytes.and_then(|b| { + String::from_utf8(b.to_vec()) + .map_err(|_| error(name, "non-UTF-8 Maven metadata")) + }) + } else { + self.text(&url, name).await + }; + match text.and_then(|text| xml_versions(&text, name)) { + Ok(v) => versions.extend(v), + Err(e) if matches!(&e,DcuError::RegistryLookup {detail,..} if detail.starts_with("HTTP 404 ") || detail=="HTTP 404") => + { + not_found.push(e.to_string()); + } + Err(e) => failures.push(e.to_string()), + } + } + if versions.is_empty() { + failures.extend(not_found); + return Err(error(name, failures.join("; "))); + } + // An inaccessible declared repository could contain a newer release. Do + // not call a partial result "latest" or auto-apply it. 404 is normal + // when searching multiple public repositories for one artifact. + if !failures.is_empty() { + return Err(error(name, failures.join("; "))); + } + Ok(versions) + } + + /// Published candidates for the bounded coupled-tool search. Cache reuses + /// the same authenticated metadata as normal selection; never invent pins. + pub async fn candidates( + &self, + entry: &Entry, + target: TargetLevel, + selected: &str, + ) -> Result, DcuError> { + let versions = if matches!(entry.dep.section, Section::Maven | Section::GradlePlugin) { + self.maven_versions(entry).await? + } else { + match entry.dep.name.as_str() { + "gradle" => self + .json(&self.endpoints.gradle, "gradle") + .await? + .as_array() + .ok_or_else(|| error("gradle", "invalid distribution metadata"))? + .iter() + .filter(|r| r.get("snapshot") != Some(&Value::Bool(true))) + .filter_map(|r| r["version"].as_str().map(str::to_owned)) + .collect(), + "jdk" => self.json(&self.endpoints.jdk, "jdk").await?["versions"] + .as_array() + .ok_or_else(|| error("jdk", "invalid release index"))? + .iter() + .filter_map(|r| r["semver"].as_str().map(str::to_owned)) + .collect(), + "android.compileSdk" | "android.targetSdk" => { + let text = self.text(&self.endpoints.android, "android SDK").await?; + let regex = regex::Regex::new(r#"]*path="platforms;android-([0-9]+)"[^>]*>([\s\S]*?)"#).unwrap(); + regex + .captures_iter(&text) + .filter(|c| { + !c[2].contains("") + && !c[2].contains("true") + }) + .map(|c| c[1].to_owned()) + .collect() + } + _ => return Err(error(&entry.dep.name, "not a coupled tool")), + } + }; + let parse = |v: &str| { + semver::Version::parse(&pad_to_three_segments(v.trim_start_matches('v'))).ok() + }; + let current = parse(&entry.dep.current_req) + .ok_or_else(|| error(&entry.dep.name, "non-numeric pin"))?; + let upper = + parse(selected).ok_or_else(|| error(&entry.dep.name, "non-numeric selection"))?; + let mut versions: Vec<_> = versions + .into_iter() + .filter_map(|v| { + let parsed = parse(&v)?; + let written = + if entry.dep.section == Section::Toolchain && entry.dep.name != "gradle" { + parse( + &v.split('.') + .take( + entry + .dep + .current_req + .trim_start_matches('v') + .split('.') + .count(), + ) + .collect::>() + .join("."), + )? + } else { + parsed.clone() + }; + (parsed.pre.is_empty() + && parsed >= current + && written <= upper + && match target { + TargetLevel::Minor => parsed.major == current.major, + TargetLevel::Patch => { + parsed.major == current.major && parsed.minor == current.minor + } + _ => true, + }) + .then_some((parsed, v)) + }) + .collect(); + versions.push(( + current, + entry.dep.current_req.trim_start_matches('v').into(), + )); + versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + versions.dedup_by(|a, b| a.0 == b.0); + Ok(versions.into_iter().map(|(_, v)| v).collect()) + } + + /// Fetch all sidecar data before any project files are written. + #[allow(clippy::too_many_lines)] + pub async fn sidecars( + &self, + doc: &Document, + updates: &[PlannedUpdate], + ) -> Result, DcuError> { + let mut extra = Vec::new(); + if let (Some(span), Some(kind), Some(update)) = ( + &doc.checksum, + &doc.distribution, + updates + .iter() + .find(|u| u.name == "gradle" && u.section == Section::Toolchain), + ) { + let url = format!( + "{}/gradle-{}-{kind}.zip.sha256", + self.endpoints.distributions, update.to + ); + let digest = self.text(&url, "gradle checksum").await?; + let digest = digest.trim(); + if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(error("gradle", "invalid official SHA-256 response")); + } + extra.push(Patch { + start: span.start, + end: span.end, + new_value: digest.to_owned(), + }); + } + for entry in &doc.entries { + let Some((span, algo)) = &entry.integrity else { + continue; + }; + let Some(update) = updates.iter().find(|u| { + u.name == entry.dep.name + && u.from == entry.dep.current_req + && u.section == entry.dep.section + }) else { + continue; + }; + if !matches!( + algo.as_str(), + "sha1" | "sha224" | "sha256" | "sha384" | "sha512" + ) { + return Err(error( + &update.name, + "unsupported packageManager hash algorithm", + )); + } + let modern_yarn = update.name == "yarn" + && update + .to + .split('.') + .next() + .and_then(|v| v.parse::().ok()) + .is_some_and(|v| v >= 2); + let tarball = if modern_yarn { + format!( + "{}/{}/packages/yarnpkg-cli/bin/yarn.js", + self.endpoints.yarn_downloads, update.to + ) + } else { + let json = self + .json( + &format!("{}/{}/{}", self.endpoints.npm, update.name, update.to), + &update.name, + ) + .await?; + json["dist"]["tarball"] + .as_str() + .ok_or_else(|| error(&update.name, "missing npm tarball for integrity"))? + .to_owned() + }; + let trusted = tarball.starts_with("https://registry.npmjs.org/") + || (modern_yarn && tarball.starts_with("https://repo.yarnpkg.com/")) + || (cfg!(test) + && (tarball.starts_with(&self.endpoints.npm) + || tarball.starts_with(&self.endpoints.yarn_downloads))); + if !trusted { + return Err(error(&update.name, "untrusted package manager tarball URL")); + } + let bytes = self + .bytes(&tarball, &update.name, MetadataCache::INTEGRITY_LIMIT) + .await?; + let digest = match algo.as_str() { + "sha1" => Sha1::digest(&bytes).to_vec(), + "sha224" => Sha224::digest(&bytes).to_vec(), + "sha256" => Sha256::digest(&bytes).to_vec(), + "sha384" => Sha384::digest(&bytes).to_vec(), + _ => Sha512::digest(&bytes).to_vec(), + }; + let hex = digest.iter().fold(String::new(), |mut s, b| { + write!(s, "{b:02x}").expect("write to String"); + s + }); + extra.push(Patch { + start: span.start, + end: span.end, + new_value: format!("{algo}.{hex}"), + }); + } + Ok(extra) + } +} + +fn public_repository(url: &str) -> bool { + [ + "https://dl.google.com/dl/android/maven2", + "https://maven.google.com", + "https://repo.maven.apache.org/maven2", + "https://repo1.maven.org/maven2", + "https://plugins.gradle.org/m2", + ] + .iter() + .any(|base| url.trim_end_matches('/') == *base) + || (cfg!(test) + && (url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:"))) +} + +fn release_tags(json: &Value, prefix: &str) -> Vec { + json.as_array() + .map(|rows| { + rows.iter() + .filter(|r| r.get("draft") != Some(&Value::Bool(true))) + .filter_map(|r| { + r.get("tag_name") + .and_then(Value::as_str) + .map(|v| v.trim_start_matches(prefix).to_owned()) + }) + .collect() + }) + .unwrap_or_default() +} + +fn choose( + versions: &[String], + current: &str, + target: TargetLevel, + newest: Option<&str>, +) -> Result { + let mut parsed: Vec<_> = versions + .iter() + .filter_map(|v| semver::Version::parse(&pad_to_three_segments(v)).ok()) + .collect(); + parsed.sort_unstable(); + parsed.dedup(); + let latest = highest_stable(&parsed); + if parsed.is_empty() { + return Err(error("metadata", "no supported published versions")); + } + let cur = semver::Version::parse(&pad_to_three_segments(current.trim_start_matches('v'))).ok(); + let selected = if target == TargetLevel::Newest && newest.is_some() { + newest.map(str::to_owned) + } else { + select_version(cur.as_ref(), &parsed, target, latest.as_deref(), None) + }; + // Keep real release strings (Gradle 8.9, SDK 36), not padded inventions. + let restore = |value: Option| { + value.map(|v| { + versions + .iter() + .find(|original| { + semver::Version::parse(&pad_to_three_segments(original)).ok() + == semver::Version::parse(&v).ok() + }) + .cloned() + .unwrap_or(v) + }) + }; + Ok(ResolvedVersion { + latest: restore(latest), + selected: restore(selected), + }) +} + +fn validate_xml(text: &str, name: &str) -> Result<(), DcuError> { + let mut reader = quick_xml::Reader::from_str(text); + loop { + match reader.read_event() { + Ok(quick_xml::events::Event::Eof) => break, + Err(e) => return Err(error(name, e.to_string())), + _ => {} + } + } + Ok(()) +} + +fn xml_versions(text: &str, name: &str) -> Result, DcuError> { + validate_xml(text, name)?; + Ok(regex::Regex::new(r"\s*([^<\s]+)\s*") + .unwrap() + .captures_iter(text) + .map(|c| c[1].to_owned()) + .collect()) +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn supported_repository_failures_do_not_report_no_versions_as_current() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/g/a/maven-metadata.xml")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + let mut doc = crate::project::parse( + "implementation(\"g:a:1.0\")\n", + std::path::Path::new("build.gradle.kts"), + ) + .unwrap(); + doc.entries[0].repositories = vec![server.uri()]; + let registry = ToolRegistry::new(); + let error = registry + .resolve(&doc.entries[0], TargetLevel::Latest) + .await + .unwrap_err(); + assert!(error.to_string().contains("503")); + Mock::given(path("/available/g/a/maven-metadata.xml")).respond_with(ResponseTemplate::new(200).set_body_string("2.0")).mount(&server).await; + doc.entries[0] + .repositories + .push(format!("{}/available", server.uri())); + let error = registry + .resolve(&doc.entries[0], TargetLevel::Latest) + .await + .unwrap_err(); + assert!(error.to_string().contains("503")); // A successful partial response is still unsafe. + } + use super::*; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + + fn entry(name: &str, version: &str, section: Section) -> Entry { + Entry { + requested: true, + dep: dependency_check_updates_core::DependencySpec { + name: name.into(), + current_req: version.into(), + section, + path_version: None, + }, + span: None, + reason: None, + repositories: Vec::new(), + integrity: None, + } + } + + #[tokio::test] + async fn yarn_object_tags_invalid_metadata_and_rust_patch_index_are_fixed() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + for (url, body) in [ + ("/yarn", r#"{"tags":{"2.0.0":{},"2.1.0":{}}}"#), + ("/invalid-yarn", r#"{"tags":null}"#), + ("/rust", "[pkg.rust]\nversion = '1.88.2 (fixed)'\n"), + ( + "/repos/rust-lang/rust/releases", + r#"[{"tag_name":"1.88.1"},{"tag_name":"1.88.2"}]"#, + ), + ] { + Mock::given(path(url)) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + let registry = ToolRegistry::with_endpoints(Endpoints { + yarn: format!("{}/yarn", server.uri()), + rust: format!("{}/rust", server.uri()), + github: server.uri(), + ..Endpoints::default() + }); + assert_eq!( + registry + .resolve( + &entry("yarn", "2.0.0", Section::Toolchain), + TargetLevel::Latest + ) + .await + .unwrap() + .selected + .as_deref(), + Some("2.1.0") + ); + assert_eq!( + registry + .resolve( + &entry("rust", "1.88.1", Section::Toolchain), + TargetLevel::Patch + ) + .await + .unwrap() + .selected + .as_deref(), + Some("1.88.2") + ); + let invalid = ToolRegistry::with_endpoints(Endpoints { + yarn: format!("{}/invalid-yarn", server.uri()), + ..Endpoints::default() + }); + assert!( + invalid + .resolve( + &entry("yarn", "2.0", Section::Toolchain), + TargetLevel::Latest + ) + .await + .is_err() + ); + assert!( + registry + .resolve( + &entry("unknown", "1.0", Section::Toolchain), + TargetLevel::Latest + ) + .await + .is_err() + ); + assert!( + registry + .candidates( + &entry("unknown", "1.0", Section::Toolchain), + TargetLevel::Latest, + "1.0" + ) + .await + .is_err() + ); + assert!(choose(&[], "1.0", TargetLevel::Latest, None).is_err()); + assert!(validate_xml("", "xml").is_err()); + } + + #[tokio::test] + async fn maven_coordinate_missing_repository_and_404_are_not_current() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + let registry = ToolRegistry::new(); + let mut dep = entry("group:bad/artifact", "1.0", Section::Maven); + assert!( + registry + .maven(&dep, TargetLevel::Latest) + .await + .unwrap_err() + .to_string() + .contains("coordinate") + ); + dep.dep.name = "group:artifact".into(); + assert!( + registry + .maven(&dep, TargetLevel::Latest) + .await + .unwrap_err() + .to_string() + .contains("repository") + ); + dep.repositories = vec![server.uri()]; + assert!( + registry + .maven(&dep, TargetLevel::Latest) + .await + .unwrap_err() + .to_string() + .contains("404") + ); + } + + #[tokio::test] + async fn malformed_checksum_hash_algorithm_and_untrusted_tarball_block_sidecars() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/gradle-9.0-bin.zip.sha256")) + .respond_with(ResponseTemplate::new(200).set_body_string("invalid")) + .mount(&server) + .await; + Mock::given(path("/pnpm/2.0.0")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({"dist":{"tarball":"https://untrusted.example/pnpm.tgz"}}), + )) + .mount(&server) + .await; + let registry = ToolRegistry::with_endpoints(Endpoints { + distributions: server.uri(), + npm: server.uri(), + ..Endpoints::default() + }); + let doc = crate::project::parse("distributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\ndistributionSha256Sum=old\n", std::path::Path::new("gradle/wrapper/gradle-wrapper.properties")).unwrap(); + let update = PlannedUpdate { + name: "gradle".into(), + from: "8.9".into(), + to: "9.0".into(), + section: Section::Toolchain, + }; + assert!( + registry + .sidecars(&doc, &[update]) + .await + .unwrap_err() + .to_string() + .contains("SHA-256") + ); + for algo in ["unsupported", "sha256"] { + let text = format!(r#"{{"packageManager":"pnpm@1.0.0+{algo}.old"}}"#); + let doc = crate::project::parse(&text, std::path::Path::new("package.json")).unwrap(); + let update = PlannedUpdate { + name: "pnpm".into(), + from: "1.0.0".into(), + to: "2.0.0".into(), + section: Section::Toolchain, + }; + let error = registry + .sidecars(&doc, &[update]) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(if algo == "unsupported" { + "hash algorithm" + } else { + "untrusted" + })); + } + } +} diff --git a/crates/cli/src/transaction.rs b/crates/cli/src/transaction.rs new file mode 100644 index 0000000..b89b441 --- /dev/null +++ b/crates/cli/src/transaction.rs @@ -0,0 +1,1831 @@ +//! Recoverable multi-file updates. Each replacement is atomic, the batch is not +//! crash-atomic. A receipt and same-directory backups survive forced termination. +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tempfile::{Builder, TempPath}; + +const RECEIPT_PREFIX: &str = ".dcu-transaction-"; +const TARGET_RECEIPT_PREFIX: &str = ".dcu-pending-"; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TargetReceipt { + receipt: PathBuf, +} + +fn marker_path(path: &Path) -> Result { + let path = std::fs::canonicalize(path).map_err(|e| e.to_string())?; + let name = path.file_name().ok_or("invalid marker target")?; + Ok(path.with_file_name(format!( + "{TARGET_RECEIPT_PREFIX}{}.json", + digest(name.as_encoded_bytes()) + ))) +} + +fn read_marker(path: &Path) -> Result, String> { + use std::io::Read; + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + }; + if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 { + return Err("unsafe target recovery marker".into()); + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .map_err(|e| e.to_string())? + .take(16 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + decode_marker(&bytes).map(Some) +} + +fn decode_marker(bytes: &[u8]) -> Result { + if bytes.len() > 16 * 1024 { + return Err("target recovery marker exceeds size limit".into()); + } + let marker: TargetReceipt = + serde_json::from_slice(bytes).map_err(|_| "invalid target recovery marker")?; + if !marker.receipt.is_absolute() + || !marker.receipt.file_name().is_some_and(|n| { + n.to_string_lossy().starts_with(RECEIPT_PREFIX) + && n.to_string_lossy().ends_with(".json") + }) + { + return Err("invalid target receipt path".into()); + } + Ok(marker) +} + +/// Read-only lookup: notices interrupted writes regardless of the caller's cwd. +pub(crate) fn pending_for<'a>( + paths: impl Iterator, +) -> Result, String> { + let mut receipts = Vec::new(); + for path in paths { + if let Some(marker) = read_marker(&marker_path(path)?)? + && marker.receipt.try_exists().map_err(|e| e.to_string())? + { + receipts.push(marker.receipt); + } + } + receipts.sort(); + receipts.dedup(); + Ok(receipts) +} + +fn target_markers(staged: &[Staged], receipt: &Path) -> Result, String> { + let receipt = std::fs::canonicalize(receipt).map_err(|e| e.to_string())?; + let mut markers = Vec::new(); + for target in staged { + let path = marker_path(&target.change.path)?; + if let Some(old) = read_marker(&path)? { + if old.receipt.try_exists().map_err(|e| e.to_string())? { + return Err(format!( + "pending update receipt {}; recover from its directory", + old.receipt.display() + )); + } + // A completed transaction may crash between receipt and marker deletion. + std::fs::remove_file(&path).map_err(|e| e.to_string())?; + } + let mut marker = Builder::new() + .prefix( + path.file_name() + .unwrap() + .to_str() + .ok_or("non-UTF-8 marker name")?, + ) + .rand_bytes(0) + .tempfile_in(path.parent().unwrap()) + .map_err(|e| e.to_string())?; + serde_json::to_writer( + &mut marker, + &TargetReceipt { + receipt: receipt.clone(), + }, + ) + .map_err(|e| e.to_string())?; + marker.as_file().sync_all().map_err(|e| e.to_string())?; + sync_directory(path.parent().unwrap())?; + markers.push(marker.into_temp_path()); + } + Ok(markers) +} + +pub(crate) struct Change { + pub path: PathBuf, + pub original: Vec, + pub replacement: Vec, +} + +pub(crate) struct Failure { + pub detail: String, + pub recovery_required: bool, + pub rolled_back: bool, + pub committed: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Step { + Stage, + Commit, + BeforeReplace, + AfterReplace, + Rollback, + Finalize, +} + +struct Staged { + change: Change, + stage: Option, + backup: Option, + committed: bool, +} + +#[derive(Serialize)] +struct ReceiptEntry<'a> { + path: &'a Path, + staged: &'a Path, + backup: &'a Path, + original_sha256: String, + replacement_sha256: String, +} + +fn digest(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(bytes)) +} + +// Stable sibling lock identities survive atomic target replacement. Do not +// unlink them: unlinking a locked inode allows a second process to lock a new +// inode with the same name. OS locks themselves vanish when a process dies. +// Explicit unlock matters on Unix: a concurrent fork may briefly inherit the +// open-file description before exec closes it. Closing only the parent's fd +// can otherwise leave a completed transaction apparently locked by that child. +struct FileLock(std::fs::File); + +impl FileLock { + fn acquire(file: std::fs::File) -> Result { + fs2::FileExt::try_lock_exclusive(&file)?; + Ok(Self(file)) + } +} + +impl Drop for FileLock { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.0); + } +} + +fn target_locks<'a>(paths: impl Iterator) -> Result, String> { + let mut paths: Vec<_> = paths + .map(std::fs::canonicalize) + .collect::>() + .map_err(|e| e.to_string())?; + paths.sort(); + paths.dedup(); + let mut locks = Vec::new(); + for path in paths { + let name = path.file_name().ok_or("invalid lock target")?; + let lock_path = + path.with_file_name(format!(".dcu-lock-{}", digest(name.as_encoded_bytes()))); + if let Ok(meta) = std::fs::symlink_metadata(&lock_path) + && (!meta.is_file() || meta.file_type().is_symlink()) + { + return Err("unsafe sibling lock file".into()); + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|e| e.to_string())?; + locks.push( + FileLock::acquire(file) + .map_err(|_| format!("update target is busy: {}", path.display()))?, + ); + } + Ok(locks) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Receipt { + #[serde(rename = "schemaVersion")] + schema_version: u32, + files: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RecoveryEntry { + path: PathBuf, + staged: PathBuf, + backup: PathBuf, + original_sha256: String, + replacement_sha256: String, +} + +fn scoped(root: &Path, path: &Path) -> Result<(), String> { + let parent = path.parent().ok_or("recovery path has no parent")?; + if !path.is_absolute() + || !std::fs::canonicalize(parent) + .map_err(|e| e.to_string())? + .starts_with(root) + { + return Err("transaction path escapes the working directory".into()); + } + let metadata = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("transaction path is not a regular non-symlink file".into()); + } + validate_native_target(path)?; + Ok(()) +} + +/// Explicit, idempotent recovery. Preflight the whole receipt before a write. +/// Already-restored targets are accepted; external edits and corrupt backups +/// block the operation without removing recovery evidence. +#[allow(clippy::too_many_lines)] +pub(crate) fn recover(root: &Path, finish: bool) -> Result { + use std::io::Read; + let receipts = pending(root).map_err(|e| e.to_string())?; + if receipts.is_empty() { + return Ok(false); + } + if receipts.len() != 1 { + return Err("multiple recovery receipts; reconcile them individually".into()); + } + let root = std::fs::canonicalize(root).map_err(|e| e.to_string())?; + let receipt_path = &receipts[0]; + scoped(&root, receipt_path)?; + let mut handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(receipt_path) + .map_err(|e| e.to_string())?; + let _receipt_lock = FileLock::acquire(handle.try_clone().map_err(|e| e.to_string())?) + .map_err(|_| "transaction is still active or recovery is already running")?; + let mut bytes = Vec::new(); + std::io::Read::by_ref(&mut handle) + .take(4 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 4 * 1024 * 1024 { + return Err("receipt exceeds size limit".into()); + } + let receipt: Receipt = + serde_json::from_slice(&bytes).map_err(|_| "invalid recovery receipt")?; + if receipt.schema_version != 1 || receipt.files.is_empty() || receipt.files.len() > 4096 { + return Err("unsupported recovery receipt schema or file count".into()); + } + let mut identities = HashSet::new(); + let mut artifacts = HashSet::new(); + let mut originals = Vec::new(); + let mut changes = Vec::new(); + for entry in &receipt.files { + scoped(&root, &entry.path)?; + if !identities.insert(std::fs::canonicalize(&entry.path).map_err(|e| e.to_string())?) { + return Err("duplicate recovery target".into()); + } + for (path, prefix) in [ + (&entry.backup, ".dcu-backup-"), + (&entry.staged, ".dcu-stage-"), + ] { + if path.parent() != entry.path.parent() + || !path.file_name().is_some_and(|n| { + n.to_string_lossy().starts_with(prefix) && n.to_string_lossy().ends_with(".tmp") + }) + || !artifacts.insert(path.clone()) + { + return Err("invalid recovery artifact path".into()); + } + if path.exists() { + scoped(&root, path)?; + } + } + let current = std::fs::read(&entry.path).map_err(|e| e.to_string())?; + let hash = digest(¤t); + if hash != entry.original_sha256 && hash != entry.replacement_sha256 { + return Err(format!( + "external edit detected in {}; recovery preserved", + entry.path.display() + )); + } + let desired = if finish { + &entry.replacement_sha256 + } else { + &entry.original_sha256 + }; + let source = if finish { &entry.staged } else { &entry.backup }; + if hash != *desired { + let data = std::fs::read(source) + .map_err(|_| format!("missing recovery artifact {}", source.display()))?; + if digest(&data) != *desired { + return Err(format!("corrupt recovery artifact {}", source.display())); + } + changes.push((entry.path.clone(), current.clone(), data)); + } + originals.push((entry.path.clone(), current)); + } + // Stage new copies, never consume original backups: a crash during recovery + // can repeat the same command and validate each target again. + let _locks = target_locks(receipt.files.iter().map(|e| e.path.as_path()))?; + let receipt_identity = std::fs::canonicalize(receipt_path).map_err(|e| e.to_string())?; + let mut markers = Vec::new(); + for entry in &receipt.files { + let path = marker_path(&entry.path)?; + if let Some(marker) = read_marker(&path)? { + if marker.receipt != receipt_identity { + return Err("target belongs to a different recovery receipt".into()); + } + markers.push(path); + } + } + let mut staged = Vec::new(); + for (path, old, data) in changes { + let permissions = std::fs::metadata(&path) + .map_err(|e| e.to_string())? + .permissions(); + staged.push(( + path.clone(), + old, + Some(file( + path.parent().unwrap(), + ".dcu-stage-", + &data, + permissions, + &path, + )?), + )); + } + for (path, current) in &originals { + unchanged(path, current)?; + } + for (path, current, stage) in &mut staged { + unchanged(path, current)?; + replace(stage, path)?; + } + for entry in &receipt.files { + for path in [&entry.backup, &entry.staged] { + if path.exists() { + std::fs::remove_file(path).map_err(|e| e.to_string())?; + } + } + } + // Keep the OS lock until after evidence cleanup. Closing releases it after + // forced termination too; a live transaction cannot be mistaken for a crash. + std::fs::remove_file(receipt_path).map_err(|e| e.to_string())?; + sync_directory(&root)?; + for path in markers { + std::fs::remove_file(&path).map_err(|e| e.to_string())?; + sync_directory(path.parent().unwrap())?; + } + Ok(true) +} + +pub(crate) fn pending(root: &Path) -> Result, std::io::Error> { + let mut paths = Vec::new(); + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(RECEIPT_PREFIX) + && name.ends_with(".json") + && entry.file_type()?.is_file() + { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths) +} + +fn file( + parent: &Path, + prefix: &str, + bytes: &[u8], + permissions: std::fs::Permissions, + origin: &Path, +) -> Result { + let mut f = Builder::new() + .prefix(prefix) + .suffix(".tmp") + .tempfile_in(parent) + .map_err(|e| e.to_string())?; + #[cfg(windows)] + copy_windows_dacl(origin, f.path())?; + #[cfg(unix)] + preserve_unix_metadata(origin, f.path())?; + f.write_all(bytes).map_err(|e| e.to_string())?; + f.as_file() + .set_permissions(permissions) + .map_err(|e| e.to_string())?; + // Writing/chmod may clear Unix capabilities or alter ACL masks. Restore + // the exposed attributes after the final data/mode operation as well. + #[cfg(unix)] + preserve_unix_metadata(origin, f.path())?; + f.as_file().sync_all().map_err(|e| e.to_string())?; + Ok(f.into_temp_path()) +} + +#[cfg(windows)] +struct WindowsDacl { + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, + acl: *mut windows_sys::Win32::Security::ACL, + protected: bool, +} + +#[cfg(windows)] +#[allow(unsafe_code)] +impl Drop for WindowsDacl { + fn drop(&mut self) { + // SAFETY: GetNamedSecurityInfo allocated this descriptor with LocalAlloc. + unsafe { + windows_sys::Win32::Foundation::LocalFree(self.descriptor); + } + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn read_windows_dacl(path: &Path) -> Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Security::{ + Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}, + DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, SE_DACL_PROTECTED, + }; + let path: Vec<_> = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut result = WindowsDacl { + descriptor: std::ptr::null_mut(), + acl: std::ptr::null_mut(), + protected: false, + }; + // SAFETY: path is a live terminated UTF-16 buffer, outputs point to live + // initialized fields, and the descriptor owns the returned DACL storage. + let code = unsafe { + GetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &raw mut result.acl, + std::ptr::null_mut(), + &raw mut result.descriptor, + ) + }; + if code != 0 { + return Err(format!("cannot preserve Windows DACL ({code})")); + } + let mut control = 0; + let mut revision = 0; + // SAFETY: descriptor is the live allocation returned by the successful call. + if unsafe { + GetSecurityDescriptorControl(result.descriptor, &raw mut control, &raw mut revision) + } == 0 + { + return Err("cannot read Windows DACL protection".into()); + } + result.protected = control & SE_DACL_PROTECTED != 0; + Ok(result) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn set_windows_dacl(path: &Path, source: &WindowsDacl) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Security::{ + Authorization::{SE_FILE_OBJECT, SetNamedSecurityInfoW}, + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + UNPROTECTED_DACL_SECURITY_INFORMATION, + }; + let path: Vec<_> = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let flags = DACL_SECURITY_INFORMATION + | if source.protected { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + }; + // SAFETY: both path and the owned descriptor/DACL remain live through this + // synchronous call; optional owner/group/SACL pointers are explicitly null. + let code = unsafe { + SetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + flags, + std::ptr::null_mut(), + std::ptr::null_mut(), + source.acl, + std::ptr::null(), + ) + }; + if code != 0 { + return Err(format!("cannot preserve Windows DACL ({code})")); + } + Ok(()) +} + +#[cfg(windows)] +fn copy_windows_dacl(origin: &Path, destination: &Path) -> Result<(), String> { + set_windows_dacl(destination, &read_windows_dacl(origin)?) +} + +#[cfg(unix)] +fn preserve_unix_metadata(origin: &Path, destination: &Path) -> Result<(), String> { + use std::os::unix::fs::{MetadataExt, chown}; + let source = std::fs::metadata(origin).map_err(|e| e.to_string())?; + let staged = std::fs::metadata(destination).map_err(|e| e.to_string())?; + if source.uid() != staged.uid() || source.gid() != staged.gid() { + chown(destination, Some(source.uid()), Some(source.gid())) + .map_err(|_| "cannot preserve Unix owner/group; update refused")?; + } + let attributes: Vec<_> = xattr::list(origin) + .map_err(|_| "cannot read extended metadata; update refused")? + .collect(); + for key in xattr::list(destination).map_err(|_| "cannot inspect staged metadata")? { + if !attributes.contains(&key) { + xattr::remove(destination, &key).map_err(|_| "cannot preserve extended metadata")?; + } + } + for key in attributes { + if let Some(value) = + xattr::get(origin, &key).map_err(|_| "cannot read extended metadata")? + { + xattr::set(destination, &key, &value) + .map_err(|_| "cannot preserve extended metadata; update refused")?; + } + } + Ok(()) +} + +fn unchanged(path: &Path, expected: &[u8]) -> Result<(), String> { + let meta = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.file_type().is_symlink() { + return Err(format!( + "{} is not a regular, non-symlink file", + path.display() + )); + } + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + if bytes != expected { + return Err(format!( + "{} changed since scanning; refused to overwrite", + path.display() + )); + } + Ok(()) +} + +pub(crate) fn commit(root: &Path, changes: Vec) -> Result<(), Failure> { + commit_with(root, changes, |_, _, _| Ok(())) +} + +#[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))] +fn sync_directory(path: &Path) -> Result<(), String> { + #[cfg(unix)] + std::fs::File::open(path) + .and_then(|f| f.sync_all()) + .map_err(|e| e.to_string())?; + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +#[cfg(not(windows))] +fn replace(file: &mut Option, path: &Path) -> Result<(), String> { + match file.take().unwrap().persist(path) { + Ok(()) => sync_directory(path.parent().ok_or("replacement has no parent")?), + Err(e) => { + let detail = e.error.to_string(); + *file = Some(e.path); + Err(detail) + } + } +} + +// Retain Windows ACL/creation metadata instead of MoveFileExW (tempfile's +// persist implementation). Do not bypass permission-merge errors. +#[cfg(windows)] +#[allow(unsafe_code)] +fn replace(file: &mut Option, path: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::{ReplaceFileW, SetFileAttributesW}; + let target: Vec<_> = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let source: Vec<_> = file + .as_ref() + .unwrap() + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let attributes = std::fs::metadata(path) + .map_err(|e| e.to_string())? + .file_attributes(); + // SAFETY: both paths are live NUL-terminated UTF-16 buffers, optional + // pointers are null, and no buffers escape this synchronous call. + unsafe { + if SetFileAttributesW(source.as_ptr(), attributes) == 0 + || ReplaceFileW( + target.as_ptr(), + source.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + } + file.take(); // The temp path no longer exists after successful replacement. + Ok(()) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +fn validate_native_target(path: &Path) -> Result<(), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + if file + .metadata() + .map_err(|e| e.to_string())? + .permissions() + .readonly() + { + return Err(format!( + "read-only update target is unsupported: {}", + path.display() + )); + } + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: the handle belongs to the live file; info is a valid writable + // structure for the duration of this synchronous Win32 call. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &raw mut info) } == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + if info.nNumberOfLinks > 1 { + return Err(format!( + "hard-linked update target is unsupported: {}", + path.display() + )); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_native_target(path: &Path) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + if std::fs::metadata(path).map_err(|e| e.to_string())?.nlink() > 1 { + return Err(format!( + "hard-linked update target is unsupported: {}", + path.display() + )); + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn validate_native_target(_path: &Path) -> Result<(), String> { + Err("native update transactions are supported on Unix and Windows only".into()) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn commit_with( + root: &Path, + mut changes: Vec, + mut hook: impl FnMut(Step, usize, &Path) -> Result<(), String>, +) -> Result<(), Failure> { + let simple = |detail| Failure { + detail, + recovery_required: false, + rolled_back: false, + committed: false, + }; + if !pending(root).map_err(|e| simple(e.to_string()))?.is_empty() { + return Err(simple( + "pending update receipt exists; inspect its backups before another -u".into(), + )); + } + changes.sort_by(|a, b| a.path.cmp(&b.path)); + if changes.is_empty() { + return Ok(()); + } + let mut identities = HashSet::new(); + let canonical_root = std::fs::canonicalize(root).map_err(|e| simple(e.to_string()))?; + for c in &changes { + unchanged(&c.path, &c.original).map_err(simple)?; + validate_native_target(&c.path).map_err(simple)?; + let identity = std::fs::canonicalize(&c.path).map_err(|e| simple(e.to_string()))?; + if !identity.starts_with(&canonical_root) { + return Err(simple( + "update target escapes the working directory; run from its project root".into(), + )); + } + if !identities.insert(identity) { + return Err(simple(format!( + "duplicate update target: {}", + c.path.display() + ))); + } + } + let _locks = target_locks(changes.iter().map(|c| c.path.as_path())).map_err(simple)?; + if let Some(receipt) = pending_for(changes.iter().map(|c| c.path.as_path())) + .map_err(simple)? + .first() + { + return Err(simple(format!( + "pending update receipt {}; recover from its directory", + receipt.display() + ))); + } + let mut staged = Vec::new(); + for (i, change) in changes.into_iter().enumerate() { + hook(Step::Stage, i, &change.path).map_err(simple)?; + let parent = change + .path + .parent() + .ok_or_else(|| simple("target has no parent".into()))?; + let permissions = std::fs::metadata(&change.path) + .map_err(|e| simple(e.to_string()))? + .permissions(); + let stage = file( + parent, + ".dcu-stage-", + &change.replacement, + permissions.clone(), + &change.path, + ) + .map_err(simple)?; + let backup = file( + parent, + ".dcu-backup-", + &change.original, + permissions, + &change.path, + ) + .map_err(simple)?; + sync_directory(parent).map_err(simple)?; + staged.push(Staged { + change, + stage: Some(stage), + backup: Some(backup), + committed: false, + }); + } + let mut receipt = Builder::new() + .prefix(RECEIPT_PREFIX) + .rand_bytes(0) + .suffix("active.json") + .tempfile_in(root) + .map_err(|e| simple(e.to_string()))?; + let _receipt_lock = FileLock::acquire( + receipt + .as_file() + .try_clone() + .map_err(|e| simple(e.to_string()))?, + ) + .map_err(|e| simple(e.to_string()))?; + let entries: Vec<_> = staged + .iter() + .map(|s| ReceiptEntry { + path: &s.change.path, + staged: s.stage.as_ref().unwrap().as_ref(), + backup: s.backup.as_ref().unwrap().as_ref(), + original_sha256: digest(&s.change.original), + replacement_sha256: digest(&s.change.replacement), + }) + .collect(); + serde_json::to_writer_pretty( + &mut receipt, + &serde_json::json!({"schemaVersion":1,"files":entries}), + ) + .map_err(|e| simple(e.to_string()))?; + receipt + .as_file() + .sync_all() + .map_err(|e| simple(e.to_string()))?; + sync_directory(root).map_err(simple)?; + let markers = target_markers(&staged, receipt.path()).map_err(simple)?; + for s in &staged { + unchanged(&s.change.path, &s.change.original).map_err(simple)?; + } + let mut failed = None; + let mut uncertain = Vec::new(); + for (i, s) in staged.iter_mut().enumerate() { + let mut attempted = false; + let result = hook(Step::Commit, i, &s.change.path) + .and_then(|()| unchanged(&s.change.path, &s.change.original)) + .and_then(|()| { + attempted = true; + hook(Step::BeforeReplace, i, &s.change.path)?; + replace(&mut s.stage, &s.change.path)?; + hook(Step::AfterReplace, i, &s.change.path) + }); + match result { + Ok(()) => s.committed = true, + Err(e) => { + if attempted { + match std::fs::read(&s.change.path) { + Ok(bytes) if bytes == s.change.replacement => s.committed = true, + Ok(bytes) if bytes == s.change.original => {} + _ => uncertain.push(format!( + "{} has uncertain replacement state", + s.change.path.display() + )), + } + } + failed = Some(format!("cannot replace {}: {e}", s.change.path.display())); + break; + } + } + } + let Some(detail) = failed else { + if let Err(error) = hook(Step::Finalize, 0, root) + .and_then(|()| std::fs::remove_file(receipt.path()).map_err(|e| e.to_string())) + { + let receipt_path = receipt.path().to_owned(); + let _ = receipt.keep(); + for marker in markers { + let _ = marker.keep(); + } + for s in staged { + if let Some(f) = s.backup { + let _ = f.keep(); + } + } + return Err(Failure { + detail: format!( + "files committed but finalization failed: {error}; finish recovery at {}", + receipt_path.display() + ), + committed: true, + recovery_required: true, + rolled_back: false, + }); + } + let completed = |detail| Failure { + detail, + committed: true, + recovery_required: false, + rolled_back: false, + }; + drop(receipt); + sync_directory(root).map_err(completed)?; + for marker in markers { + marker.close().map_err(|e| { + completed(format!( + "files committed but target marker cleanup failed: {e}" + )) + })?; + } + for target in &mut staged { + if let Some(backup) = target.backup.take() { + backup.close().map_err(|e| { + completed(format!("files committed but backup cleanup failed: {e}")) + })?; + } + sync_directory(target.change.path.parent().unwrap()).map_err(completed)?; + } + return Ok(()); + }; + let rolled_back = staged.iter().any(|s| s.committed); + let mut recovery = uncertain; + for (i, s) in staged + .iter_mut() + .enumerate() + .rev() + .filter(|(_, s)| s.committed) + { + // Never replace a concurrent edit made after this transaction's write. + let result = hook(Step::Rollback, i, &s.change.path) + .and_then(|()| unchanged(&s.change.path, &s.change.replacement)) + .and_then(|()| replace(&mut s.backup, &s.change.path)); + if let Err(e) = result { + recovery.push(format!("{}: {e}", s.change.path.display())); + } + } + if recovery.is_empty() { + receipt.close().map_err(|e| simple(e.to_string()))?; + sync_directory(root).map_err(simple)?; + drop(markers); + return Err(Failure { + detail: format!("{detail}; all committed files rolled back"), + recovery_required: false, + rolled_back, + committed: false, + }); + } + let receipt_path = receipt.path().to_owned(); + let _ = receipt.keep(); + for marker in markers { + let _ = marker.keep(); + } + for s in staged { + if let Some(f) = s.stage { + let _ = f.keep(); + } + if let Some(f) = s.backup { + let _ = f.keep(); + } + } + Err(Failure { + detail: format!( + "{detail}; recovery required: {}; receipt and original backups: {}", + recovery.join("; "), + receipt_path.display() + ), + recovery_required: true, + rolled_back, + committed: false, + }) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + #[test] + fn marker_metadata_errors_are_reported_without_following_looping_parents() { + let dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink("loop", dir.path().join("loop")).unwrap(); + assert!(read_marker(&dir.path().join("loop/marker.json")).is_err()); + } + use super::*; + + fn non_lock_files(root: &Path) -> usize { + std::fs::read_dir(root) + .unwrap() + .filter(|entry| { + !entry + .as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".dcu-lock-") + }) + .count() + } + + fn fixture() -> (tempfile::TempDir, Vec) { + let dir = tempfile::TempDir::new().unwrap(); + let changes = ["a.toml", "b.toml"] + .into_iter() + .map(|name| { + let path = dir.path().join(name); + std::fs::write(&path, b"old\r\n").unwrap(); + Change { + path, + original: b"old\r\n".to_vec(), + replacement: b"new\r\n".to_vec(), + } + }) + .collect(); + (dir, changes) + } + + #[test] + fn untrusted_marker_lock_and_receipt_shapes_are_rejected_before_writes() { + let (dir, changes) = fixture(); + let path = &changes[0].path; + let marker = marker_path(path).unwrap(); + std::fs::create_dir(&marker).unwrap(); + assert!(read_marker(&marker).err().unwrap().contains("unsafe")); + std::fs::remove_dir(&marker).unwrap(); + std::fs::write(&marker, vec![b' '; 16 * 1024 + 1]).unwrap(); + assert!(read_marker(&marker).err().unwrap().contains("unsafe")); + assert!( + decode_marker(&vec![b' '; 16 * 1024 + 1]) + .err() + .unwrap() + .contains("size limit") + ); + assert!( + decode_marker(br#"{"receipt":"relative.json"}"#) + .err() + .unwrap() + .contains("path") + ); + let lock_path = path.with_file_name(format!( + ".dcu-lock-{}", + digest(path.file_name().unwrap().as_encoded_bytes()) + )); + std::fs::create_dir(&lock_path).unwrap(); + assert!( + target_locks(std::iter::once(path.as_path())) + .err() + .unwrap() + .contains("unsafe") + ); + assert!( + scoped(dir.path(), Path::new("relative")) + .err() + .unwrap() + .contains("escapes") + ); + assert!(scoped(dir.path(), dir.path()).is_err()); + let directory = dir.path().join("directory"); + std::fs::create_dir(&directory).unwrap(); + assert!( + scoped(&std::fs::canonicalize(dir.path()).unwrap(), &directory) + .err() + .unwrap() + .contains("regular") + ); + let first = dir.path().join(".dcu-transaction-first.json"); + let second = dir.path().join(".dcu-transaction-second.json"); + std::fs::write(&first, b"{}").unwrap(); + std::fs::write(&second, b"{}").unwrap(); + assert!(recover(dir.path(), false).unwrap_err().contains("multiple")); + std::fs::remove_file(&second).unwrap(); + std::fs::write(&first, vec![b' '; 4 * 1024 * 1024 + 1]).unwrap(); + assert!( + recover(dir.path(), false) + .unwrap_err() + .contains("size limit") + ); + std::fs::write(&first, br#"{"schemaVersion":2,"files":[]}"#).unwrap(); + assert!(recover(dir.path(), false).unwrap_err().contains("schema")); + assert_eq!(std::fs::read(path).unwrap(), b"old\r\n"); + } + + #[test] + fn conflicting_pending_marker_cannot_be_attached_to_a_new_transaction() { + let (dir, changes) = fixture(); + let receipt = dir.path().join(".dcu-transaction-existing.json"); + std::fs::write(&receipt, b"{}").unwrap(); + let marker = marker_path(&changes[0].path).unwrap(); + std::fs::write( + &marker, + serde_json::to_vec(&TargetReceipt { + receipt: receipt.clone(), + }) + .unwrap(), + ) + .unwrap(); + let staged = vec![Staged { + change: changes.into_iter().next().unwrap(), + stage: None, + backup: None, + committed: false, + }]; + assert!( + target_markers(&staged, &receipt) + .err() + .unwrap() + .contains("pending update receipt") + ); + } + + #[test] + fn recovery_refuses_a_marker_owned_by_a_different_receipt() { + let dir = crashed_fixture(); + let path = dir.path().join("a.toml"); + let marker = marker_path(&path).unwrap(); + std::fs::write( + &marker, + serde_json::to_vec(&TargetReceipt { + receipt: dir.path().join(".dcu-transaction-other.json"), + }) + .unwrap(), + ) + .unwrap(); + assert!( + recover(dir.path(), false) + .unwrap_err() + .contains("different recovery receipt") + ); + assert_eq!(std::fs::read(path).unwrap(), b"new\r\n"); + } + + #[test] + fn empty_batches_and_out_of_scope_targets_have_no_side_effects() { + let (dir, changes) = fixture(); + commit(dir.path(), Vec::new()).unwrap_or_else(|e| panic!("{}", e.detail)); + let other = tempfile::TempDir::new().unwrap(); + assert!( + commit(other.path(), changes) + .err() + .unwrap() + .detail + .contains("escapes") + ); + assert_eq!(non_lock_files(dir.path()), 2); + } + + #[test] + fn replacement_errors_reconcile_original_replaced_and_external_bytes() { + for step in [Step::BeforeReplace, Step::AfterReplace] { + let (dir, changes) = fixture(); + let error = commit_with(dir.path(), changes, |at, _, _| { + if at == step { + Err("replacement error".into()) + } else { + Ok(()) + } + }) + .err() + .unwrap(); + assert!(!error.recovery_required); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"old\r\n" + ); + } + let (dir, changes) = fixture(); + let error = commit_with(dir.path(), changes, |at, _, path| { + if at == Step::BeforeReplace { + std::fs::write(path, b"external").unwrap(); + return Err("replacement state uncertain".into()); + } + Ok(()) + }) + .err() + .unwrap(); + assert!(error.recovery_required); + assert!(error.detail.contains("uncertain replacement state")); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"external" + ); + assert!( + recover(dir.path(), false) + .unwrap_err() + .contains("external edit") + ); + } + + #[cfg(unix)] + #[test] + fn recovery_rejects_hard_links_and_replacement_failures_keep_the_stage() { + let (dir, changes) = fixture(); + let path = &changes[0].path; + let linked = dir.path().join("linked"); + std::fs::hard_link(path, &linked).unwrap(); + assert!( + scoped(&std::fs::canonicalize(dir.path()).unwrap(), &linked) + .unwrap_err() + .contains("hard-linked") + ); + let mut stage = Some( + Builder::new() + .tempfile_in(dir.path()) + .unwrap() + .into_temp_path(), + ); + let stage_path = stage.as_ref().unwrap().to_path_buf(); + let directory = dir.path().join("target-directory"); + std::fs::create_dir(&directory).unwrap(); + assert!(replace(&mut stage, &directory).is_err()); + assert!(stage.is_some()); + assert!(stage_path.exists()); + } + + #[cfg(unix)] + #[test] + fn unix_metadata_removes_stale_attributes_and_preserves_a_different_group() { + use std::os::unix::fs::{MetadataExt, chown}; + let (dir, changes) = fixture(); + let origin = &changes[0].path; + let destination = &changes[1].path; + let attribute = if cfg!(target_os = "macos") { + "com.dcu.stale" + } else { + "user.dcu.stale" + }; + xattr::set(destination, attribute, b"stale").unwrap(); + // Root-owned coverage containers can exercise a differing group. Other + // hosts still validate attribute removal without requiring elevation. + let metadata = std::fs::metadata(origin).unwrap(); + if metadata.uid() == 0 { + chown(origin, None, Some(1)).unwrap(); + } + preserve_unix_metadata(origin, destination).unwrap(); + assert!(xattr::get(destination, attribute).unwrap().is_none()); + assert_eq!( + std::fs::metadata(origin).unwrap().gid(), + std::fs::metadata(destination).unwrap().gid() + ); + assert!(dir.path().is_dir()); + } + + #[cfg(unix)] + #[test] + fn completed_lock_is_released_even_if_an_inherited_description_remains_open() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("lock"); + let file = std::fs::File::create(&path).unwrap(); + let inherited = file.try_clone().unwrap(); + let guard = FileLock::acquire(file).unwrap(); + let second = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&second).is_err()); + drop(guard); + fs2::FileExt::try_lock_exclusive(&second).unwrap(); + fs2::FileExt::unlock(&second).unwrap(); + drop(inherited); + } + + #[test] + fn successful_batch_preserves_crlf_and_cleans_artifacts() { + let (dir, changes) = fixture(); + commit(dir.path(), changes).unwrap_or_else(|e| panic!("{}", e.detail)); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"new\r\n" + ); + assert_eq!(non_lock_files(dir.path()), 2); + } + + #[test] + fn staging_and_second_commit_failures_leave_originals() { + for fail_step in [Step::Stage, Step::Commit] { + let (dir, changes) = fixture(); + let e = commit_with(dir.path(), changes, |step, i, _| { + if step == fail_step && i == 1 { + Err("injected failure".into()) + } else { + Ok(()) + } + }) + .err() + .unwrap(); + assert!(!e.recovery_required); + for name in ["a.toml", "b.toml"] { + assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), b"old\r\n"); + } + assert_eq!(non_lock_files(dir.path()), 2); + } + } + + #[test] + fn concurrent_edit_is_preserved_and_prior_files_are_rolled_back() { + let (dir, changes) = fixture(); + let e = commit_with(dir.path(), changes, |step, i, path| { + if step == Step::Commit && i == 1 { + std::fs::write(path, b"external").unwrap(); + } + Ok(()) + }) + .err() + .unwrap(); + assert!(!e.recovery_required); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"old\r\n" + ); + assert_eq!( + std::fs::read(dir.path().join("b.toml")).unwrap(), + b"external" + ); + } + + #[test] + fn rollback_failure_keeps_receipt_and_backup_without_overwriting_external_edit() { + let (dir, changes) = fixture(); + let e = commit_with(dir.path(), changes, |step, i, path| { + if step == Step::Commit && i == 1 { + return Err("commit failed".into()); + } + if step == Step::Rollback { + std::fs::write(path, b"external edit").unwrap(); + } + Ok(()) + }) + .err() + .unwrap(); + assert!(e.recovery_required); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"external edit" + ); + let receipts = pending(dir.path()).unwrap(); + assert_eq!(receipts.len(), 1); + let receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(&receipts[0]).unwrap()).unwrap(); + let backup = receipt["files"][0]["backup"].as_str().unwrap(); + assert_eq!(std::fs::read(backup).unwrap(), b"old\r\n"); + assert!(e.detail.contains(receipts[0].to_str().unwrap())); + } + + #[test] + fn duplicate_targets_and_pending_receipts_are_rejected_before_writing() { + let (dir, mut changes) = fixture(); + changes.push(Change { + path: changes[0].path.clone(), + original: b"old\r\n".to_vec(), + replacement: b"other".to_vec(), + }); + assert!( + commit(dir.path(), changes) + .err() + .unwrap() + .detail + .contains("duplicate") + ); + std::fs::write(dir.path().join(".dcu-transaction-test.json"), b"{}").unwrap(); + assert!( + commit(dir.path(), Vec::new()) + .err() + .unwrap() + .detail + .contains("receipt") + ); + } + + #[test] + fn another_transaction_in_same_root_cannot_commit_concurrently() { + let (dir, changes) = fixture(); + commit_with(dir.path(), changes, |step, _, _| { + if step == Step::Commit { + assert!( + commit(dir.path(), Vec::new()) + .err() + .unwrap() + .detail + .contains("receipt") + ); + } + Ok(()) + }) + .unwrap_or_else(|e| panic!("{}", e.detail)); + assert!(pending(dir.path()).unwrap().is_empty()); + } + + // A child test process exits without running destructors, reproducing + // forced termination after one replacement. This hook exists only in tests. + #[test] + fn forced_termination_child() { + let Some(root) = std::env::var_os("DCU_TRANSACTION_CRASH_TEST_ROOT") else { + return; + }; + let root = PathBuf::from(root); + let changes = ["a.toml", "b.toml"] + .into_iter() + .map(|name| Change { + path: root.join(name), + original: b"old\r\n".to_vec(), + replacement: b"new\r\n".to_vec(), + }) + .collect(); + let _ = commit_with(&root, changes, |step, i, _| { + if step == Step::Commit && i == 1 { + std::process::exit(73); + } + Ok(()) + }); + panic!("child did not reach forced termination"); + } + + #[test] + fn forced_termination_leaves_original_backups_and_a_pending_receipt() { + let (dir, _) = fixture(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "transaction::tests::forced_termination_child", + "--nocapture", + ]) + .env("DCU_TRANSACTION_CRASH_TEST_ROOT", dir.path()) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(73), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"new\r\n" + ); + assert_eq!( + std::fs::read(dir.path().join("b.toml")).unwrap(), + b"old\r\n" + ); + let receipts = pending(dir.path()).unwrap(); + assert_eq!(receipts.len(), 1); + let receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(&receipts[0]).unwrap()).unwrap(); + for entry in receipt["files"].as_array().unwrap() { + assert_eq!( + std::fs::read(entry["backup"].as_str().unwrap()).unwrap(), + b"old\r\n" + ); + assert_eq!(entry["original_sha256"], digest(b"old\r\n")); + assert_eq!(entry["replacement_sha256"], digest(b"new\r\n")); + } + assert!(commit(dir.path(), Vec::new()).is_err()); + } + + fn crashed_fixture() -> tempfile::TempDir { + let (dir, _) = fixture(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "transaction::tests::forced_termination_child", + "--nocapture", + ]) + .env("DCU_TRANSACTION_CRASH_TEST_ROOT", dir.path()) + .output() + .unwrap() + .status; + assert_eq!(status.code(), Some(73)); + dir + } + + #[test] + fn explicit_recovery_rolls_back_or_finishes_and_is_idempotent() { + for finish in [false, true] { + let dir = crashed_fixture(); + assert!(recover(dir.path(), finish).unwrap()); + for name in ["a.toml", "b.toml"] { + assert_eq!( + std::fs::read(dir.path().join(name)).unwrap(), + if finish { b"new\r\n" } else { b"old\r\n" } + ); + } + assert!(!recover(dir.path(), finish).unwrap()); + assert_eq!(non_lock_files(dir.path()), 2); + } + } + + #[test] + fn recovery_rejects_live_transactions_external_edits_and_corrupt_backups() { + let (dir, changes) = fixture(); + commit_with(dir.path(), changes, |step, _, _| { + if step == Step::Commit { + assert!(recover(dir.path(), false).unwrap_err().contains("active")); + } + Ok(()) + }) + .unwrap_or_else(|e| panic!("{}", e.detail)); + let dir = crashed_fixture(); + std::fs::write(dir.path().join("b.toml"), "external edit").unwrap(); + assert!( + recover(dir.path(), false) + .unwrap_err() + .contains("external edit") + ); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"new\r\n" + ); + assert_eq!(pending(dir.path()).unwrap().len(), 1); + std::fs::write(dir.path().join("b.toml"), b"old\r\n").unwrap(); + let receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(pending(dir.path()).unwrap().remove(0)).unwrap()) + .unwrap(); + let backup = receipt["files"][0]["backup"].as_str().unwrap(); + std::fs::write(backup, "corrupt").unwrap(); + assert!(recover(dir.path(), false).unwrap_err().contains("corrupt")); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"new\r\n" + ); + } + + #[test] + fn recovery_rejects_forged_paths_and_duplicate_targets_before_writing() { + for duplicate in [false, true] { + let dir = crashed_fixture(); + let receipt_path = pending(dir.path()).unwrap().remove(0); + let mut receipt: serde_json::Value = + serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); + if duplicate { + receipt["files"][1]["path"] = receipt["files"][0]["path"].clone(); + } else { + receipt["files"][0]["backup"] = dir + .path() + .parent() + .unwrap() + .join("outside.tmp") + .display() + .to_string() + .into(); + } + std::fs::write(&receipt_path, serde_json::to_vec(&receipt).unwrap()).unwrap(); + assert!(recover(dir.path(), false).is_err()); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"new\r\n" + ); + assert!(receipt_path.exists()); + } + } + + #[test] + fn different_working_directories_share_target_locks() { + let (dir, changes) = fixture(); + let other = dir.path().parent().unwrap(); + commit_with(dir.path(), changes, |step, _, path| { + if step == Step::Commit { + let bytes = std::fs::read(path).unwrap(); + let error = commit( + other, + vec![Change { + path: path.to_owned(), + original: bytes, + replacement: b"other".to_vec(), + }], + ) + .err() + .unwrap(); + assert!(error.detail.contains("busy")); + } + Ok(()) + }) + .unwrap_or_else(|e| panic!("{}", e.detail)); + } + + #[test] + fn interrupted_transactions_protect_targets_from_other_working_directories() { + let dir = crashed_fixture(); + let path = dir.path().join("a.toml"); + assert_eq!( + pending_for(std::iter::once(path.as_path())).unwrap().len(), + 1 + ); + let original = std::fs::read(&path).unwrap(); + let error = commit( + dir.path().parent().unwrap(), + vec![Change { + path: path.clone(), + original: original.clone(), + replacement: b"other".to_vec(), + }], + ) + .err() + .unwrap(); + assert!(error.detail.contains("pending update receipt")); + assert_eq!(std::fs::read(&path).unwrap(), original); + recover(dir.path(), false).unwrap(); + assert!( + pending_for(std::iter::once(path.as_path())) + .unwrap() + .is_empty() + ); + commit( + dir.path().parent().unwrap(), + vec![Change { + path, + original: b"old\r\n".to_vec(), + replacement: b"other".to_vec(), + }], + ) + .unwrap_or_else(|e| panic!("{}", e.detail)); + } + + #[test] + fn finalization_failures_report_committed_bytes_and_keep_recoverable_evidence() { + for finish in [false, true] { + let (dir, changes) = fixture(); + let error = commit_with(dir.path(), changes, |step, _, _| { + if step == Step::Finalize { + Err("cleanup failed".into()) + } else { + Ok(()) + } + }) + .err() + .unwrap(); + assert!(error.committed); + assert!(error.recovery_required); + for name in ["a.toml", "b.toml"] { + assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), b"new\r\n"); + } + recover(dir.path(), finish).unwrap(); + for name in ["a.toml", "b.toml"] { + assert_eq!( + std::fs::read(dir.path().join(name)).unwrap(), + if finish { b"new\r\n" } else { b"old\r\n" } + ); + } + assert_eq!(non_lock_files(dir.path()), 2); + } + } + + #[test] + fn stale_target_markers_are_reclaimed_only_under_target_locks() { + let (dir, changes) = fixture(); + let marker = marker_path(&changes[0].path).unwrap(); + let receipt = TargetReceipt { + receipt: dir.path().join(".dcu-transaction-gone.json"), + }; + std::fs::write(&marker, serde_json::to_vec(&receipt).unwrap()).unwrap(); + assert!( + pending_for(changes.iter().map(|c| c.path.as_path())) + .unwrap() + .is_empty() + ); + assert!(marker.exists(), "read-only queries must not clean markers"); + commit(dir.path(), changes).unwrap_or_else(|e| panic!("{}", e.detail)); + assert!(!marker.exists()); + assert_eq!(non_lock_files(dir.path()), 2); + } + + #[cfg(windows)] + #[test] + fn actual_receipt_deletion_failure_preserves_committed_files_and_backups() { + use std::os::windows::fs::OpenOptionsExt; + let (dir, changes) = fixture(); + let mut held = None; + let error = commit_with(dir.path(), changes, |step, _, _| { + if step == Step::Finalize { + let path = pending(dir.path()).unwrap().remove(0); + // Permit existing readers/writers but deny deletion until the + // external handle closes, producing a real sharing violation. + held = Some( + std::fs::OpenOptions::new() + .read(true) + .share_mode(3) + .open(path) + .unwrap(), + ); + } + Ok(()) + }) + .err() + .unwrap(); + assert!(error.committed); + assert!(error.recovery_required); + drop(held); + recover(dir.path(), false).unwrap(); + for name in ["a.toml", "b.toml"] { + assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), b"old\r\n"); + } + assert_eq!(non_lock_files(dir.path()), 2); + } + + #[cfg(windows)] + #[test] + fn windows_readonly_and_hardlinked_targets_are_rejected() { + let (dir, changes) = fixture(); + let path = changes[0].path.clone(); + let original = std::fs::metadata(&path).unwrap().permissions(); + let mut readonly = original.clone(); + readonly.set_readonly(true); + std::fs::set_permissions(&path, readonly).unwrap(); + let error = commit(dir.path(), changes).err().unwrap(); + std::fs::set_permissions(&path, original).unwrap(); + assert!(error.detail.contains("read-only")); + std::fs::hard_link(&path, dir.path().join("linked")).unwrap(); + assert!( + commit( + dir.path(), + vec![Change { + path, + original: b"old\r\n".to_vec(), + replacement: Vec::new() + }] + ) + .err() + .unwrap() + .detail + .contains("hard-linked") + ); + } + + #[cfg(windows)] + #[test] + fn windows_sharing_violation_rolls_back_first_file() { + use std::os::windows::fs::OpenOptionsExt; + let (dir, changes) = fixture(); + let locked = std::fs::OpenOptions::new() + .read(true) + .share_mode(1) + .open(&changes[1].path) + .unwrap(); + let error = commit(dir.path(), changes).err().unwrap(); + drop(locked); + assert!(error.rolled_back); + assert!(!error.recovery_required); + assert_eq!( + std::fs::read(dir.path().join("a.toml")).unwrap(), + b"old\r\n" + ); + assert_eq!( + std::fs::read(dir.path().join("b.toml")).unwrap(), + b"old\r\n" + ); + assert!(pending(dir.path()).unwrap().is_empty()); + } + + #[cfg(windows)] + #[test] + fn windows_replace_preserves_creation_metadata() { + use std::os::windows::fs::MetadataExt; + let (dir, changes) = fixture(); + let path = changes[0].path.clone(); + let before = std::fs::metadata(&path).unwrap().creation_time(); + commit(dir.path(), changes).unwrap_or_else(|e| panic!("{}", e.detail)); + assert_eq!(std::fs::metadata(&path).unwrap().creation_time(), before); + } + + #[cfg(windows)] + #[test] + fn windows_stages_backups_and_replacement_retain_protected_dacl() { + let (dir, changes) = fixture(); + let mut acl = read_windows_dacl(&changes[0].path).unwrap(); + acl.protected = true; + for change in &changes { + set_windows_dacl(&change.path, &acl).unwrap(); + } + commit_with(dir.path(), changes, |step, _, _| { + if step == Step::Commit { + for entry in std::fs::read_dir(dir.path()).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(".dcu-stage-") || name.starts_with(".dcu-backup-") { + assert!(read_windows_dacl(&entry.path()).unwrap().protected); + } + } + } + Ok(()) + }) + .unwrap_or_else(|e| panic!("{}", e.detail)); + assert!( + read_windows_dacl(&dir.path().join("a.toml")) + .unwrap() + .protected + ); + } + + #[cfg(unix)] + #[test] + fn symlinks_hardlinks_and_unix_permissions() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let (dir, changes) = fixture(); + std::fs::set_permissions(&changes[0].path, std::fs::Permissions::from_mode(0o640)).unwrap(); + commit(dir.path(), changes).unwrap_or_else(|e| panic!("{}", e.detail)); + assert_eq!( + std::fs::metadata(dir.path().join("a.toml")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o640 + ); + let original = dir.path().join("a.toml"); + let linked = dir.path().join("linked"); + symlink(&original, &linked).unwrap(); + assert!( + commit( + dir.path(), + vec![Change { + path: linked, + original: b"new\r\n".to_vec(), + replacement: Vec::new() + }] + ) + .is_err() + ); + std::fs::hard_link(&original, dir.path().join("hard")).unwrap(); + assert!( + commit( + dir.path(), + vec![Change { + path: original, + original: b"new\r\n".to_vec(), + replacement: Vec::new() + }] + ) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn unix_replacement_retains_owner_group_and_extended_attributes() { + use std::os::unix::fs::MetadataExt; + let (dir, changes) = fixture(); + let path = changes[0].path.clone(); + let attribute = if cfg!(target_os = "macos") { + "com.dcu.test" + } else { + "user.dcu.test" + }; + xattr::set(&path, attribute, b"preserve me").unwrap(); + let original = std::fs::metadata(&path).unwrap(); + commit(dir.path(), changes).unwrap_or_else(|e| panic!("{}", e.detail)); + let current = std::fs::metadata(&path).unwrap(); + assert_eq!( + (original.uid(), original.gid()), + (current.uid(), current.gid()) + ); + assert_eq!( + xattr::get(&path, attribute).unwrap().unwrap(), + b"preserve me" + ); + } +} diff --git a/crates/cli/tests/cli_output.rs b/crates/cli/tests/cli_output.rs new file mode 100644 index 0000000..a2d528a --- /dev/null +++ b/crates/cli/tests/cli_output.rs @@ -0,0 +1,364 @@ +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn traversal_and_cleanup_failures_are_in_json_and_exit_nonzero() { + for scan_error in [true, false] { + let root = TempDir::new().unwrap(); + std::fs::write(root.path().join("package.json"), "{}").unwrap(); + let mut args = vec!["--format", "json-report", "--fail-on-incomplete"]; + if scan_error { + std::fs::write(root.path().join(".ignore"), "[z-a]\n").unwrap(); + args.push("-d"); + } else { + std::fs::create_dir(root.path().join("package-lock.json")).unwrap(); + args.push("--rm"); + } + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(args) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let diagnostic = &report["diagnostics"][0]; + assert_eq!( + diagnostic["code"], + if scan_error { + "execution-failed" + } else { + "cleanup-failed" + } + ); + if scan_error { + assert!(diagnostic["message"].as_str().unwrap().contains("range")); + } else { + assert!( + diagnostic["path"] + .as_str() + .unwrap() + .ends_with("package-lock.json") + ); + } + } +} + +#[test] +fn json_stdout_is_one_document_and_reports_unsupported_declarations() { + let root = TempDir::new().unwrap(); + let script = + "// implementation(\"comment:fake:1.0\")\r\nimplementation(\"g:a:${getVersion()}\")\r\n"; + let file = root.path().join("build.gradle.kts"); + std::fs::write(&file, script).unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(["--manifest", "build.gradle.kts", "--format", "json", "-v"]) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let rows: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["name"], "g:a"); + assert_eq!(rows[0]["status"], "unsupported"); + assert!(rows[0]["latest"].is_null()); + assert_eq!(std::fs::read_to_string(file).unwrap(), script); +} + +#[test] +fn local_tool_mode_cannot_be_combined_with_project_mutations() { + let result = Command::new(env!("CARGO_BIN_EXE_dcu")) + .args(["--local-tools", "-u"]) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("cannot be used")); +} + +#[test] +fn filtered_local_mode_needs_no_project_and_emits_json() { + let root = TempDir::new().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args([ + "--local-tools", + "node", + "--reject", + "node", + "--format", + "json", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!([]) + ); +} + +#[test] +fn recovery_cli_uses_no_registry_and_reports_committed_or_restored_outcome() { + use sha2::{Digest, Sha256}; + for (mode, outcome, expected) in [ + ("rollback", "rolled-back", "old\r\n"), + ("finish", "committed", "new\r\n"), + ] { + let root = TempDir::new().unwrap(); + let target = root.path().join("build.gradle.kts"); + let backup = root.path().join(".dcu-backup-test.tmp"); + let stage = root.path().join(".dcu-stage-test.tmp"); + let receipt = root.path().join(".dcu-transaction-active.json"); + std::fs::write(&target, "new\r\n").unwrap(); + std::fs::write(&backup, "old\r\n").unwrap(); + std::fs::write(&stage, "new\r\n").unwrap(); + let journal = serde_json::json!({"schemaVersion":1,"files":[{ + "path":target,"backup":backup,"staged":stage, + "original_sha256":format!("{:x}", Sha256::digest(b"old\r\n")), + "replacement_sha256":format!("{:x}", Sha256::digest(b"new\r\n")) + }]}); + std::fs::write(&receipt, serde_json::to_vec(&journal).unwrap()).unwrap(); + let run = || { + Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(["--recover", mode, "--format", "json-report"]) + .output() + .unwrap() + }; + let output = run(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["applyOutcome"], outcome); + assert_eq!(report["items"], serde_json::json!([])); + assert_eq!(std::fs::read_to_string(&target).unwrap(), expected); + assert!(!receipt.exists()); + assert!(!backup.exists()); + assert!(!stage.exists()); + let again: serde_json::Value = serde_json::from_slice(&run().stdout).unwrap(); + assert_eq!(again["applyOutcome"], "no-changes"); + } +} + +#[tokio::test] +async fn private_maven_config_is_explicit_and_credentials_are_child_process_scoped() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, path}, + }; + let server = MockServer::start().await; + Mock::given(path("/maven/org/example/library/maven-metadata.xml")) + .and(header("authorization", "Bearer test-secret")) + .respond_with(ResponseTemplate::new(200).set_body_string("1.0.01.1.0")) + .expect(1).mount(&server).await; + let root = TempDir::new().unwrap(); + let script = format!( + "repositories {{ maven(\"{}/maven\") }}\nimplementation(\"org.example:library:1.0.0\")\n", + server.uri() + ); + let target = root.path().join("build.gradle.kts"); + std::fs::write(&target, &script).unwrap(); + std::fs::write(root.path().join("repositories.json"), serde_json::to_vec(&serde_json::json!({ + "schemaVersion":1, "repositories":[{"url":format!("{}/maven", server.uri()), "tokenEnv":"DCU_TEST_MAVEN_TOKEN"}] + })).unwrap()).unwrap(); + let cwd = root.path().to_owned(); + let output = tokio::task::spawn_blocking(move || { + Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(cwd) + .env("DCU_TEST_MAVEN_TOKEN", "test-secret") + .args([ + "-u", + "--maven-config", + "repositories.json", + "--fail-on-incomplete", + "--format", + "json-report", + ]) + .output() + .unwrap() + }) + .await + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + std::fs::read_to_string(&target) + .unwrap() + .contains("library:1.1.0") + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("test-secret")); + server.verify().await; + std::fs::write(&target, &script).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(["-u", "--fail-on-incomplete", "--format", "json-report"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert_eq!(std::fs::read_to_string(&target).unwrap(), script); + server.verify().await; +} + +#[test] +fn incomplete_ci_exit_two_precedes_update_policy_and_prevents_cleanup() { + let root = TempDir::new().unwrap(); + std::fs::write(root.path().join("package.json"), "{}").unwrap(); + let script = "implementation(\"g:a:${lookup()}\")\r\n"; + std::fs::write(root.path().join("build.gradle.kts"), script).unwrap(); + std::fs::write(root.path().join("package-lock.json"), "keep").unwrap(); + std::fs::create_dir(root.path().join("node_modules")).unwrap(); + std::fs::write(root.path().join("node_modules/keep"), "keep").unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args([ + "-d", + "-u", + "--rm", + "--fail-on-incomplete", + "-e", + "2", + "--format", + "json-report", + "-v", + ]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["schemaVersion"], 2); + assert_eq!(json["applyOutcome"], "aborted"); + assert_eq!(json["summary"]["updated"], 0); + assert_eq!(json["summary"]["incomplete"], 1); + assert_eq!( + std::fs::read_to_string(root.path().join("build.gradle.kts")).unwrap(), + script + ); + assert!(root.path().join("package-lock.json").exists()); + assert!(root.path().join("node_modules/keep").exists()); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(["-d", "-e", "2", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(0)); // Preserve the original -e 2 policy. + assert!( + serde_json::from_slice::(&output.stdout) + .unwrap() + .is_array() + ); +} + +#[test] +fn legacy_is_one_object_and_rejects_multiple_manifests_before_mutations() { + let root = TempDir::new().unwrap(); + std::fs::write( + root.path().join("build.gradle.kts"), + "implementation(\"g:a:${lookup()}\")\n", + ) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args([ + "--manifest", + "build.gradle.kts", + "--format", + "json-legacy", + "-v", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({}) + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("unsupported")); + std::fs::write(root.path().join("package.json"), "{}").unwrap(); + std::fs::write(root.path().join("package-lock.json"), "keep").unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(["-d", "-u", "--rm", "--format", "json-legacy"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("one effective project manifest")); + assert!(root.path().join("package-lock.json").exists()); +} + +#[test] +fn pending_receipts_are_read_only_diagnostics_and_block_upgrade() { + let root = TempDir::new().unwrap(); + std::fs::write(root.path().join("package.json"), "{}").unwrap(); + let receipt = root.path().join(".dcu-transaction-active.json"); + std::fs::write(&receipt, "manual recovery needed").unwrap(); + for (flags, expected, outcome) in [ + (vec!["--format", "json-report"], 0, "not-requested"), + ( + vec!["--format", "json-report", "--fail-on-incomplete"], + 2, + "not-requested", + ), + (vec!["-u", "--format", "json-report"], 1, "aborted"), + ] { + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(flags) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(expected)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["applyOutcome"], outcome); + assert_eq!(report["diagnostics"][0]["code"], "pending-recovery"); + assert_eq!( + std::fs::read_to_string(&receipt).unwrap(), + "manual recovery needed" + ); + } +} + +#[test] +fn fatal_parse_errors_and_empty_local_reports_keep_the_versioned_contract() { + let root = TempDir::new().unwrap(); + std::fs::write(root.path().join("package.json"), "{bad JSON").unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args(["--format", "json-report"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schemaVersion"], 2); + assert_eq!(report["diagnostics"][0]["code"], "execution-failed"); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .current_dir(root.path()) + .args([ + "--local-tools", + "node", + "--reject", + "node", + "--fail-on-incomplete", + "--format", + "json-report", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["items"], serde_json::json!([])); + assert_eq!(report["summary"]["checked"], 0); + let output = Command::new(env!("CARGO_BIN_EXE_dcu")) + .args(["--local-tools", "--strict-compatibility"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); // clap argument conflict. +} diff --git a/crates/cli/tests/fixtures/tauri/.github/workflows/CI.yml b/crates/cli/tests/fixtures/tauri/.github/workflows/CI.yml new file mode 100644 index 0000000..d76a119 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/.github/workflows/CI.yml @@ -0,0 +1,5 @@ +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo fixture diff --git a/crates/cli/tests/fixtures/tauri/.mise.toml b/crates/cli/tests/fixtures/tauri/.mise.toml new file mode 100644 index 0000000..402d2d5 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/.mise.toml @@ -0,0 +1,3 @@ +[tools] +bun = "1.1.0" +yarn = "1.22.20" diff --git a/crates/cli/tests/fixtures/tauri/.node-version b/crates/cli/tests/fixtures/tauri/.node-version new file mode 100644 index 0000000..e43bba4 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/.node-version @@ -0,0 +1 @@ +20.1.0 diff --git a/crates/cli/tests/fixtures/tauri/.nvmrc b/crates/cli/tests/fixtures/tauri/.nvmrc new file mode 100644 index 0000000..c1cedab --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/.nvmrc @@ -0,0 +1 @@ +v20.1.0 diff --git a/crates/cli/tests/fixtures/tauri/.tool-versions b/crates/cli/tests/fixtures/tauri/.tool-versions new file mode 100644 index 0000000..a17fd8f --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/.tool-versions @@ -0,0 +1,5 @@ +# Project pins, not the locally installed versions +nodejs 20.1.0 +java 17 +rust 1.85.0 +bun 1.1.0 diff --git a/crates/cli/tests/fixtures/tauri/Cargo.toml.fixture b/crates/cli/tests/fixtures/tauri/Cargo.toml.fixture new file mode 100644 index 0000000..d0eabca --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/Cargo.toml.fixture @@ -0,0 +1,3 @@ +[package] +name = "fixture" +version = "0.1.0" diff --git a/crates/cli/tests/fixtures/tauri/Dockerfile b/crates/cli/tests/fixtures/tauri/Dockerfile new file mode 100644 index 0000000..c35f1b5 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/app/build.gradle.kts b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..5f1efcb --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,17 @@ +android { + compileSdk = 35 + defaultConfig { + targetSdk = 35 + minSdk = 24 // Supported devices must stay supported + } +} +dependencies { + implementation("androidx.webkit:webkit:1.6.1") + implementation("androidx.webkit:webkit:$webkitVersion") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.8.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") + implementation("dynamic.example:library:${lookupVersion()}") +} diff --git a/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/build.gradle.kts b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..068e66f --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,9 @@ +buildscript { + repositories { google(); mavenCentral() } + dependencies { + classpath("com.android.tools.build:gradle:8.5.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} +// classpath("fake.comment:dependency:1.0.0") +/* implementation("fake.block:dependency:1.0.0") */ diff --git a/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/buildSrc/build.gradle.kts b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..477e9d8 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,4 @@ +repositories { google(); mavenCentral() } +dependencies { + implementation("com.android.tools.build:gradle:8.5.1") +} diff --git a/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/gradle.properties b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..cd7ec25 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/gradle.properties @@ -0,0 +1,2 @@ +# This is the source declaration for a referenced dependency version +webkitVersion = 1.6.1 diff --git a/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/settings.gradle.kts b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/settings.gradle.kts new file mode 100644 index 0000000..d86c5e4 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/apps/app/src-tauri/gen/android/settings.gradle.kts @@ -0,0 +1,14 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} +include(":app") diff --git a/crates/cli/tests/fixtures/tauri/gradle/libs.versions.toml b/crates/cli/tests/fixtures/tauri/gradle/libs.versions.toml new file mode 100644 index 0000000..27c9d31 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/gradle/libs.versions.toml @@ -0,0 +1,13 @@ +[versions] +webkit = "1.6.1" # Shared version source +agp = "8.5.1" +kotlin = "1.9.25" + +[libraries] +webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +webkit-alias = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } +material = { module = "com.google.android.material:material", version = "1.8.0" } + +[plugins] +android = { id = "com.android.application", version.ref = "agp" } +kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/crates/cli/tests/fixtures/tauri/gradle/wrapper/gradle-wrapper.properties b/crates/cli/tests/fixtures/tauri/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..af1688a --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,3 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +# Keep distribution type and all formatting diff --git a/crates/cli/tests/fixtures/tauri/mise.toml b/crates/cli/tests/fixtures/tauri/mise.toml new file mode 100644 index 0000000..2c51b76 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/mise.toml @@ -0,0 +1,4 @@ +[tools] +node = "20" +rust = "stable" # Moving channel remains stable +pnpm = "10.12.1" diff --git a/crates/cli/tests/fixtures/tauri/package.json b/crates/cli/tests/fixtures/tauri/package.json new file mode 100644 index 0000000..565b8bd --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/package.json @@ -0,0 +1,6 @@ +{ + "name": "tauri-monorepo", + "packageManager": "pnpm@10.12.1", + "engines": { "node": ">=20" }, + "dependencies": {} +} diff --git a/crates/cli/tests/fixtures/tauri/pyproject.toml b/crates/cli/tests/fixtures/tauri/pyproject.toml new file mode 100644 index 0000000..d847566 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "fixture" +version = "0.1.0" +dependencies = [] diff --git a/crates/cli/tests/fixtures/tauri/rust-toolchain.toml b/crates/cli/tests/fixtures/tauri/rust-toolchain.toml new file mode 100644 index 0000000..b475f2f --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.85.0" +components = ["rustfmt", "clippy"] diff --git a/crates/cli/tests/fixtures/tauri/settings.gradle.kts b/crates/cli/tests/fixtures/tauri/settings.gradle.kts new file mode 100644 index 0000000..afa7f37 --- /dev/null +++ b/crates/cli/tests/fixtures/tauri/settings.gradle.kts @@ -0,0 +1,2 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositories { google(); mavenCentral() } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d838de5..735bacc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -18,6 +18,7 @@ miette.workspace = true ignore.workspace = true tracing.workspace = true reqwest.workspace = true +tokio.workspace = true node-semver.workspace = true semver.workspace = true pep440_rs.workspace = true @@ -27,6 +28,8 @@ toml_edit.workspace = true [dev-dependencies] rstest.workspace = true tempfile.workspace = true +wiremock.workspace = true +rustls.workspace = true criterion.workspace = true [[bench]] diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 5997e62..de3d573 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -35,7 +35,12 @@ const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; /// TLS backend at the platform level, not a recoverable runtime condition. #[must_use] pub fn build_client() -> Client { + build_client_with_redirects(reqwest::redirect::Policy::limited(10)) +} + +pub(crate) fn build_client_with_redirects(policy: reqwest::redirect::Policy) -> Client { Client::builder() + .redirect(policy) .timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)) .user_agent(concat!( "dependency-check-updates/", @@ -108,3 +113,46 @@ where })) .await } + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path}; + + #[tokio::test] + async fn checked_requests_preserve_success_and_report_http_and_network_failures() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/ok")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(path("/missing")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let client = build_client(); + assert!( + send_checked(client.get(format!("{}/ok", server.uri())), "pkg") + .await + .unwrap() + .status() + .is_success() + ); + assert!( + send_checked(client.get(format!("{}/missing", server.uri())), "pkg") + .await + .unwrap_err() + .to_string() + .contains("404") + ); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + assert!( + send_checked(client.get(format!("http://{address}/")), "pkg") + .await + .is_err() + ); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7d06524..b6f40e5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod http; pub mod manifest; +pub mod metadata; pub mod patch; pub mod toml_decor; pub mod types; @@ -22,6 +23,7 @@ pub use http::{ DEFAULT_MAX_CONCURRENT_REQUESTS, build_client, resolve_batch_concurrent, send_checked, }; pub use manifest::{ManifestHandler, ParsedManifest, Scanner}; +pub use metadata::MetadataCache; pub use patch::{Patch, apply_byte_patches}; pub use toml_decor::replace_string_preserving_decor; pub use types::{ diff --git a/crates/core/src/manifest.rs b/crates/core/src/manifest.rs index b06eccd..46c9311 100644 --- a/crates/core/src/manifest.rs +++ b/crates/core/src/manifest.rs @@ -71,6 +71,14 @@ impl Scanner { /// `read_dir` on every invocation just to catch a rare layout. #[must_use] pub fn scan_dir(root: &Path) -> Vec { + Self::scan_dir_checked(root).unwrap_or_default() + } + + /// Scan without silently dropping filesystem errors. + /// + /// # Errors + /// Returns inaccessible candidate or workflow directory errors. + pub fn scan_dir_checked(root: &Path) -> Result, DcuError> { let mut manifests = Vec::new(); let candidates = [ @@ -84,29 +92,61 @@ impl Scanner { "compose.yaml", "docker-compose.yml", "docker-compose.yaml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + ".nvmrc", + ".node-version", + "rust-toolchain", + "rust-toolchain.toml", + ".tool-versions", + "mise.toml", + ".mise.toml", ]; for filename in &candidates { let path = root.join(filename); - if path.is_file() { - if let Some(kind) = ManifestKind::from_path(&path) { - manifests.push(ManifestRef { path, kind }); - } + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => return Err(DcuError::Io { path, source }), + }; + if metadata.is_file() + && let Some(kind) = ManifestKind::from_path(&path) + { + manifests.push(ManifestRef { path, kind }); } } // GitHub Actions: enumerate `.github/workflows/*.yml`/`*.yaml`. let workflows_dir = root.join(".github").join("workflows"); - if let Ok(entries) = std::fs::read_dir(&workflows_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } - if let Some(kind) = ManifestKind::from_path(&path) { - manifests.push(ManifestRef { path, kind }); + match std::fs::read_dir(&workflows_dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|source| DcuError::Io { + path: workflows_dir.clone(), + source, + })?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(kind) = ManifestKind::from_path(&path) { + manifests.push(ManifestRef { path, kind }); + } } } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(DcuError::Io { + path: workflows_dir, + source, + }); + } } // Stable order so output is reproducible across platforms regardless @@ -118,7 +158,7 @@ impl Scanner { // 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 + Ok(manifests) } /// Find a specific manifest file. @@ -153,6 +193,40 @@ impl Scanner { /// every GitHub Actions manifest. #[must_use] pub fn scan_deep(root: &Path) -> Vec { + Self::scan_deep_checked(root).unwrap_or_default() + } + + /// Recursively scan while preserving traversal errors. + /// + /// # Errors + /// Returns filesystem and ignore-file errors from traversal. + pub fn scan_deep_checked(root: &Path) -> Result, DcuError> { + Self::walk(root, None) + } + + /// Discover only files under `scopes` (directories or exact file paths). + /// Traverses their ancestor paths to apply the same ignore rules as a deep + /// scan, while pruning unrelated branches before entering them. + #[must_use] + pub fn scan_scoped(root: &Path, scopes: &[std::path::PathBuf]) -> Vec { + Self::scan_scoped_checked(root, scopes).unwrap_or_default() + } + + /// Scope a recursive scan without discarding traversal errors. + /// + /// # Errors + /// Returns filesystem and ignore-file errors from traversal. + pub fn scan_scoped_checked( + root: &Path, + scopes: &[std::path::PathBuf], + ) -> Result, DcuError> { + Self::walk(root, Some(scopes.to_vec())) + } + + fn walk( + root: &Path, + scopes: Option>, + ) -> Result, DcuError> { use ignore::WalkBuilder; let walker = WalkBuilder::new(root) @@ -162,13 +236,30 @@ impl Scanner { .git_ignore(true) .git_global(true) .git_exclude(true) - .filter_entry(|entry| { + .filter_entry(move |entry| { + if let Some(scopes) = &scopes + && !scopes.iter().any(|scope| { + entry.path().starts_with(scope) + || (entry.file_type().is_some_and(|ft| ft.is_dir()) + && scope.starts_with(entry.path())) + }) + { + return false; + } let name = entry.file_name().to_string_lossy(); // Skip common dependency/build directories and hidden dirs that // are NOT `.github`. The leading-dot check lets `.github` and // any descendants through while still pruning `.git`, `.venv`, // `.idea`, etc. - if name.starts_with('.') && name.as_ref() != "." && name.as_ref() != ".github" { + if name.starts_with('.') + && name.as_ref() != "." + && name.as_ref() != ".github" + && !(entry.file_type().is_some_and(|ft| ft.is_file()) + && matches!( + name.as_ref(), + ".nvmrc" | ".node-version" | ".tool-versions" | ".mise.toml" + )) + { return false; } !matches!( @@ -192,7 +283,17 @@ impl Scanner { // 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() { + for entry in walker { + let entry = entry.map_err(|source| DcuError::Io { + path: root.to_owned(), + source: std::io::Error::other(source.to_string()), + })?; + if let Some(source) = entry.error() { + return Err(DcuError::Io { + path: entry.path().to_owned(), + source: std::io::Error::other(source.to_string()), + }); + } if !entry.file_type().is_some_and(|ft| ft.is_file()) { continue; } @@ -207,7 +308,7 @@ impl Scanner { // 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 + Ok(manifests) } /// Find manifests, either from a specific path or by scanning the directory. @@ -233,9 +334,9 @@ impl Scanner { } let manifests = if deep { - Self::scan_deep(root) + Self::scan_deep_checked(root)? } else { - Self::scan_dir(root) + Self::scan_dir_checked(root)? }; if manifests.is_empty() { @@ -250,6 +351,14 @@ impl Scanner { #[cfg(test)] mod tests { + #[cfg(unix)] + #[test] + fn root_candidate_metadata_errors_are_not_silently_ignored() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("package.json"); + std::os::unix::fs::symlink("package.json", &path).unwrap(); + assert!(super::Scanner::scan_dir_checked(dir.path()).is_err()); + } use super::*; use rstest::{fixture, rstest}; use std::fs; @@ -355,7 +464,7 @@ mod tests { fn test_scan_dir_ignores_unknown_files() { let dir = TempDir::new().unwrap(); create_temp_manifest(dir.path(), "README.md", "# Hello"); - create_temp_manifest(dir.path(), "build.gradle", ""); + create_temp_manifest(dir.path(), "notes.gradle", ""); let manifests = Scanner::scan_dir(dir.path()); assert!(manifests.is_empty()); @@ -367,7 +476,7 @@ mod tests { /// non-existent path) does not share the fixture. #[rstest] #[case::valid_package_json("package.json", "{}", Some(ManifestKind::PackageJson))] - #[case::unknown_file("build.gradle", "", None)] + #[case::unknown_file("notes.gradle", "", None)] fn from_path_existing_file( tmp: TempDir, #[case] filename: &str, @@ -621,4 +730,52 @@ mod tests { .count(); assert_eq!(secret_count, 0, "other hidden dirs must stay hidden"); } + + #[test] + fn scoped_scan_prunes_unrelated_branches_and_preserves_ignore_rules() { + let dir = TempDir::new().unwrap(); + for name in ["apps/a", "apps/b", "apps/a/build", "apps/a/.secret"] { + std::fs::create_dir_all(dir.path().join(name)).unwrap(); + } + create_temp_manifest(dir.path(), ".tool-versions", "node 20\n"); + create_temp_manifest(&dir.path().join("apps/a"), "build.gradle.kts", ""); + create_temp_manifest(&dir.path().join("apps/a"), ".node-version", "20\n"); + create_temp_manifest( + &dir.path().join("apps/a"), + "gradle.properties", + "ignoredVersion=1.0\n", + ); + create_temp_manifest(&dir.path().join("apps/a"), ".ignore", "gradle.properties\n"); + for folder in ["apps/b", "apps/a/build", "apps/a/.secret"] { + create_temp_manifest(&dir.path().join(folder), "package.json", "{}"); + } + let paths: Vec<_> = Scanner::scan_scoped( + dir.path(), + &[dir.path().join("apps/a"), dir.path().join(".tool-versions")], + ) + .into_iter() + .map(|m| m.path) + .collect(); + assert_eq!(paths.len(), 3); + assert!(paths.contains(&dir.path().join(".tool-versions"))); + assert!(paths.contains(&dir.path().join("apps/a/.node-version"))); + assert!(paths.contains(&dir.path().join("apps/a/build.gradle.kts"))); + } +} +#[test] +fn checked_scans_report_traversal_and_workflow_errors() { + let dir = tempfile::TempDir::new().unwrap(); + assert!(Scanner::scan_deep_checked(&dir.path().join("missing")).is_err()); + std::fs::create_dir(dir.path().join(".github")).unwrap(); + std::fs::write(dir.path().join(".github/workflows"), "not a directory").unwrap(); + assert!(Scanner::scan_dir_checked(dir.path()).is_err()); + assert!(Scanner::discover(dir.path(), None, false).is_err()); +} + +#[test] +fn checked_scans_report_invalid_ignore_rules() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + std::fs::write(dir.path().join(".ignore"), "[z-a]\n").unwrap(); + assert!(Scanner::scan_deep_checked(dir.path()).is_err()); } diff --git a/crates/core/src/metadata.rs b/crates/core/src/metadata.rs new file mode 100644 index 0000000..e508d4e --- /dev/null +++ b/crates/core/src/metadata.rs @@ -0,0 +1,470 @@ +//! Bounded, run-scoped GET metadata cache with in-flight request sharing. +//! Keys include all explicitly supplied headers and response size policy. This +//! client has no auth defaults or cookie jar; credentials are never logged. +use crate::{DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, build_client}; +use futures::future::{BoxFuture, FutureExt, Shared}; +use reqwest::header::HeaderMap; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Semaphore; + +const MAX_KEYS: usize = 512; +const MAX_CACHED_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Clone, Hash, PartialEq, Eq)] +struct Key { + url: String, + headers: Vec<(String, Vec)>, + limit: usize, +} + +type Response = Result<(Arc<[u8]>, bool), Arc>; +type Request = Shared>; + +struct Cached { + id: u64, + request: Request, +} + +struct State { + client: reqwest::Client, + requests: Mutex>, + semaphore: Arc, + cached_bytes: Arc, + sequence: AtomicU64, +} + +/// Share metadata/download responses within one execution, never on disk. +/// Clones share in-flight requests; a newly constructed cache is independent. +#[derive(Clone)] +pub struct MetadataCache { + state: Arc, +} + +impl Default for MetadataCache { + fn default() -> Self { + Self::new() + } +} + +impl MetadataCache { + /// Maximum accepted size of metadata responses (32 MiB). + pub const METADATA_LIMIT: usize = 32 * 1024 * 1024; + /// Maximum accepted size of integrity downloads (50 MiB). + pub const INTEGRITY_LIMIT: usize = 50 * 1024 * 1024; + + /// Construct a cache with a shared client and ten concurrent GETs. + #[must_use] + pub fn new() -> Self { + Self::from_client(build_client()) + } + + /// A bounded cache which refuses redirects, for explicitly authorized + /// private endpoints. Credentials cannot follow a redirect to another URL. + #[must_use] + pub fn without_redirects() -> Self { + Self::from_client(crate::http::build_client_with_redirects( + reqwest::redirect::Policy::none(), + )) + } + + fn from_client(client: reqwest::Client) -> Self { + Self { + state: Arc::new(State { + client, + requests: Mutex::new(HashMap::new()), + semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_REQUESTS)), + cached_bytes: Arc::new(AtomicUsize::new(0)), + sequence: AtomicU64::new(0), + }), + } + } + + /// Fetch bounded bytes, reusing an equivalent GET (URL, headers, limit). + /// Failures are also shared for this execution and attributed to `name`. + /// No version-selection result is cached, so targets/pins stay independent. + /// + /// # Errors + /// Returns a registry diagnostic on HTTP/network failure or oversized data. + pub async fn get( + &self, + url: &str, + headers: HeaderMap, + limit: usize, + name: &str, + ) -> Result, DcuError> { + let mut header_key: Vec<_> = headers + .iter() + .map(|(name, v)| (name.as_str().to_owned(), v.as_bytes().to_vec())) + .collect(); + header_key.sort(); + let key = Key { + url: url.to_owned(), + headers: header_key, + limit, + }; + let (id, request) = { + let mut requests = self + .state + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(cached) = requests.get(&key) { + (cached.id, cached.request.clone()) + } else { + let client = self.state.client.clone(); + let semaphore = self.state.semaphore.clone(); + let cached_bytes = self.state.cached_bytes.clone(); + let url = url.to_owned(); + let id = self.state.sequence.fetch_add(1, Ordering::Relaxed); + let cacheable = requests.len() < MAX_KEYS; + let request = async move { + fetch( + &client, + &semaphore, + &cached_bytes, + &url, + headers, + limit, + cacheable, + ) + .await + } + .boxed() + .shared(); + if cacheable { + requests.insert( + key.clone(), + Cached { + id, + request: request.clone(), + }, + ); + } + (id, request) + } + }; + let result = request.await; + if result.as_ref().is_ok_and(|(_, retained)| !retained) { + let mut requests = self + .state + .requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if requests.get(&key).is_some_and(|cached| cached.id == id) { + requests.remove(&key); + } + } + result + .map(|(bytes, _)| bytes) + .map_err(|detail| DcuError::RegistryLookup { + package: name.to_owned(), + detail: detail.to_string(), + }) + } +} + +async fn fetch( + client: &reqwest::Client, + semaphore: &Semaphore, + cached_bytes: &AtomicUsize, + url: &str, + headers: HeaderMap, + limit: usize, + cacheable: bool, +) -> Response { + let _permit = semaphore + .acquire() + .await + .map_err(|e| Arc::::from(e.to_string()))?; + let mut response = client + .get(url) + .headers(headers) + .send() + .await + .map_err(|e| Arc::::from(e.without_url().to_string()))?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status()).into()); + } + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err("response exceeds size limit".into()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| Arc::::from(e.without_url().to_string()))? + { + if chunk.len() > limit.saturating_sub(bytes.len()) { + return Err("response exceeds size limit".into()); + } + bytes.extend_from_slice(&chunk); + } + let retained = cacheable + && cached_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { + used.checked_add(bytes.len()) + .filter(|n| *n <= MAX_CACHED_BYTES) + }) + .is_ok(); + Ok((bytes.into(), retained)) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, path}, + }; + + #[tokio::test] + async fn shares_requests_but_separates_headers_urls_and_executions() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/metadata")) + .and(header("accept", "application/json")) + .respond_with(ResponseTemplate::new(200).set_body_string("body")) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/metadata")) + .and(header("accept", "text/xml")) + .respond_with(ResponseTemplate::new(200).set_body_string("xml")) + .expect(1) + .mount(&server) + .await; + let cache = MetadataCache::new(); + let url = format!("{}/metadata", server.uri()); + let mut headers = HeaderMap::new(); + headers.insert("accept", "application/json".parse().unwrap()); + let results = futures::future::join_all( + (0..20).map(|_| cache.get(&url, headers.clone(), 100, "one")), + ) + .await; + assert!( + results + .iter() + .all(|r| r.as_ref().unwrap().as_ref() == b"body") + ); + headers.insert("accept", "text/xml".parse().unwrap()); + assert_eq!( + cache.get(&url, headers, 100, "two").await.unwrap().as_ref(), + b"xml" + ); + server.verify().await; + server.reset().await; + Mock::given(path("/metadata")) + .respond_with(ResponseTemplate::new(200).set_body_string("new")) + .expect(1) + .mount(&server) + .await; + assert_eq!( + MetadataCache::new() + .get(&url, HeaderMap::new(), 100, "new") + .await + .unwrap() + .as_ref(), + b"new" + ); + } + + #[tokio::test] + async fn failed_requests_are_shared_and_errors_keep_each_consumers_name() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/failed")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + let cache = MetadataCache::new(); + let url = format!("{}/failed", server.uri()); + let (a, b) = futures::join!( + cache.get(&url, HeaderMap::new(), 100, "first"), + cache.get(&url, HeaderMap::new(), 100, "second") + ); + assert!(a.unwrap_err().to_string().contains("first")); + assert!(b.unwrap_err().to_string().contains("second")); + } + + #[tokio::test] + async fn response_limits_are_enforced_and_not_shared_with_other_limits() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/large")) + .respond_with(ResponseTemplate::new(200).set_body_string("1234567890")) + .expect(2) + .mount(&server) + .await; + let cache = MetadataCache::new(); + let url = format!("{}/large", server.uri()); + assert!( + cache + .get(&url, HeaderMap::new(), 4, "small") + .await + .unwrap_err() + .to_string() + .contains("size limit") + ); + assert!(cache.get(&url, HeaderMap::new(), 20, "large").await.is_ok()); + } + + #[tokio::test] + async fn chunked_bodies_cannot_bypass_the_response_size_limit() { + use std::io::{Read, Write}; + let _ = rustls::crypto::ring::default_provider().install_default(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/stream", listener.local_addr().unwrap()); + let peer = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut request = [0; 4096]; + assert!(stream.read(&mut request).unwrap() > 0); + stream.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\na\r\n1234567890\r\n0\r\n\r\n").unwrap(); + }); + let cache = MetadataCache::default(); + assert!( + cache + .get(&url, HeaderMap::new(), 4, "streamed") + .await + .unwrap_err() + .to_string() + .contains("size limit") + ); + peer.join().unwrap(); + } + + #[tokio::test] + async fn authorization_and_repository_urls_are_distinct_cache_keys() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + for (url, token, body) in [ + ("/one", "Bearer one", "first"), + ("/one", "Bearer two", "second"), + ("/two", "Bearer one", "third"), + ] { + Mock::given(path(url)) + .and(header("authorization", token)) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + } + let cache = MetadataCache::new(); + for (url, token, body) in [ + ("/one", "Bearer one", "first"), + ("/one", "Bearer two", "second"), + ("/two", "Bearer one", "third"), + ] { + let mut headers = HeaderMap::new(); + headers.insert("authorization", token.parse().unwrap()); + assert_eq!( + cache + .get(&format!("{}{url}", server.uri()), headers, 100, "consumer") + .await + .unwrap() + .as_ref(), + body.as_bytes() + ); + } + } + + #[tokio::test] + async fn exhausted_byte_budget_does_not_break_lookups_or_retain_more_bodies() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/body")) + .respond_with(ResponseTemplate::new(200).set_body_string("body")) + .expect(2) + .mount(&server) + .await; + let cache = MetadataCache::new(); + cache + .state + .cached_bytes + .store(MAX_CACHED_BYTES, Ordering::Relaxed); + let url = format!("{}/body", server.uri()); + for _ in 0..2 { + assert_eq!( + cache + .get(&url, HeaderMap::new(), 100, "consumer") + .await + .unwrap() + .as_ref(), + b"body" + ); + } + assert!(cache.state.requests.lock().unwrap().is_empty()); + assert_eq!( + cache.state.cached_bytes.load(Ordering::Relaxed), + MAX_CACHED_BYTES + ); + } + + #[tokio::test] + async fn canceling_last_caller_does_not_keep_cache_state_in_a_reference_cycle() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/slow")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("body") + .set_delay(std::time::Duration::from_secs(1)), + ) + .mount(&server) + .await; + let cache = MetadataCache::new(); + let weak = Arc::downgrade(&cache.state); + let url = format!("{}/slow", server.uri()); + { + let pending = cache.get(&url, HeaderMap::new(), 100, "consumer"); + futures::pin_mut!(pending); + assert!(futures::poll!(pending).is_pending()); + } + drop(cache); + assert!(weak.upgrade().is_none()); + } + + #[tokio::test] + async fn exhausted_key_budget_keeps_request_count_bounded_without_failing_new_lookups() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let server = MockServer::start().await; + Mock::given(path("/new")) + .respond_with(ResponseTemplate::new(200).set_body_string("body")) + .expect(2) + .mount(&server) + .await; + let cache = MetadataCache::new(); + for i in 0..MAX_KEYS { + let key = Key { + url: format!("http://unused.invalid/{i}"), + headers: Vec::new(), + limit: 100, + }; + let request = async { Err(Arc::::from("cached failure")) } + .boxed() + .shared(); + cache + .state + .requests + .lock() + .unwrap() + .insert(key, Cached { id: 0, request }); + } + let url = format!("{}/new", server.uri()); + for _ in 0..2 { + assert!( + cache + .get(&url, HeaderMap::new(), 100, "consumer") + .await + .is_ok() + ); + } + assert_eq!(cache.state.requests.lock().unwrap().len(), MAX_KEYS); + assert_eq!(cache.state.cached_bytes.load(Ordering::Relaxed), 0); + } +} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 94e3292..1b513c9 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -22,6 +22,16 @@ pub enum ManifestKind { /// Docker Compose project file (`compose.y(a)ml`, /// `docker-compose.y(a)ml`, and their `..` variants). DockerCompose, + /// Gradle Groovy or Kotlin build/settings scripts. + Gradle, + /// Gradle version catalog. + GradleCatalog, + /// Project Gradle properties (version references only). + GradleProperties, + /// Gradle distribution and optional checksum. + GradleWrapper, + /// Project development tool pins and channels. + ToolVersions, } impl ManifestKind { @@ -38,6 +48,21 @@ impl ManifestKind { let file_name = path.file_name()?.to_str()?; match file_name { "package.json" => Some(Self::PackageJson), + "build.gradle" | "build.gradle.kts" | "settings.gradle" | "settings.gradle.kts" => { + Some(Self::Gradle) + } + "libs.versions.toml" if path.parent()?.file_name()?.to_str()? == "gradle" => { + Some(Self::GradleCatalog) + } + "gradle.properties" => Some(Self::GradleProperties), + "gradle-wrapper.properties" => Some(Self::GradleWrapper), + ".nvmrc" + | ".node-version" + | "rust-toolchain" + | "rust-toolchain.toml" + | ".tool-versions" + | "mise.toml" + | ".mise.toml" => Some(Self::ToolVersions), "Cargo.toml" => Some(Self::CargoToml), "pyproject.toml" => Some(Self::PyProjectToml), "action.yml" | "action.yaml" => Some(Self::GitHubWorkflow), @@ -114,6 +139,11 @@ impl std::fmt::Display for ManifestKind { Self::GitHubWorkflow => write!(f, "GitHub workflow"), Self::Dockerfile => write!(f, "Dockerfile"), Self::DockerCompose => write!(f, "Docker Compose"), + Self::Gradle => write!(f, "Gradle"), + Self::GradleCatalog => write!(f, "Gradle version catalog"), + Self::GradleProperties => write!(f, "Gradle properties"), + Self::GradleWrapper => write!(f, "Gradle wrapper"), + Self::ToolVersions => write!(f, "Development tools"), } } } @@ -149,6 +179,14 @@ pub enum DependencySection { /// Container image references: Dockerfile `FROM` instructions and the /// `image:` key of Compose services / workflow job containers. DockerImage, + /// Maven artifact declarations. + Maven, + /// Gradle plugin marker declarations. + GradlePlugin, + /// Android compile/target SDK levels, never minSdk. + AndroidSdk, + /// Project tool version pins (not support ranges). + Toolchain, } impl DependencySection { @@ -165,6 +203,10 @@ impl DependencySection { Self::ProjectDependencies => "project.dependencies", Self::GitHubActions => "uses", Self::DockerImage => "image", + Self::Maven => "maven", + Self::GradlePlugin => "plugins", + Self::AndroidSdk => "android-sdk", + Self::Toolchain => "toolchain", } } } @@ -272,6 +314,17 @@ pub enum BumpType { #[cfg(test)] mod tests { + #[test] + fn project_manifest_names_are_distinct_and_human_readable() { + for (kind, label) in [ + (super::ManifestKind::GradleCatalog, "Gradle version catalog"), + (super::ManifestKind::GradleProperties, "Gradle properties"), + (super::ManifestKind::GradleWrapper, "Gradle wrapper"), + (super::ManifestKind::ToolVersions, "Development tools"), + ] { + assert_eq!(kind.to_string(), label); + } + } use super::*; use rstest::rstest; use std::str::FromStr; diff --git a/crates/github/src/registry.rs b/crates/github/src/registry.rs index 96732a2..6d58c68 100644 --- a/crates/github/src/registry.rs +++ b/crates/github/src/registry.rs @@ -273,10 +273,10 @@ impl GitHubActionsRegistry { 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) { - if seen.insert(key) { - unique_repos.push(key.to_owned()); - } + if let Some(key) = Self::repo_key(&dep.name) + && seen.insert(key) + { + unique_repos.push(key.to_owned()); } } diff --git a/crates/node/src/parser.rs b/crates/node/src/parser.rs index d6211b6..3528bc1 100644 --- a/crates/node/src/parser.rs +++ b/crates/node/src/parser.rs @@ -47,15 +47,15 @@ impl PackageJsonManifest { for &(section, key) in DEPENDENCY_SECTIONS { if let Some(Value::Object(map)) = root.get(key) { for (name, value) in map { - if let Some(version_str) = value.as_str() { - if is_version_spec(version_str) { - deps.push(DependencySpec { - name: name.clone(), - current_req: version_str.to_owned(), - section, - path_version: None, - }); - } + if let Some(version_str) = value.as_str() + && is_version_spec(version_str) + { + deps.push(DependencySpec { + name: name.clone(), + current_req: version_str.to_owned(), + section, + path_version: None, + }); } // Non-string values (object form like { "version": "^1.0" }) are skipped. } diff --git a/crates/node/src/patcher.rs b/crates/node/src/patcher.rs index 84d68ca..175b46d 100644 --- a/crates/node/src/patcher.rs +++ b/crates/node/src/patcher.rs @@ -75,17 +75,17 @@ impl JsonPatcher { // For each dependency in this section, find its value position for (dep_name, dep_value) in deps { - if let Some(version_str) = dep_value.as_str() { - if let Some(loc) = find_dep_value_position( + if let Some(version_str) = dep_value.as_str() + && let Some(loc) = find_dep_value_position( text, obj_start, obj_end, dep_name, version_str, section, - ) { - locations.push(loc); - } + ) + { + locations.push(loc); } } } diff --git a/crates/node/src/registry.rs b/crates/node/src/registry.rs index ffadc5d..ee848b3 100644 --- a/crates/node/src/registry.rs +++ b/crates/node/src/registry.rs @@ -4,21 +4,20 @@ 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, current_req_is_prerelease, send_checked, + DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, MetadataCache, ResolvedVersion, + TargetLevel, current_req_is_prerelease, }; /// npm registry client for looking up package versions. #[derive(Clone)] pub struct NpmRegistry { - client: Client, + cache: MetadataCache, semaphore: Arc, base_url: Arc, } @@ -105,8 +104,14 @@ impl NpmRegistry { /// Panics if the HTTP client cannot be built (should never happen with default settings). #[must_use] pub fn with_base_url(base_url: &str) -> Self { + Self::with_cache(base_url, MetadataCache::new()) + } + + /// Construct a registry sharing run-scoped metadata with other clients. + #[must_use] + pub fn with_cache(base_url: &str, cache: MetadataCache) -> Self { Self { - client: build_client(), + cache, semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_REQUESTS)), base_url: Arc::from(base_url.trim_end_matches('/')), } @@ -151,10 +156,13 @@ impl NpmRegistry { "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*" }; - let request = self.client.get(&url).header("Accept", accept); - let response = send_checked(request, name).await?; - - response.json().await.map_err(|e| DcuError::RegistryLookup { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("accept", accept.parse().expect("static Accept header")); + let response = self + .cache + .get(&url, headers, MetadataCache::METADATA_LIMIT, name) + .await?; + serde_json::from_slice(&response).map_err(|e| DcuError::RegistryLookup { package: name.to_owned(), detail: format!("failed to parse response: {e}"), }) diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index e9bdfff..2ba1065 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -17,6 +17,7 @@ dependency-check-updates-core.workspace = true 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 diff --git a/crates/python/src/parser.rs b/crates/python/src/parser.rs index 508c443..84d6497 100644 --- a/crates/python/src/parser.rs +++ b/crates/python/src/parser.rs @@ -61,14 +61,14 @@ impl PyProjectManifest { // `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(t) = poetry.get("dependencies").and_then(Item::as_table) { - collect_poetry_table(t, DependencySection::Dependencies, &mut deps); - } - if let Some(t) = poetry.get("dev-dependencies").and_then(Item::as_table) { - collect_poetry_table(t, DependencySection::DevDependencies, &mut deps); - } + if let Some(tool) = doc.get("tool").and_then(Item::as_table) + && let Some(poetry) = tool.get("poetry").and_then(Item::as_table) + { + if let Some(t) = poetry.get("dependencies").and_then(Item::as_table) { + collect_poetry_table(t, DependencySection::Dependencies, &mut deps); + } + if let Some(t) = poetry.get("dev-dependencies").and_then(Item::as_table) { + collect_poetry_table(t, DependencySection::DevDependencies, &mut deps); } } @@ -95,10 +95,10 @@ impl PyProjectManifest { fn apply_single_update(&mut self, update: &PlannedUpdate) { // 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) { - if apply_to_pep508_array(dep_array, update) { - return; - } + if let Some(dep_array) = project.get_mut("dependencies").and_then(Item::as_array_mut) + && 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 @@ -109,10 +109,10 @@ impl PyProjectManifest { .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; - } + if let Some(arr) = items.as_array_mut() + && apply_to_pep508_array(arr, update) + { + return; } } } @@ -125,10 +125,10 @@ impl PyProjectManifest { .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; - } + if let Some(arr) = items.as_array_mut() + && apply_to_pep508_array(arr, update) + { + return; } } } @@ -140,19 +140,19 @@ impl PyProjectManifest { // 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 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); - } + if let Some(tool) = self.doc.get_mut("tool").and_then(Item::as_table_mut) + && 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) + && 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); } } @@ -178,10 +178,10 @@ fn collect_pep508_array( 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); - } + if let Some(spec_str) = item.as_str() + && let Some(dep) = parse_pep508_spec(spec_str, section) + { + deps.push(dep); } } } diff --git a/crates/python/src/registry.rs b/crates/python/src/registry.rs index 839fe1a..d888f1b 100644 --- a/crates/python/src/registry.rs +++ b/crates/python/src/registry.rs @@ -3,20 +3,19 @@ use std::str::FromStr; use std::sync::Arc; -use reqwest::Client; 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, current_req_is_prerelease, parse_and_select, send_checked, + DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, MetadataCache, ResolvedVersion, + TargetLevel, current_req_is_prerelease, parse_and_select, }; /// `PyPI` registry client. #[derive(Clone)] pub struct PyPiRegistry { - client: Client, + cache: MetadataCache, semaphore: Arc, base_url: Arc, } @@ -70,8 +69,14 @@ impl PyPiRegistry { /// Panics if the HTTP client cannot be built. #[must_use] pub fn with_base_url(base_url: &str) -> Self { + Self::with_cache(base_url, MetadataCache::new()) + } + + /// Share run-scoped, bounded metadata requests with other registry clients. + #[must_use] + pub fn with_cache(base_url: &str, cache: MetadataCache) -> Self { Self { - client: build_client(), + cache, semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_REQUESTS)), base_url: Arc::from(base_url.trim_end_matches('/')), } @@ -93,10 +98,16 @@ impl PyPiRegistry { let url = format!("{}/{normalized}/json", self.base_url); debug!(package = name, %url, "fetching PyPI package info"); - let request = self.client.get(&url); - let response = send_checked(request, name).await?; - - response.json().await.map_err(|e| DcuError::RegistryLookup { + let response = self + .cache + .get( + &url, + reqwest::header::HeaderMap::new(), + MetadataCache::METADATA_LIMIT, + name, + ) + .await?; + serde_json::from_slice(&response).map_err(|e| DcuError::RegistryLookup { package: name.to_owned(), detail: format!("failed to parse response: {e}"), }) @@ -270,6 +281,30 @@ mod tests { } } + #[tokio::test] + async fn shared_metadata_keeps_normalized_names_and_pin_specific_targets() { + install_crypto_provider(); + let server = MockServer::start().await; + Mock::given(path("/my-package/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "info":{"version":"2.0.1"},"releases":{"1.0.1":[{}],"1.2.0":[{}],"2.0.1":[{}]} + }))) + .expect(1) + .mount(&server) + .await; + let cache = dependency_check_updates_core::MetadataCache::new(); + let one = PyPiRegistry::with_cache(&server.uri(), cache.clone()); + let two = PyPiRegistry::with_cache(&server.uri(), cache); + let first = make_dep("My_Package", "1.0.0"); + let second = make_dep("my-package", "2.0.0"); + let (a, b) = tokio::join!( + one.resolve_version(&first, TargetLevel::Patch), + two.resolve_version(&second, TargetLevel::Patch) + ); + assert_eq!(a.unwrap().selected.as_deref(), Some("1.0.1")); + assert_eq!(b.unwrap().selected.as_deref(), Some("2.0.1")); + } + #[test] fn normalized_name_converts_underscores_and_case() { // PyPI normalizes names: underscores -> hyphens, lowercase diff --git a/crates/rust/Cargo.toml b/crates/rust/Cargo.toml index f8dfd90..a8bdd45 100644 --- a/crates/rust/Cargo.toml +++ b/crates/rust/Cargo.toml @@ -16,6 +16,7 @@ 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.workspace = true diff --git a/crates/rust/src/parser.rs b/crates/rust/src/parser.rs index 5d80264..cdd92d2 100644 --- a/crates/rust/src/parser.rs +++ b/crates/rust/src/parser.rs @@ -64,15 +64,15 @@ impl CargoTomlManifest { } // Also check [workspace.dependencies] - if let Some(ws) = doc.get("workspace").and_then(Item::as_table) { - if let Some(ws_deps) = ws.get("dependencies").and_then(Item::as_table) { - Self::collect_from_table( - ws_deps, - DependencySection::WorkspaceDependencies, - manifest_dir, - &mut deps, - ); - } + if let Some(ws) = doc.get("workspace").and_then(Item::as_table) + && let Some(ws_deps) = ws.get("dependencies").and_then(Item::as_table) + { + Self::collect_from_table( + ws_deps, + DependencySection::WorkspaceDependencies, + manifest_dir, + &mut deps, + ); } deps @@ -129,12 +129,11 @@ impl CargoTomlManifest { DependencySection::BuildDependencies => "build-dependencies", DependencySection::WorkspaceDependencies => { // Handle workspace.dependencies separately - if let Some(ws) = self.doc.get_mut("workspace").and_then(Item::as_table_mut) { - if let Some(ws_deps) = + if let Some(ws) = self.doc.get_mut("workspace").and_then(Item::as_table_mut) + && let Some(ws_deps) = ws.get_mut("dependencies").and_then(Item::as_table_mut) - { - Self::update_dep_in_table(ws_deps, &update.name, &update.to)?; - } + { + Self::update_dep_in_table(ws_deps, &update.name, &update.to)?; } continue; } @@ -337,19 +336,17 @@ 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 let Ok(text) = std::fs::read_to_string(&cargo_path) + && let Ok(doc) = text.parse::() + && 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; diff --git a/crates/rust/src/registry.rs b/crates/rust/src/registry.rs index 8738c05..99b6ecb 100644 --- a/crates/rust/src/registry.rs +++ b/crates/rust/src/registry.rs @@ -2,20 +2,19 @@ use std::sync::Arc; -use reqwest::Client; 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, send_checked, + DEFAULT_MAX_CONCURRENT_REQUESTS, DcuError, DependencySpec, MetadataCache, ResolvedVersion, + TargetLevel, }; /// crates.io registry client. #[derive(Clone)] pub struct CratesIoRegistry { - client: Client, + cache: MetadataCache, semaphore: Arc, base_url: Arc, } @@ -49,8 +48,14 @@ impl CratesIoRegistry { /// Panics if the HTTP client cannot be built. #[must_use] pub fn with_base_url(base_url: &str) -> Self { + Self::with_cache(base_url, MetadataCache::new()) + } + + /// Share run-scoped, bounded metadata requests with other registry clients. + #[must_use] + pub fn with_cache(base_url: &str, cache: MetadataCache) -> Self { Self { - client: build_client(), + cache, semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_REQUESTS)), base_url: Arc::from(base_url.trim_end_matches('/')), } @@ -70,17 +75,20 @@ impl CratesIoRegistry { let url = format!("{}/crates/{name}/versions", self.base_url); debug!(crate_name = name, %url, "fetching crate versions"); - let request = self.client.get(&url); - let response = send_checked(request, name).await?; - + let response = self + .cache + .get( + &url, + reqwest::header::HeaderMap::new(), + MetadataCache::METADATA_LIMIT, + name, + ) + .await?; let resp: CratesIoResponse = - response - .json() - .await - .map_err(|e| DcuError::RegistryLookup { - package: name.to_owned(), - detail: format!("failed to parse response: {e}"), - })?; + serde_json::from_slice(&response).map_err(|e| DcuError::RegistryLookup { + package: name.to_owned(), + detail: format!("failed to parse response: {e}"), + })?; Ok(resp.versions) } @@ -247,6 +255,26 @@ mod tests { } } + #[tokio::test] + async fn shared_metadata_preserves_pin_specific_targets_with_one_request() { + install_tls_provider(); + let server = MockServer::start().await; + Mock::given(path("/crates/serde/versions")).respond_with(ResponseTemplate::new(200).set_body_json(json!({"versions":[ + {"num":"1.0.1","yanked":false},{"num":"1.2.0","yanked":false},{"num":"2.0.1","yanked":false} + ]}))).expect(1).mount(&server).await; + let cache = MetadataCache::new(); + let one = CratesIoRegistry::with_cache(&server.uri(), cache.clone()); + let two = CratesIoRegistry::with_cache(&server.uri(), cache); + let first = serde_dep("1.0.0"); + let second = serde_dep("2.0.0"); + let (a, b) = tokio::join!( + one.resolve_version(&first, TargetLevel::Patch), + two.resolve_version(&second, TargetLevel::Patch) + ); + assert_eq!(a.unwrap().selected.as_deref(), Some("1.0.1")); + assert_eq!(b.unwrap().selected.as_deref(), Some("2.0.1")); + } + async fn mock_versions_endpoint( server: &MockServer, crate_name: &str,